.NET 的 Native AOT 是什么 - 先厘清与 JIT、ReadyToRun、trimming 的区别

· 更新日期: · · C#, .NET, Native AOT, 发布, 设计

之前写过 把 C# 通过 Native AOT 做成原生 DLL 的方法 - 使用 UnmanagedCallersOnly 从 C/C++ 调用,但那篇其实顺序有点颠倒了。
本来应该先讲「Native AOT 到底是什么」比较自然。

Native AOT 这个话题,一开始这几点常常会混在一起。

  • 是在说去掉 JIT 这件事吗
  • 和 self-contained 或 single-file 有什么不同
  • 和 ReadyToRun 是一类东西吗
  • trimming warning 大量冒出来到底是什么情况
  • WPF 或 WinForms 能不能直接套上去

这些混在一起的时候,Native AOT 看起来会像「只是变快的魔法」,或者反过来像「全是限制、不能碰的东西」。
两边都稍显粗糙。

简单说,Native AOT 是 把 .NET 应用在 publish 时相当彻底地固化下来的发布模式
代价是对启动和发布层面效果明显。
反过来,想在运行期动态解决各种事情的程序,就不太合拍。

这篇文章就从实务角度整理这一点。

1. Native AOT 做了什么

平时的 .NET 会先生成 IL,再在运行期对需要的地方做 JIT。
Native AOT 则是把「运行期才生成原生代码」这一步大幅提前到 publish 阶段。

所以从外观上看,它是朝着「把 .NET 打包得像原生应用一样发布」的方向走。

这里重要的是 希望在 publish 时就能看到绝大多数必要的代码

也就是说,

  • 运行期才去查找类型
  • 运行期才生成代码
  • 运行期才读取程序集来判断

这类写法,无论如何都会比较不合适。

如果把 Native AOT 只看成「变快的功能」,这里就会理解偏了。
本质上更像是 舍弃一部分动态世界,往容易固化的静态世界靠拢

2. 和 ReadyToRun 差在哪里

先把这里分清楚,会轻松很多。

角度 一般 JIT ReadyToRun Native AOT
运行期 JIT 使用 仍会使用 不使用
发布产物 以 IL 为主 IL + 事先生成的原生代码 以原生可执行文件为主
启动 基准 容易改善 更容易改善
兼容性 广 相对广 限制较多

ReadyToRun 是把 JIT 的工作稍微提前的方向。
Native AOT 则是 不以运行期 JIT 为前提 的方向。

名字看起来接近,实际手感却差不少。
ReadyToRun 是「先让启动稍微轻一点」。
Native AOT 是「为了启动、发布、运行环境的需求,设计也往静态一点靠」。

3. 用了 Native AOT 有什么好处

最直观的还是启动速度。

在 CLI 工具、短生命周期进程、容器里的小型 API、常驻但功能有限的工具上,JIT 的开销会很明显。
Native AOT 把这部分提前做完,起步就会更轻。

另一个是发布层面。
在不想要求「请先在机器上安装 .NET Runtime」的场景中,心里会轻松不少。

  • 只想发布一个小工具
  • 希望容器镜像尽量小
  • 不想在运行环境上装 JIT

这种场景下,Native AOT 用得上的位置很明确。

所以 Native AOT 适合的不是「所有应用」,而是 想减少启动、发布、运行环境前提条件的应用

4. 反过来哪些地方会变辛苦

这里答案相当明确。
首先是反射与动态生成代码。

  • Assembly.LoadFile
  • System.Reflection.Emit
  • 运行期把类型逐一枚举出来查找的结构
  • 从字符串名称查类型再实例化的机制

这些在 publish 时不容易把必要代码固定下来,就会成为 AOT warning 的温床。

Native AOT 冒出一堆 warning 时,与其觉得「太吵了」,不如读成「这个设计在 publish 时就是看不清楚」。
尤其是 RequiresDynamicCode 系列,通常值得认真看一看。

再来是 trimming。
Native AOT 和 trimming 绑得相当紧。
这里麻烦的是,不只自己的代码,连依赖库的写法都会受影响。

例如,

  • 以反射为基础的序列化器
  • 以运行期扫描为前提的 DI
  • 使用动态代理的库

这些都容易让事情变麻烦。

换句话说,做 Native AOT 的时候,与其「运行期再聪明处理」,不如 往编译期就能看清楚的形式靠拢 更好控制。

5. Windows 桌面端要稍微谨慎一点

在 Windows 上,Native AOT 没有内置 COM 支持。
再加上 WPF 和 trimming 的搭配并不好,WinForms 也相当依赖内置的 COM marshalling。

因此,

  • 直接把 WPF / WinForms 主体丢给 Native AOT
  • 照常把 COM interop 直接带进去

这些都要谨慎对待。

反过来,比较适合当入口的是,

  • Console
  • worker
  • 小型 Web API
  • 职责单一的小组件

Native AOT 并不是「.NET 的东西都能直接套上去」。
尤其在 Windows 桌面和 COM 味浓的世界中,很多场景还是维持一般 JIT 会更顺畅。

6. 最小步骤其实很简单

在项目端,先在 csproj 里加上 PublishAot

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

publish 的命令,例如 Windows x64 会是这样。

dotnet publish -c Release -r win-x64

Linux x64 则是,

dotnet publish -c Release -r linux-x64

走到这一步,就已经相当「像原生应用的世界」了。
由于 RID 是固定的,产物比起「同一个 DLL 到哪都能跑」,更像是为该 OS / 架构量身定制的可执行文件。

另外还有一个意外常中招的地方是 JSON。
与其用平常的方式把 System.Text.Json 写成依赖反射的路径,不如靠 source generation,更不容易出问题。

using System.Text.Json;
using System.Text.Json.Serialization;

[JsonSerializable(typeof(AppConfig))]
internal partial class AppJsonContext : JsonSerializerContext
{
}

public sealed class AppConfig
{
    public string? Name { get; init; }
    public int RetryCount { get; init; }
}

var config = new AppConfig
{
    Name = "sample",
    RetryCount = 3
};

string json = JsonSerializer.Serialize(config, AppJsonContext.Default.AppConfig);

与其说是为了 Native AOT,不如记成 改往「不要让运行期去查找类型」的写法靠,这样比较不会理解偏。

7. 从哪里开始试比较好

第一个尝试对象,下列几类比较容易上手。

  • 启动速度很重要的 CLI
  • worker
  • 小型 Web API
  • 在原生集成中职责较窄的组件

这些比较容易减少动态机制,也比较容易看到 Native AOT 的好处。

反之,WPF / WinForms 既有的大型应用主体、以插件加载为核心的结构、重度依赖 COM 的结构,作为首批尝试对象并不太平顺。

8. 总结

Native AOT 是在 publish 时把 .NET 做得相当静态的机制。

  • 对启动有效
  • 发布也会变轻松
  • 代价是对动态机制会变得严格

先掌握这三点,思路会清楚很多。

Native AOT 不是「所有 .NET 应用都该套上的标配开关」。
但在启动速度重要、想让发布更轻松、想降低对运行环境前提条件的要求时,它是相当强的选项。

反过来,在 Windows 桌面或 COM 味浓的世界中,继续走一般 .NET 往往更顺畅。
能分清楚这两边之后,Native AOT 就不再是「难搞的新功能」,而是位置清晰的工具。

9. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

Native AOT 是什么?
Native AOT 是把 .NET 应用在 publish 时相当彻底地固化下来的发布模式。平时的 .NET 会先生成 IL,再在运行期对需要的地方做 JIT;Native AOT 则把「运行期才生成原生代码」这一步大幅提前到 publish 阶段,产出以原生可执行文件为主的成品,不再使用运行期 JIT。它的本质不是单纯「变快的魔法」,而是舍弃一部分动态世界,往容易固化的静态世界靠拢,对启动和发布层面的效果很明显。
Native AOT 和 ReadyToRun 有什么区别?
ReadyToRun 是把 JIT 的工作稍微提前的方向,发布产物是 IL 加上事先生成的原生代码,运行期仍会用到 JIT,兼容性相对广。Native AOT 则是不以运行期 JIT 为前提的方向,发布产物以原生可执行文件为主,启动更容易改善但限制更多。简单说,ReadyToRun 是「先让启动稍微轻一点」,Native AOT 是「为了启动、发布、运行环境的需求,设计也往静态靠一点」。
什么样的应用适合 Native AOT?
适合的是想减少启动、发布、运行环境前提条件的应用,例如启动速度很重要的 CLI 工具、worker、容器里的小型 Web API、职责单一的小组件。它在不想要求运行环境事先安装 .NET Runtime、希望容器镜像尽量小的场景特别有用。反过来,重度依赖反射与动态生成代码的结构、以插件加载为核心的结构,就不太合拍。
WPF 或 WinForms 可以用 Native AOT 吗?
要谨慎对待。在 Windows 上 Native AOT 没有内置 COM 支持,而 WPF 和 trimming 的搭配并不好,WinForms 也相当依赖内置的 COM marshalling,所以直接把 WPF / WinForms 主体丢给 Native AOT、或者照常把 COM interop 带进去,都不建议。比较适合当入口的是 Console、worker、小型 Web API 这类职责清晰的组件;Windows 桌面和 COM 味浓的世界,很多场景维持一般 JIT 会更顺畅。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表