1. AOT 与 JIT 的根本差异
一句话总结: JIT 在运行时把 IL 编译成机器码,Native AOT 在发布时就把整个应用(含运行时)编译成单个原生可执行文件,代价是失去运行时代码生成能力。
.NET 的传统模型是:C# 编译成 IL,运行时用 JIT 在方法首次执行时编译成机器码。这带来动态优化(Tiered Compilation、PGO)与平台无关性,但也带来启动开销——每个方法首次调用都要编译。
Native AOT 把编译提前到发布阶段:IL 被直接编译成目标平台的机器码,运行时(GC、异常处理、反射元数据的一个子集)被静态链接进可执行文件。产物是单个原生二进制,双击即启动,不需要安装 .NET 运行时。
代价是三条硬约束:没有 JIT(不能运行时生成代码)、没有动态加载(不能 Assembly.LoadFile)、反射受限(裁剪器看不到的代码会被删掉)。
<!-- 启用 Native AOT 的项目配置 -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
<IlcOptimizationPreference>Speed</IlcOptimizationPreference>
</PropertyGroup>
# 发布
dotnet publish -c Release -r linux-x64
# 产物:单个可执行文件
ls -lh bin/Release/net8.0/linux-x64/publish/app
| 维度 | JIT | Native AOT |
|---|---|---|
| 编译时机 | 运行时 | 发布时 |
| 启动时间 | 较慢(预热) | 极快 |
| 峰值吞吐 | 高(PGO) | 略低 |
| 反射 | 完整 | 受限 |
| 动态代码 | 支持 | 不支持 |
| 跨平台 | 一份 IL | 每平台一份二进制 |
避坑: Native AOT 编译必须指定 RID(
-r linux-x64),且必须在目标平台上编译(不支持跨平台 AOT 交叉编译到所有平台)。编译过程依赖 C++ 工具链(Linux 需 clang、Windows 需 MSVC),CI 里要预先安装。另外 AOT 的编译时间明显更长,小项目也要几十秒,大项目可能数分钟——不要在每次本地构建时都开 AOT。
2. 裁剪器与反射限制
一句话总结: 裁剪器通过静态分析删除未使用的代码,但反射调用是「静态不可见」的,被反射使用的类型会被误删,必须通过注解或根描述符保留。
裁剪(Trimming) 是 AOT 的前提,也可以单独用于减小自包含部署的体积。它的工作方式是:从入口点出发,构建调用图,只保留可达的代码。
反射是这套分析的盲区:Type.GetType("MyApp.Foo")、Activator.CreateInstance(t)、JsonSerializer.Serialize(obj) 都让裁剪器无法知道到底用了哪些类型。默认情况下,裁剪器会保守地保留所有可能被反射的类型,但一旦开启 TrimMode=full 或 AOT,就必须显式标注。
标注方式有三种:特性注解([DynamicallyAccessedMembers]、[RequiresUnreferencedCode])、根描述符 XML(ILLink.Descriptors.xml)、[DynamicDependency]。
// 用特性告诉裁剪器:这个类型的方法会被反射使用
public static object Create(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
Type type)
{
return Activator.CreateInstance(type)!;
}
// 明确声明此方法不兼容裁剪
[RequiresUnreferencedCode("使用反射解析类型,裁剪后可能失败")]
public static object Resolve(string typeName)
{
var t = Type.GetType(typeName)!;
return Activator.CreateInstance(t)!;
}
// 显式保留某个成员
[DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(Plugin))]
public static void RegisterPlugins() { }
<!-- ILLink.Descriptors.xml:根描述符 -->
<linker>
<assembly fullname="MyApp">
<type fullname="MyApp.Plugins.*" preserve="all" />
</assembly>
</linker>
| 手段 | 作用范围 | 使用场景 |
|---|---|---|
[DynamicallyAccessedMembers] | 参数/字段/属性 | 明确知道要用哪些成员 |
[RequiresUnreferencedCode] | 方法 | 整个方法不兼容裁剪 |
[DynamicDependency] | 方法 | 手工保留特定成员 |
| 根描述符 XML | 程序集 | 第三方库无注解时的兜底 |
<TrimmerRootAssembly> | 程序集 | 整个程序集不裁剪 |
避坑: 裁剪警告(IL2026、IL2075)默认只是警告,不是错误,但运行时可能表现为
MissingMethodException或TypeLoadException。正确做法是把<TreatWarningsAsErrors>true</TreatWarningsAsErrors>打开,让所有裁剪警告在构建期暴露。第三方库若没有裁剪注解,最省事的做法是用<TrimmerRootAssembly Include="SomeLib" />整个保留,代价是体积变大。
3. 源生成序列化与依赖注入
一句话总结: 反射驱动的序列化与 DI 在 AOT 下必须换成源生成版本,这是从「能编译」到「能运行」的关键一步。
System.Text.Json 的默认序列化走反射,AOT 下会失败或需要大量保留注解。正确做法是使用源生成序列化上下文,它在编译期生成读写代码。
依赖注入同理:AddScoped<T>() 依赖反射构造对象,Microsoft 提供了源生成 DI(Microsoft.Extensions.DependencyInjection.SourceGenerator),通过 [ServiceProviderModule] 与 ServiceCollection 生成注册代码。
// 源生成 JSON 上下文
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
WriteIndented = false)]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<OrderDto>))]
[JsonSerializable(typeof(ApiResponse<OrderDto>))]
public partial class ApiJsonContext : JsonSerializerContext { }
// 使用:零反射
var json = JsonSerializer.Serialize(dto, ApiJsonContext.Default.OrderDto);
// Minimal API 中配置 AOT 友好的 JSON
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default);
});
// 路由处理器必须用强类型,避免反射绑定
app.MapPost("/orders", (OrderDto dto) => Results.Ok(dto));
// 源生成 DI:编译期生成注册
[ServiceProviderModule]
public static class ServiceModule
{
public static IServiceCollection AddAppServices(this IServiceCollection services)
{
services.AddSingleton<IClock, SystemClock>();
services.AddScoped<IOrderService, OrderService>();
return services;
}
}
| 反射用法 | AOT 替代 | 说明 |
|---|---|---|
JsonSerializer.Serialize(obj) | JsonSerializerContext | 编译期生成读写器 |
Activator.CreateInstance | 源生成工厂 | DI 源生成器 |
services.AddScoped<T>() | 源生成 DI 模块 | 减少反射调用 |
Enum.Parse | 源生成枚举转换 | 或手写 switch |
Configuration.Bind | 源生成绑定器 | .NET 8 起支持 |
避坑: 即使配了源生成上下文,默认的反射解析器仍在链上——必须用
TypeInfoResolverChain.Insert(0, ...)或在JsonSerializerOptions里显式设置TypeInfoResolver,并在发布前用<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>关闭反射回退,让遗漏在构建期暴露而不是运行期。泛型类型(如ApiResponse<T>)的每个闭合类型都要单独[JsonSerializable]声明。
4. 启动时间与体积对比
一句话总结: Native AOT 把启动时间从数百毫秒压到个位数毫秒,体积从几十 MB 降到几 MB 到十几 MB,但峰值吞吐可能略低于 JIT。
启动时间的改善最直观。一个典型的 ASP.NET Core 最小 API:
- JIT + 框架依赖部署:冷启动约 300~600 ms(含运行时初始化与 JIT 预热)
- JIT + 自包含:冷启动约 200~400 ms
- ReadyToRun:约 150~250 ms
- Native AOT:约 5~20 ms
体积方面,AOT 产物通常 515 MB(含运行时),远小于自包含部署的 6080 MB,略大于框架依赖部署的几 MB(但那需要目标机装运行时)。
# 对比体积
dotnet publish -c Release -r linux-x64 --self-contained # 自包含
dotnet publish -c Release -r linux-x64 -p:PublishAot=true # AOT
# 对比启动时间
hyperfine './app --urls http://localhost:5000'
<!-- 进一步压缩体积的开关 -->
<PropertyGroup>
<InvariantGlobalization>true</InvariantGlobalization> <!-- 去掉 ICU -->
<UseSystemResourceKeys>true</UseSystemResourceKeys> <!-- 精简异常消息 -->
<IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>
<EventSourceSupport>false</EventSourceSupport>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
</PropertyGroup>
| 部署方式 | 体积 | 冷启动 | 依赖运行时 |
|---|---|---|---|
| 框架依赖 | 最小 | 慢 | 是 |
| 自包含 | 大 | 较慢 | 否 |
| ReadyToRun | 大 | 中 | 否 |
| 单文件 | 大 | 较慢 | 否 |
| Native AOT | 小 | 极快 | 否 |
避坑:
InvariantGlobalization=true会让所有文化相关的格式化退化为不变文化(日期、货币、排序),多语言应用不能开。UseSystemResourceKeys=true会把异常消息替换成资源键,日志里只能看到Arg_ArgumentException这样的键名,生产排错会变难——建议只在明确不需要人类可读消息的场景开启。AOT 的峰值吞吐通常低于 JIT,长驻高吞吐服务未必划算,短生命周期、需要快速扩缩容的场景(Serverless、CLI)收益最大。
5. 兼容性检查与排错
一句话总结: AOT 的问题几乎都能在构建期通过警告与
dotnet publish的分析器发现,开启严格模式是避免运行期崩溃的最有效手段。
发布前应开启全套分析:
<PropertyGroup>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
常见错误码与含义:
| 警告码 | 含义 | 修法 |
|---|---|---|
| IL2026 | 调用了标注 RequiresUnreferencedCode 的方法 | 改用源生成或加根描述符 |
| IL2075 | 反射获取的成员未被保留 | 加 DynamicallyAccessedMembers |
| IL3050 | 使用了 AOT 不支持的 API | 替换为源生成版本 |
| IL3053 | 泛型实例化无法静态分析 | 显式实例化或加 DynamicDependency |
| IL2104 | 程序集产生裁剪警告汇总 | 定位到具体程序集 |
# 定位警告来源
dotnet publish -c Release -r linux-x64 -p:PublishAot=true /warnaserror
# 查看保留的成员(诊断裁剪结果)
dotnet publish -c Release -r linux-x64 -p:PublishAot=true \
-p:IlcGenerateMapFile=true
// 运行期排错:捕获 AOT 特有的异常
try
{
var obj = JsonSerializer.Deserialize<Order>(json);
}
catch (NotSupportedException ex)
{
// AOT 下常见:未配置源生成上下文
logger.LogError(ex, "序列化失败,检查 JsonSerializerContext 配置");
}
避坑: 最隐蔽的一类是第三方库内部用了反射。它自己的代码没有警告(因为库作者没开分析器),但你的应用在 AOT 下调用它时会崩。解决办法是用
IsAotCompatible检查依赖,或对已知不兼容的库加根描述符。另一个坑是多态序列化:JsonDerivedType标注的派生类型必须显式声明,否则 AOT 下反序列化到派生类型会失败。
6. 典型场景与收益评估
一句话总结: Native AOT 最适合 CLI 工具、Serverless 函数与需要极速冷启动的容器化服务,不适合重度依赖反射或动态代码加载的应用。
判断是否值得上 AOT,看三个问题:启动时间是否是瓶颈?部署环境是否有运行时?反射依赖是否可控?
// 场景一:CLI 工具——AOT 的完美用例
// 启动 5ms vs 300ms,用户感知明显
app.MapGet("/", () => "ok");
// 场景二:Serverless 函数——冷启动直接决定成本
// AOT 后冷启动从秒级降到毫秒级
// 场景三:容器化微服务——镜像更小,扩缩容更快
// FROM scratch 直接跑 AOT 二进制
# AOT 产物可以直接跑在 scratch 镜像上
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -r linux-x64 -p:PublishAot=true -o /app
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-noble-chiseled
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENTRYPOINT ["./MyApp"]
| 场景 | AOT 收益 | 建议 |
|---|---|---|
| CLI 工具 | 极高 | 强烈推荐 |
| Serverless | 极高 | 强烈推荐 |
| 边缘计算 | 高 | 推荐 |
| 短生命周期容器 | 高 | 推荐 |
| 长驻高吞吐服务 | 中 | 视反射依赖而定 |
| 依赖大量反射的框架 | 低 | 不建议 |
| 需要动态加载插件 | 无 | 不可行 |
避坑: AOT 二进制没有 JIT,所以不能在运行时加载新的程序集——任何「插件热加载」「动态编译表达式树」的架构都要推翻重做。
Expression.Compile()在 AOT 下不可用(可用解释模式或源生成替代)。若应用重度使用System.Reflection.Emit、Castle.DynamicProxy、老版本 ORM,AOT 迁移成本会非常高,应先用IsAotCompatible做一次依赖体检再决定。
7. 渐进式迁移策略
一句话总结: 不要一次性把整个应用切到 AOT,而是先开裁剪分析、再逐个消除反射、最后启用 AOT,每一步都保持可发布。
迁移路径建议分四步:
第一步,只开分析器。设置 EnableTrimAnalyzer=true 与 IsAotCompatible=true,但不开 PublishAot。此时只产生警告,不影响运行。先把警告清零。
第二步,替换反射热点。把 JSON 序列化换成源生成上下文,把 DI 注册换成源生成,把 Activator.CreateInstance 换成工厂。
第三步,裁剪发布。开 PublishTrimmed=true(不开 AOT),验证功能完整。这一步能在保留 JIT 的前提下暴露裁剪问题,调试比 AOT 容易。
第四步,启用 AOT。前两步都干净后再开 PublishAot=true,剩下的问题通常很少。
<!-- 第一步:只分析,不改行为 -->
<PropertyGroup>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
<IsAotCompatible>true</IsAotCompatible>
<!-- 暂不开启 -->
<PublishAot>false</PublishAot>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
// 用条件编译隔离不兼容代码
#if !AOT
// 反射回退路径,仅 JIT 构建使用
var plugin = Assembly.LoadFrom(path);
#else
throw new PlatformNotSupportedException("AOT 构建不支持插件热加载");
#endif
| 步骤 | 开关 | 风险 | 可回退 |
|---|---|---|---|
| 1 分析 | EnableAotAnalyzer | 无 | — |
| 2 换源生成 | 代码改动 | 中 | 是 |
| 3 裁剪发布 | PublishTrimmed | 中 | 是 |
| 4 启用 AOT | PublishAot | 高 | 是 |
避坑: 每一步都要有完整的集成测试兜底——AOT 的问题往往是特定代码路径才触发,单测覆盖不到。建议在 CI 里同时构建 JIT 与 AOT 两个产物并跑同一套测试。另外 AOT 产物的堆栈跟踪信息会退化(符号被剥离),生产排错要保留
.dbg文件或构建时开启StackTraceSupport。
8. 总结
| 环节 | 要点 |
|---|---|
| 根本差异 | AOT 发布时编译,JIT 运行时编译,前者快启动后者高吞吐 |
| 裁剪 | 静态分析删代码,反射是盲区必须显式标注 |
| 序列化 | 必须换成源生成上下文,关闭反射回退 |
| 依赖注入 | 用源生成 DI 模块替代反射注册 |
| 收益 | 启动从数百毫秒到个位数毫秒,体积降到几 MB |
| 兼容检查 | 开 IsAotCompatible 与 TreatWarningsAsErrors,构建期暴露问题 |
| 迁移 | 分析 → 换源生成 → 裁剪 → AOT,四步渐进 |
Native AOT 是 .NET 近年来最有分量的一项能力:它让 C# 也能产出「原生程序」的启动体验,同时保留类型安全与丰富类库。代价是必须放弃反射与动态代码——这不是技术细节,而是架构约束,会影响序列化、DI、插件、ORM 的选择。把这条约束前置到设计阶段,AOT 就是纯粹的收益;等到上线前才尝试,往往要付出重构代价。下一篇文章回到工程保障的最后一环:如何用 Testcontainers 把真实依赖(数据库、缓存、消息队列)拉进集成测试。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。