Native AOT 与裁剪

深入 .NET 的原生编译与裁剪,覆盖 Native AOT 发布流程、裁剪器与反射限制、源生成序列化与依赖注入、启动时间与体积对比、兼容性检查与常见排错手段。

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
维度JITNative 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 启用 AOTPublishAot高是

避坑: 每一步都要有完整的集成测试兜底——AOT 的问题往往是特定代码路径才触发,单测覆盖不到。建议在 CI 里同时构建 JIT 与 AOT 两个产物并跑同一套测试。另外 AOT 产物的堆栈跟踪信息会退化(符号被剥离),生产排错要保留 .dbg 文件或构建时开启 StackTraceSupport。

8. 总结

环节要点
根本差异AOT 发布时编译,JIT 运行时编译,前者快启动后者高吞吐
裁剪静态分析删代码,反射是盲区必须显式标注
序列化必须换成源生成上下文,关闭反射回退
依赖注入用源生成 DI 模块替代反射注册
收益启动从数百毫秒到个位数毫秒,体积降到几 MB
兼容检查开 IsAotCompatible 与 TreatWarningsAsErrors,构建期暴露问题
迁移分析 → 换源生成 → 裁剪 → AOT,四步渐进

Native AOT 是 .NET 近年来最有分量的一项能力:它让 C# 也能产出「原生程序」的启动体验,同时保留类型安全与丰富类库。代价是必须放弃反射与动态代码——这不是技术细节,而是架构约束,会影响序列化、DI、插件、ORM 的选择。把这条约束前置到设计阶段,AOT 就是纯粹的收益;等到上线前才尝试,往往要付出重构代价。下一篇文章回到工程保障的最后一环:如何用 Testcontainers 把真实依赖(数据库、缓存、消息队列)拉进集成测试。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排