Native AOT 最初在 .NET 7 中引入,在即将发布的 .NET 8 版本中可以与 ASP.NET Core 一起使用。在这篇文章中,我们从总体角度审视其优点和缺点,并进行测量以量化不同平台上的改进。
源代码:https://download.csdn.net/download/hefeng_aspnet/88689164
介绍
.NET 8 版本即将发布,将为 ASP.NET Core带来一项新功能:本机提前编译,简称本机 AOT 。通常,在构建和部署 ASP.NET Core 应用程序时,C# 代码首先由 Roslyn(C# 编译器)编译为 Microsoft 中间语言 (MSIL)。在运行时,公共语言运行时 (CLR ) 的即时编译器 (JIT) 将 MSIL 编译为其运行平台的本机代码(遵循著名的“编译一次,到处运行”原则)。本机 AOT 会将您的代码直接编译为选定目标平台的本机代码 - 运行时不再涉及 JIT。微软承诺更小的磁盘上的应用程序大小、更快的启动时间以及更少的执行期间的内存消耗。
在这篇文章中,我们将从一般角度探讨本机 AOT 的优点和缺点,所以让我们深入研究它。
本土化
首先,我创建一个.NET 8 ASP.NET Core 空 Web 应用程序项目,并 通过在 csproj 文件中设置PublishAot MSBuild 属性来激活本机 AOT 。我还禁用了<ImplicitUsings>,以便您可以看到引用代码的实际 using 语句,并在 <InvariantGlobalization>模式下运行,以消除特定于区域性的开销并简化部署 - 您可以在此处阅读有关它的更多信息。
当您现在运行应用程序时(例如从 IDE 或使用 dotnet run),您将体验不到任何差异。本机 AOT 仅在您发布应用程序时编译为本机代码。在这篇介绍性文章中,我想进行一些测量以确定本机 AOT 和 JIT CLR 部署之间的差异。为此,我将稍微修改 Program.cs:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<DefineConstants Condition="'$(PublishAot)' == 'true'">
$(DefineConstants);IS_NATIVE_AOT
</DefineConstants>
</PropertyGroup>
</Project>
using System;
using System.Diagnostics;
using Microsoft.AspNetCore.Builder;
long initialTimestamp = Stopwatch.GetTimestamp();
var builder =
#if IS_NATIVE_AOT
WebApplication.CreateSlimBuilder(args);
#else
WebApplication.CreateBuilder(args);
#endif
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
await app.StartAsync();
TimeSpan elapsedStartupTime = Stopwatch.GetElapsedTime(initialTimestamp);
Console.WriteLine($"Startup took {elapsedStartupTime.TotalMilliseconds:N3}ms");
double workingSet = Process.GetCurrentProcess().WorkingSet64;
Console.WriteLine($"Working Set: {workingSet / (1024 * 1024):N2}MB");
await app.StopAsync();
首先要注意的是在 Native AOT 模式下使用WebApplicaiton.CreateSlimBuilder而不是WebApplication.CreateBuilder – 这将减少默认注册的 ASP.NET Core 功能 数量,从而减少应用程序大小和内存使用量。我插入了对 Stopwatch.GetTimestamp和Stopwatch.GetElapsedTime的调用 来测量 Web 服务器的启动时间。 请注意,我还将调用从app.Run更改为app.StartAsync - 这样,我们不会阻塞执行线程,直到 Web 应用程序实际关闭,而只是直到 HTTP 服务器成功启动为止。此外,我们还获取进程的WorkingSet64 RAM 使用值并打印出已用内存(以兆字节为单位)。测量后,我们使用app.StopAsync正常关闭 Web 应用程序。
现在让我们发布应用程序两次,一次启用 Native AOT,一次在常规 CLR 模式下(只需在 csproj 文件旁边的 bash/终端中执行这些语句):
# If you follow along, please execute the following statements in the same "WebApp" folder
# This call will have PublishAot set to true because we defined it previously in the csproj file
dotnet publish -c Release -o ./bin/native-aot
# For this call, we will explicitly set PublishAot to false via cmd arguments
dotnet publish -c Release -o ./bin/regular-clr /p:PublishAot=false
您会注意到,由于生成本机代码,第一个命令比第二个命令花费的时间更长。当您导航到 ./bin/native-aot 子文件夹并查看其内容时,您将看到调试符号 (.pdb) 和 appsettings.json 文件旁边只有一个 EXE 文件 – 所有 DLL已合并到可执行文件中,因为本机 AOT 涉及自包含、单文件部署和修剪。只需将其与常规 clr 子文件夹的内容进行比较,您就可以在较小的 EXE 文件旁边找到 DLL。
# On Ubuntu 22.04:
bin
├─ native-aot
│ ├─ WebApp # Executable, 9.79MB
│ ├─ WebApp.dbg # debugging symbols, 33.02MB
│ └─ appsettings.json # 142B
└─ regular-clr
├─ WebApp # Executable, 70.96KB
├─ WebApp.deps.json # runtime and hosting information, 388B
├─ WebApp.dll # Compiled code of our project, 7KB
├─ WebApp.pdb # debgging symbols, 20.5KB
├─ WebApp.runtimeconfig.json # runtime information, 600B
├─ appsettings.json # 142B
└─ web.config # IIS configuration, 482B
# On Windows 11
bin
└─ native-aot
├─ WebApp.exe # Executable, 8.77MB
├─ WebApp.pdb # debugging symbols, 102.83MB
└─ appsettings.json # 151B
# The regular-clr folder on Windows is omitted here, it contains the same files with roughly the same sizes
设置应用程序后,我们现在可以对 Microsoft 声称在 Native AOT 中改进的内容进行一些测量:启动时间、内存使用情况和应用程序大小。请记住,所有这些值很大程度上取决于实际实现,例如您分配的对象数量、启动期间执行的语句以及您引用的框架和库。即将推出的图表仅类似于我们的小型简单应用程序 - 尽管如此,我们可以使用它来了解其优点以及它们在不同平台上的表现。
启动时间
现在,我们可以多次执行应用程序以了解启动时间。我启动每个应用程序至少 10 次,然后对结果值取平均值。我的初始测试是在以下目标上执行的:
基于 Ubuntu-22.04-Jammy 的 Docker 容器(在基于 WSL 2 的引擎上运行)
基于 Alpine-3.18 的 Docker 容器(在基于 WSL 2 的引擎上运行)
WSL 2 上的 Ubuntu 22.04
Windows 11
我们可以看到,Native AOT 的启动时间在 Linux 上约为 14 毫秒,在 Windows 11 上约为 17 毫秒。对于常规 CLR 部署,Ubuntu 上的启动时间约为 70 毫秒,Windows 上约为 80 毫秒。对于运行常规 CRL 构建的基于 Alpine-3.18 的 Docker 容器,存在大约 180 毫秒的巨大异常值,因此我在 Raspberry Pi 4 上重新执行了等效测试 - 这样我们还可以掌握功率较小的机器:
在 Raspberry Pi 4 的 ARM Cortex-A72 芯片上,Native AOT 启动时间仅不到 90ms。常规 CLR 构建的启动时间约为 530 毫秒。我们仍然有一个异常值,在 Alpine 3.18 Docker 容器中运行的常规 CLR 构建大约需要 600 毫秒(我还在 Azure 中的专用 Ubuntu 22.04 VM 上检查了这一点,并得到了与 Windows 上相同的结果)。
总体而言,结果表明本机 AOT 启动时间比使用带有 JIT 的常规 CLR 运行速度大约快四到六倍(如果我们计算出 Alpine 异常值)。
内存使用情况
让我们看一下 ASP.NET Core 应用程序的内存消耗:
正如我们所看到的,根据底层架构的不同,结果会略有不同。对于本机 AOT,基于 ARM 的应用程序需要的内存量最少,约为 17MB 到 18MB,而在基于 x64 的 Linux 上,工作集范围约为 19MB 到 21MB。Windows x64 的最大工作集接近 23MB。
与常规 CLR 构建相比,这些仍然是很大的改进:在 Linux 上,工作集减少了 50% 以上,因为常规 CLR 构建占用超过 50MB 的内存。在 Windows 上,CLR JIT 内存消耗略低于 40MB,因此收益并没有那么大。请记住,这些只是启动 Kestrel(ASP.NET Core 的 HTTP 服务器)后的数字 - 这些值将根据您在程序执行期间执行的分配而有很大差异(.NET 垃圾收集器没有任何变化)本机 AOT)。
应用大小
那么应用程序的大小又如何呢?调用 dotnetpublish 后应用程序有多大?
仅编译为 MSIL 会产生非常小的二进制文件大小,如上图所示。常规 CLR 部署在 Linux 上的大小仅为 82kB 左右,在 Windows 上为 145kB。相比之下,本机 AOT 在 Linux 上大约需要 10MB,在 Windows 上大约需要 9MB。然而,Native AOT 完全包含了所有必需的运行时组件,因此当我们添加实际的依赖项来运行我们的应用程序时,情况会发生巨大变化。让我们看一下 Docker 镜像的大小:
在上图中,我们看到,特别是基于 Alpine 的 Docker 镜像在使用 Native AOT 时非常小。对于包含 我们的准系统 ASP.NET Core 应用程序的图像来说,大约 18MB 是一个惊人的值,大约是常规 CLR 构建的图像大小的 15%。基于 Ubuntu 的镜像也有类似的情况:它们的大小在 80MB 到 90MB 之间,不到常规 216MB 镜像大小的 40%。所有这些都减少了部署的有效负载,尤其是在云原生场景中。
用于本机 AOT 的 Dockerfile
您可能想知道如何自己为 Native AOT 构建上述基于 Alpine 的 Dockerfile?让我们看一下:
FROM alpine:3.18 AS prepare
WORKDIR /app
RUN adduser -u 1000 --disabled-password --gecos "" appuser && chown -R appuser /app
USER appuser
FROM mcr.microsoft.com/dotnet/sdk:8.0.100-rc.2-alpine3.18 AS build
RUN apk update && apk upgrade
RUN apk add --no-cache clang build-base zlib-dev
WORKDIR /code
COPY ./WebApp.csproj .
ARG RUNTIME_ID=linux-musl-x64
RUN dotnet restore -r $RUNTIME_ID
COPY . .
RUN dotnet publish \
-c Release \
-r $RUNTIME_ID \
-o /app \
--no-restore
FROM prepare AS final
COPY --chown=appuser --from=build /app/WebApp ./WebApp
ENTRYPOINT ["./WebApp"]
在第一个“准备”阶段,我们将从基本的 Alpine 3.18 镜像开始,该镜像默认使用 root 用户。为了避免以特权运行我们的 Web 应用程序,我们首先创建一个 /app 目录,编译后的 Web 应用程序将复制到其中,然后运行 adduser 命令来创建具有较少权限的标准用户。我们使用以下参数:
-u 1000 设置新用户的ID
–disabled-password 不再需要新用户的密码
–gecos “”是设置帐户的一些常规信息(如“真实姓名”或“电话号码”)的简短方法 – 由于我们只传递空字符串,相应的字段将被初始化为空,操作系统不会询问这些信息用户首次登录时的信息
然后,我们立即使用chown -R appuser /app将 app 文件夹的权限移交给这个新用户,并使用USER appuser切换到它。
然后是重要的“构建”阶段,我们使用 Native AOT 发布应用程序。我们从 mcr.microsoft.com/dotnet/sdk:8.0.100-rc.2-alpine3.18映像开始(.NET 8 正式发布后,您很可能可以使用 mcr.microsoft.com/dotnet/sdk :8.0-alpine3.18 映像,请参阅 Microsoft Artifact Registry了解详细信息)并使用apk update && apk Upgrade更新和升级 Alpine 的 APK 包管理器的存储库,以安装 clang、build-base 和 zlib-dev。在 Alpine 上构建本机 AOT 需要这些包 - 您可以在这篇 Microsoft Learn 文章中找到有关本机 AOT 构建先决条件的信息。
最后,我们恢复NuGet包并跨多个层发布,以便可以缓存第一步以减少重建图像所需的时间。我们只需复制 csproj 文件,然后对其调用dotnet Restore ,并使用$RUNTIME_ID构建参数传入运行时标识符。默认情况下,它设置为 linux-musl-x64,但您可以轻松地将其更改为 linux-musl-arm64 来构建基于 arm 的系统。然后,我们复制其余的源文件并调用dotnetpublish在一个 Dockerfile 层中以发布模式构建和发布整个应用程序 - 请记住,PublishAot在我们的 csproj 文件中已打开。
然后,发布的本机 AOT 应用程序驻留在 /app 顶级文件夹中。我们可以简单地使用COPY –chown=appuser –from=build /app/WebApp ./WebApp命令将其从构建阶段复制到最后阶段 (该命令还将所有权一次性转移给 appuser)。请注意,我们仅复制单个可执行文件,而不是 .dbg 文件,也没有 appsettings.json 文件(您希望使用环境变量来配置应用程序)。此外,我们的本机 AOT 部署没有进一步的运行时依赖项- 我们的应用程序在普通的 Alpine 3.18 映像上运行,只需复制并执行二进制文件即可。
缺点
因此,我们看到启动时间、内存使用量和 Docker 映像大小显着减少。我们不再需要打包任何运行时依赖项。我们现在应该切换到 Native AOT 吗?可能不会,因为您将放弃 .NET 生态系统中您可能了解和喜爱的许多功能。
最重要的是:你不能使用未绑定的反射。本机 AOT 应用程序在发布期间会被修剪,即静态代码分析器将检查所有程序集中的类型及其成员,并确定它们是否被调用。这样做是因为本机代码指令比 MSIL 指令占用更多的空间,因此获得合理的二进制大小的唯一方法是在本机代码生成期间删除未使用的代码。
不幸的是,这有 一些警告,其中一些是:
1、不使用未绑定的反射,例如基于反射的 DTO 反序列化
2、没有运行时代码生成
3、不动态加载程序集(即不支持插件式应用程序)
静态代码分析器在很大程度上取决于代码的调用结构,解决诸如反射调用之类的复杂问题既不容易完成,也无法在合理的时间范围内完成。然而,这导致许多依赖反射的框架与本机 AOT 部署不兼容:
1、无法使用像 Newtonsoft.Json 这样在序列化和反序列化过程中依赖反射的序列化器。DTO 的 JSON 序列化仅适用于 System.Text.Json 与 Roslyn Source Generators 的结合。这与 IConfiguration 的反序列化是一样的:微软引入了 一个专用的源生成器 ,它发出反序列化代码以避免未绑定的反射。
2、同样,像 Entity Framework Core 和 Dapper 这样的对象/关系映射器 (ORM) 依赖使用未绑定反射来实例化实体并填充其属性,它们也不与本机 AOT 兼容。他们都计划添加对源生成器的支持,但这在 .NET 8 发布时还没有准备好。
3、不支持 ASP.NET Core MVC 和 SignalR – 仅最小 API 和 GRPC 可用于创建端点和流数据。有一个最小 API 源生成器,可以使用 MapXXX 方法处理端点注册。
4、不直接支持本机代码的自动化测试:像 xunit 和 nunit 这样的单元测试框架都无法在本机 AOT 模式下运行。目前,.NET 团队专注于 分析器方法 ,因此当您构建应用程序并运行测试时,Roslyn 分析器将创建警告,提示在本机 AOT 模式下运行时的潜在问题。请注意,当您在 IDE 中使用 dotnet run、dotnet test 或类似功能时,您的应用程序将以常规 CLI 模式运行 - 本机 AOT 仅在您的应用程序发布时才会生效。
5、本机代码的有限调试:发布后,您的应用程序是真正的本机二进制文件,因此 C# 的托管调试器将无法使用它。部署后我们需要使用gdb或WinDbg等调试器。您还需要调试符号(.dbg 或 .pdb 文件)以获得有用的调试体验。
这些只是 Native AOT 的主要缺点。一般来说,您需要检查您想要合并的每个框架或库的本机 AOT 兼容性。严重依赖反射的流行框架需要在编译时用某种形式的代码生成替换此代码才能兼容。可以在此处找到本机 AOT 支持的 ASP.NET Core 功能列表。
结论
本机 AOT 是 ASP.NET Core 的一个有趣的开发,它可能会在即将发布的版本(例如 .NET 9 和 10)中获得更多关注。主要目的是在云本机场景中,您更喜欢具有较小图像大小和内存占用的微服务,并且减少启动时间。对于需要快速启动的无服务器功能也是如此——在这些场景中,Native AOT 可以大放异彩。在常规 CLR 模式下运行时,JIT 在启动时需要编译大量代码(尤其是静态方法/成员),而在 Native AOT 模式下可以完全省略。但不要忘记:Native AOT 生成的代码不一定更快。事实上,分层 JIT 编译过程可以考虑目标系统(因为它就在您的代码旁边运行)——这是编译时创建的本机代码无法考虑的。
库和框架仍然需要赶上原生 AOT 支持。当前依赖于未绑定反射的代码必须转换为编译时生成的代码,很可能是通过源生成器。一旦第三方框架和库支持它,开发人员是否会采用这种部署模型将会很有趣——尤其是 Entity Framework Core 在这里很重要。接下来的几年也将非常有趣,如何进一步调整 Native AOT 以与 Go 和 Rust 等语言竞争。