更新记录(1 条,最后更新 2026年09月03日)
本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。
- 本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.21615453)
- 首次发布
引用本文(DOI: 10.5281/zenodo.21615452)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《.NET Native AOT 是什么 - 与 JIT、trimming 的区别》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615452 https://comcomponent.com/zh-CN/blog/2026/03/13/001-dotnet-native-aot-what-is/
- DOI(最新版本)
- 10.5281/zenodo.21615452
- DOI(此版本)
- 10.5281/zenodo.22281970
在 从 C/C++ 调用 C# Native AOT DLL 的方法 里,已经写过用 Native AOT 从 C/C++ 调用 C# 的做法。 不过说实话,更周到的顺序是先放一篇 Native AOT 究竟是什么。前后稍微颠倒了一点。
谈 Native AOT,一开始最容易出问题的就是术语混在一起。
- 这是在讲去掉 JIT 吗
- 和 self-contained、single-file 有什么不同
- 和 ReadyToRun 是同一路的吗
- trimming warning 大量冒出来,到底发生了什么
- 在 WPF / WinForms / ASP.NET Core 上是否也能以同样的力度使用
这些一旦混为一谈,Native AOT 看起来就会像“只是变快的魔法”,或者反过来像“全是限制、很可怕的东西”。两种看法都有点粗糙。
flowchart TB
accTitle: 术语混在一起会造成的误解
accDescr: JIT、self-contained、ReadyToRun、trimming这些术语混为一谈时,Native AOT看起来会像只是变快的魔法,或者反过来像全是限制的可怕东西,但两种看法都很粗糙。
mix["术语混为一谈"] --> m1["看起来像变快的魔法"]
mix --> m2["看起来像全是限制、很可怕"]
sort["把词分开梳理"] --> fair["变成用途清晰的选项"]
图1:误解的根源是术语混线。先从把词分开开始。
本文以 .NET 8 之后的当前实务感受为前提,先整理下面四点。
- Native AOT 到底是什么
- 好处在哪里,哪里会变吃力
- 与 ReadyToRun、trimming 有什么不同
- 从什么样的应用开始试比较稳
目录
- 先说结论(一句话)
- 先看的对照表
- 2.1. Native AOT 周边的术语
- 2.2. JIT / ReadyToRun / Native AOT 的区别
- Native AOT 的整体图景(图)
- Native AOT 能带来什么好处
- 4.1. 启动容易变轻
- 4.2. 不必以预装运行时为前提
- 4.3. 适合受限的运行环境
- Native AOT 会在哪些地方变吃力
- 5.1. 反射与动态代码生成
- 5.2. 必须以 trimming 为前提来考虑
- 5.3. 按平台分别发布
- 5.4. Windows 桌面 / COM 场景要相当谨慎
- 最小步骤
- 6.1.
csproj - 6.2. publish
- 6.3. JSON 的写法
- 6.1.
- 适合的场景
- 不适合的场景
- 容易踩的坑
- 总结
- 参考资料
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 16 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
1. 先说结论(一句话)
- Native AOT 是在 publish 时把 .NET 应用预先编译成原生代码再分发的方式。
- 因为不使用运行期 JIT,启动时间和内存占用容易变好,也更容易分发到没有安装 .NET 运行时的环境。
- 但代价是,与自由的反射、动态代码生成、built-in COM 以及不支持 trimming 的库都不合拍。
- 也就是说,它与其说是让程序变快的魔法,不如说是为了启动、发布、运行环境上的需要,舍弃一部分动态世界、向静态世界靠拢的发布模型。
Native AOT 是“把 .NET 以接近原生的方式分发出去的机制”,而不是一个单纯让编译变快的勾选框。
flowchart TB
accTitle: 得到什么、舍弃什么
accDescr: Native AOT通过publish时的预先编译换来启动时间、内存占用和无需运行时的分发,代价是舍弃自由的反射、动态代码生成以及与built-in COM的兼容,是这样一种发布模型。
aot["Native AOT"] --> gain["得到的东西"]
aot --> lose["舍弃的东西"]
gain --> g1["启动、内存与分发的轻量"]
lose --> l1["自由的反射"]
lose --> l2["动态代码生成与built-in COM"]
图2:不是变快的魔法,而是舍弃一部分动态世界、向静态世界靠拢的一笔交换。
2. 先看的对照表
2.1. Native AOT 周边的术语
先把这几个词分清楚,后面会轻松很多。
| 术语 | 做的是什么 | 与 Native AOT 的关系 |
|---|---|---|
| JIT | 在运行期从 IL 生成原生代码 | Native AOT 把这一步提前做完 |
| self-contained | 把运行所需的 .NET 一整套一起分发 | Native AOT 归在这一路来考虑 |
| single-file | 把发布产物合并成 1 个文件 | 与 Native AOT 的本质是两回事,但结果的外观容易接近 |
| trimming | 删掉没用到的代码 | 在 Native AOT 里基本上是前提 |
| ReadyToRun | 保留 IL,把 JIT 的工作稍微提前 | 与 Native AOT 貌似而实非,是另一样东西 |
| source generator | 把运行期的动态处理挪到构建期代码生成 | 与 Native AOT 配合良好 |
容易混淆的地方在于,Native AOT 与其说是一个功能,不如说是一种与 self-contained、trimming、source generation、固定 RID 的 publish 等配合运作的发布模型。
flowchart TB
accTitle: 与Native AOT一起运作的机制
accDescr: 说明Native AOT不是单独的一个功能,而是与self-contained、trimming、source generation、固定RID的publish组合起来运作的发布模型。
aot["Native AOT〔发布模型〕"] --> s1["self-contained这一路"]
aot --> s2["trimming基本是前提"]
aot --> s3["与source generator配合良好"]
aot --> s4["固定RID后publish"]
图3:Native AOT 不是单独的功能,而是与周边机制成套运作的发布模型。
2.2. JIT / ReadyToRun / Native AOT 的区别
这里也是先用一张表看完更快。
| 角度 | 普通的 JIT 执行 | ReadyToRun | Native AOT |
|---|---|---|---|
| 运行期 JIT | 使用 | 仍有会用到的场合 | 不使用 |
| 发布产物的内容 | 以 IL 为主 | IL + 事先生成的代码 | 以原生可执行文件为主 |
| 启动 | 基准 | 容易改善 | 相当容易改善 |
| 兼容性 | 最广 | 广 | 限制较强 |
| 动态功能 | 好用 | 大体上好用 | 限制较多 |
| 适合的目的 | 一般的 .NET 开发 | 首先想改善启动 | 强力争取启动、分发、受限环境 |
如果说 ReadyToRun 是“让 JIT 轻松一点”的方向,那么 Native AOT 就是“不以运行期 JIT 本身为前提”的方向。 虽然都带着 AOT 这个词,力度却相差很多。
flowchart TB
accTitle: ReadyToRun与Native AOT的方向差异
accDescr: ReadyToRun是保留IL、把JIT的工作稍微提前从而让JIT轻松一点的方向,Native AOT是不以运行期JIT本身为前提的方向,虽然都叫AOT,力度却相差很多。
q{"想把JIT怎么办"}
q -->|"让它轻松一点"| r2r["ReadyToRun〔IL保留〕"]
q -->|"不作为前提"| aot["Native AOT〔以原生为主〕"]
r2r --> soft["兼容性依然很广"]
aot --> hard["启动快但限制强"]
图4:同样是 AOT,“让 JIT 轻松”和“不以 JIT 为前提”是两回事。
在此基础上,把“那到底该选哪种分发形态”画成一张图,就是下面这样。
flowchart TD
S["想确定分发形态"] --> Q1{"能在目标环境装 .NET 运行时吗?"}
Q1 -- "能装" --> Q2{"想压缩启动时间吗?"}
Q2 -- "没到那种程度" --> P1["framework-dependent<br/>普通发布"]
Q2 -- "想压缩" --> P2["ReadyToRun<br/>保持兼容性的同时改善启动"]
Q1 -- "不想装 / 装不了" --> Q3{"依赖 reflection、动态代码生成、built-in COM 吗?"}
Q3 -- "依赖" --> P3["self-contained<br/>需要时用 single-file 打包"]
Q3 -- "不依赖" --> Q4{"对象是 console / worker / 小型 API 吗?"}
Q4 -- "是" --> P4["Native AOT"]
Q4 -- "否" --> P5["先 self-contained<br/>减少动态依赖后再重新评估"]
图5:分发形态的选法。先看能不能放运行时,再看能不能减少动态机制。
第一个分支是 能不能在目标环境放置运行时,第二个分支是 动态机制能减少到什么程度。 self-contained 与 single-file 讲的是“怎么分发”,ReadyToRun 与 Native AOT 讲的是“什么时候生成原生代码”,所以实际上要组合起来考虑。
3. Native AOT 的整体图景(图)
把 Native AOT 粗略画成图,就是下面这样。
flowchart LR
Src["C# / .NET 源代码"] --> IL["IL 程序集"]
IL -->|通常执行| JIT["运行期 JIT"]
JIT --> Run1["应用运行"]
IL -->|dotnet publish + PublishAot| Analyze["AOT / trim 分析"]
Analyze --> Trim["削减不需要的代码"]
Trim --> AOT["生成原生代码"]
AOT --> Run2["RID 专用的可执行文件"]
图6:通常执行是在运行期做 JIT,而 Native AOT 把分析、削减、原生代码生成都提前到 publish 时。
平时的 .NET 先生成 IL,再在运行期只对需要的部分做 JIT。 Native AOT 把其中后半段的大部分工作提前到 publish 时。
这里关键的一点是,在 publish 时点“必须几乎全部知道运行期会用到哪些代码”。 于是,允许什么样的写法,前提就变了。
- 在运行期查找类型
- 在运行期生成代码
- 在运行期加载 Assembly
- 在运行期抱着“总能糊弄过去”的心态做延迟解析
这类写法,一遇到 Native AOT 就会突然变得不合拍。
flowchart TB
accTitle: publish时必须已经全部知道
accDescr: Native AOT在publish时点必须几乎全部知道运行期会用到的代码,因此与运行期查找类型、运行期生成代码、运行期加载Assembly、靠延迟解析蒙混过去这类写法都不合拍。
need["publish时确定必要代码"] --> ng1["运行期查找类型"]
need --> ng2["运行期生成代码"]
need --> ng3["运行期读取Assembly"]
need --> ng4["靠延迟解析应付"]
ng1 -.-> bad["这些都与AOT不合拍"]
图7:前提改变的核心。越是“运行期看了再决定”的写法,越容易和 Native AOT 冲突。
4. Native AOT 能带来什么好处
4.1. 启动容易变轻
Native AOT 最直观的效果,还是启动。
- CLI 工具
- 短生命周期进程
- 类似 serverless 的启动
- 容器的启动与替换
- 监控工具和小型常驻进程
在这些场景里 JIT 的开销很容易显现,用 Native AOT 把这部分提前做完,起步就会更轻。
内存占用也容易改善,因此在想把同样数量的实例塞进有限资源的场合同样有利。 特别是在会大量拉起同一个进程的云端,这点差距会慢慢显现出来。
4.2. 不必以预装运行时为前提
用 Native AOT publish 出来的应用,在没有安装 .NET 运行时的环境里也更容易跑起来。
这一点看着不起眼,其实影响很大。
- 不想对使用方说“请先装好 .NET 9 Runtime”
- 想把容器镜像做瘦
- 只想放一个小工具就让它跑起来
- 不想在运行环境上允许 JIT,或者根本不被允许
在这些场合里,只要没有“还得另外准备运行时”这个前提,事情就会安静很多。
这里说的“不需要运行时”,指的是不必在目标环境另外安装 .NET。 并不是说应用内部连相当于 runtime 的必要部分都完全消失了。
flowchart TB
accTitle: 不需要运行时的正确含义
accDescr: Native AOT说的不需要运行时是指不必在目标环境另外安装.NET,并不是说应用内部连相当于runtime的部分也完全消失了。
word["不需要运行时这个说法"] --> ok["不必在目标环境另装.NET"]
word -.-> ngx["并不是runtime部分消失了"]
ok --> merit["分发与启动的前提变少"]
图8:“不需要运行时”说的是目标环境。相当于 runtime 的部分仍然包含在应用里。
4.3. 适合受限的运行环境
Native AOT 不使用运行期 JIT,所以在不允许 JIT 的环境里也更容易运行。
这一点比起 desktop,更容易在云、容器、移动端这类场景里发挥作用。 不过即使在 Windows 开发的语境下,从“减少目标环境上多余的前提条件”这个意义上说,它也足够让人高兴。
5. Native AOT 会在哪些地方变吃力
5.1. 反射与动态代码生成
Native AOT 最核心的限制就在这里。
- 像
Assembly.LoadFile这样的动态加载 - 像
System.Reflection.Emit这样的运行期代码生成 - 在运行期无限制地遍历类型的反射
- 在运行期随心所欲拼装 generic 的写法
这些写法在 publish 时不容易把必要代码固定下来,因此会成为 AOT warning 的温床。
当然,事情没有简单到“只要有一行反射就立刻出局”。 不过,越是偏向“运行期看了再决定”的设计就越吃力,这个倾向不会错。
warning 名里经常出现的是 RequiresDynamicCode 系列。
它的意思是“这个调用在 AOT 下可能会坏掉”,所以不要草率地 suppress 更安全。
做 Native AOT 的时候,可以记成:减少“运行期的聪明”,增加“构建期的显式声明”,这样理解起来比较清楚。
flowchart TB
accTitle: 从运行期的聪明转向构建期的显式声明
accDescr: 动态加载、运行期代码生成、无限制的反射都是AOT warning的温床,因此要减少运行期的聪明,把设计推向增加构建期显式声明的方向。
dynamicway["依赖运行期聪明的设计"] --> warn["RequiresDynamicCode系的warning"]
warn --> shift["增加构建期的显式声明"]
shift --> calm["publish时确定必要代码"]
warn -.-> nosup["不要草率地suppress"]
图9:面对核心限制的方向只有一个。把“运行期看了再决定”换成“构建期明确写出来”。
5.2. 必须以 trimming 为前提来考虑
Native AOT 与 trimming 结合得很紧。 这里容易被忽略的是,起作用的不只是自己的代码,还有依赖库那边的写法。
需要注意的是下面这些。
- 以反射为基础的序列化器
- 靠运行期扫描收集类型的 DI / 插件结构
- 从字符串名查找类型再实例化的机制
- 偏向动态 proxy 或 IL 生成的库
明明这里冒出了 warning,却以“publish 通过了就行”收场,后面会相当难受。 在 Native AOT 里,warning 基本上都值得认真读。
flowchart TB
accTitle: 不只由自己的代码决定的trimming
accDescr: trimming不只自己的代码起作用,依赖库的写法也会起作用,以反射为基础的序列化器、靠运行期扫描的DI、从字符串名解析类型、偏向动态proxy的库都需要注意。
trim["trimming的成败"] --> own["自己代码的写法"]
trim --> dep["依赖库的写法"]
dep -.-> ex["反射系与运行期扫描系"]
dep --> care["认真读warning并核实"]
图10:容易漏掉的是依赖库那边。“publish 通过了就行”后面会变难受。
话虽如此,只说“认真读”还是没法动手,所以这里写下处理的优先顺序。 官方文档也建议按这个顺序尝试。
| 顺序 | 处理方式 | 使用场合 |
|---|---|---|
| 1 | 不再使用 reflection | 能把 Activator.CreateInstance(Type) 换成 generic 参数,或者能挪到 source generator |
| 2 | 加上 DynamicallyAccessedMembers |
reflection 确实需要,但目标类型在编译期已经确定 |
| 3 | 加上 RequiresUnreferencedCode |
用运行期的字符串决定类型名等,本质上无法做静态分析 |
| 4 | 用 UnconditionalSuppressMessage 抑制 |
上面几种全都行不通、且已确认安全时的最后手段 |
作为第一步,最常见的是第 2 种,也就是“类型明明已经确定,却只是冒出警告”的情况。
例如,下面这段代码会报 IL2070。
// IL2070: 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicMethods'
void PrintMethodNames(Type type)
{
foreach (var method in type.GetMethods())
{
Console.WriteLine(method.Name);
}
}
只要用特性声明“既然要调用 GetMethods(),就请保留 public 方法”,警告就会消失。
using System.Diagnostics.CodeAnalysis;
void PrintMethodNames(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
foreach (var method in type.GetMethods())
{
Console.WriteLine(method.Name);
}
}
// 只要调用方是用 typeof 传进来的,这个要求就会自动得到满足
PrintMethodNames(typeof(DateTime));
调用的 API 与需要的指定大体上是一一对应的。GetMethod / GetMethods 对应 PublicMethods,GetProperty / GetProperties 对应 PublicProperties,Activator.CreateInstance 对应 PublicParameterlessConstructor 或 PublicConstructors。
DynamicallyAccessedMemberTypes.All 写起来省事,但会把目标类型的成员整个保留下来导致体积膨胀,而且保留下来的成员还会带出别的 warning,所以原则是 只指定必要的最小范围。
另外,加了特性 warning 仍然不消失时,要 从使用 reflection 的地方向调用方回溯,确认整条路径上都加上了特性。中间只要少一处,要求就会在那里断掉。
flowchart TB
accTitle: 加了特性warning仍不消失时
accDescr: DynamicallyAccessedMembers要按必要的最小范围指定,warning不消失时要从使用reflection的地方向调用方回溯,确认整条路径上都加了特性。中间少一处,要求就会在那里断掉。
warnleft["加了特性warning仍然残留"] --> back["向调用方回溯"]
back --> chain["确认整条路径都加上了"]
chain -.-> cut["少一处要求就在那里断掉"]
warnleft -.-> minrule["指定以必要的最小范围为原则"]
图11:特性要加在整条路径上,而不是加在某一个点上。中间少一处,要求就在那里断掉。
5.3. 按平台分别发布
Native AOT 是在固定 RID(Runtime Identifier)的前提下 publish 的。
也就是说,这里不是那种把为 win-x64 生成的产物原样拿到 linux-x64 上运行的世界。
- Windows x64
- Windows Arm64
- Linux x64
- Linux Arm64
- macOS Arm64
像这样,前提是按每个目标分别生成发布产物。
这一点比起普通的 framework-dependent 的 .NET,感觉会明显更“像原生应用”。
5.4. Windows 桌面 / COM 场景要相当谨慎
放在 KomuraSoft 的语境里,这一点尤其重要。
在 Windows 上,Native AOT 没有 built-in COM。 再加上 WPF 与 trimming 配合不好,WinForms 对 built-in COM marshalling 的依赖很重,所以至少在当前阶段,把这两者当作“第一批 Native AOT 候选”都需要相当谨慎地看待。
这里把所说的“当前”的基准写清楚。 本文的描述基于 .NET 8 / 9 / 10 这几代的官方文档(截至 2026 年 7 月)。 关于 WPF 和 WinForms,微软的“Known trimming incompatibilities”中明确写道:WPF 对 reflection 和运行期代码检查的依赖很强,trimming 之后基本无法运行;WinForms 对 built-in COM marshalling 的依赖很重,因此两者都已在 .NET SDK 侧关闭了 trimming 支持。 也就是说,现状不是“最好谨慎看待”,而是 SDK 直接拦住了。将来这段描述有可能变化,请在阅读时确认同一个页面。
flowchart TB
accTitle: WPF与WinForms被拦住的理由
accDescr: Windows的Native AOT没有built-in COM,WPF因为对reflection和运行期代码检查的依赖很强在trimming之后基本无法运行,WinForms因为对built-in COM marshalling的依赖很重,两者都已在.NET SDK侧关闭了trimming支持。
win["Windows的Native AOT"] --> nocom["没有built-in COM"]
wpf["WPF"] --> refl["reflection依赖很强"]
wf["WinForms"] --> commar["COM marshalling依赖很重"]
refl --> off["SDK侧关闭了trimming"]
commar --> off
图12:不是“要谨慎”,而是现状由 SDK 拦住。桌面主体不要当作第一批候选。
总之,
- 一上来就把 WPF / WinForms 主体改成 Native AOT
- 按平时的感觉把 COM interop 原样带进来
这些做法会让 publish 时的 warning 和运行期的限制一下子增多,应对成本容易暴涨。
反过来,
- 控制台程序
- worker
- 小型 Web API
- 在原生互操作里也容易收敛到 C 函数边界的组件
这些作为入口更自然。
如果确实需要 COM,有些场合下保持 JIT,或者以 ComWrappers / source-generated COM 为前提重新设计,反而更合理。
6. 最小步骤
在此之前,Native AOT 的 publish 需要原生工具链。
只加了 PublishAot 就执行 dotnet publish,失败的不是编译,而是最后的原生链接阶段。这是第一道关卡,请先把它装好。
| 环境 | 需要的东西 |
|---|---|
| Windows | Visual Studio 2022 以上。安装“使用 C++ 的桌面开发”工作负载,并把默认组件全部选上 |
| Ubuntu 18.04 以上 | sudo apt-get install clang zlib1g-dev |
| Alpine 3.15 以上 | sudo apk add clang build-base zlib-dev |
| Fedora 39 以上 / RHEL 8 以上 | sudo dnf install clang zlib-ng-devel zlib-ng-compat-devel zlib-devel |
| macOS | Xcode 的 Command Line Tools(.NET 8 以后支持) |
说白了就是编译器工具链,以及 .NET 运行时所依赖的库的开发用软件包。 如果要在 CI 上跑,dotnet/samples 的 Native AOT 示例里同时放了 Linux / Windows 两种 Dockerfile,从那里照搬前提条件的安装步骤最快。
另外,在 Linux 上构建的二进制 只能在相同或更新的 Linux 上运行。在 Ubuntu 20.04 上构建的产物可以在 20.04 及以后运行,但在 18.04 上跑不起来。构建环境怎么选,直接决定了可分发的范围。
flowchart TB
accTitle: publish前的关卡与可分发范围
accDescr: Native AOT的publish需要原生工具链,没有的话会在最后的原生链接阶段失败。另外在Linux上构建的二进制只能在相同或更新的Linux上运行,构建环境的选法直接成为可分发范围。
tool{"有没有工具链"}
tool -->|"没有"| fail["在原生链接阶段失败"]
tool -->|"有"| bin["产出按RID区分的二进制"]
bin -.-> range["Linux只在相同或更新的环境上运行"]
range -.-> pick["构建环境的选法即分发范围"]
图13:第一道关卡是工具链。在 Linux 上,构建环境有多旧决定了可分发的范围。
6.1. csproj
首先在项目文件里加上 PublishAot。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PublishAot>true</PublishAot>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
示例用 net8.0 就够了。思路本身在 .NET 9 / 10 上也基本相同。
重要的是,与其只在 dotnet publish 的命令行上临时加一下,不如
平时就把它放在项目里,日常查看 build / publish 时的分析结果。
另外,即使加上 <PublishAot>true</PublishAot>,平时的本地运行也不会立刻变成 Native AOT。
日常的 dotnet run 和普通执行仍然走 JIT,Native AOT 编译的正戏在 publish 时。
flowchart TB
accTitle: 加入PublishAot之后的日常
accDescr: 即使把PublishAot放进项目,日常的dotnet run仍然走JIT,Native AOT编译的正戏在publish时才跑。平时就把它放在项目里,日常查看build与publish时的分析结果很重要。
put["在csproj里放入PublishAot"] --> daily["日常的dotnet run仍是JIT"]
put --> pub["publish时进行AOT编译"]
daily -.-> watch["日常查看分析与warning"]
图14:日常走 JIT,正戏在 publish。正因如此,配置不要临时加,而要常驻并持续查看分析结果。
6.2. publish
例如面向 Windows x64 时,是这样。
dotnet publish -c Release -r win-x64
面向 Linux x64 时,是这样。
dotnet publish -c Release -r linux-x64
输出是固定 RID 的。 看待方式会从“一份到处都能跑的 .NET DLL”,变成“为那个 OS / 架构专门生成的可执行文件”。
如果从 Web API 这边入手,从以 Native AOT 为前提的模板开始会更省事。
dotnet new webapiaot -o MyFirstAotWebApi
worker 的话用这个。
dotnet new worker -o WorkerWithAot --aot
6.3. JSON 的写法
在 Native AOT 里意外常撞上的是 JSON。
System.Text.Json 按平时的感觉使用容易滑向 reflection,因此挪到 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. 适合的场景
Native AOT 用起来最舒服的,是下面这些场景。
- 以启动为主角的 CLI / 工具
- 在容器里大量部署的小型 API
- worker / 后台服务
- serverless 或短生命周期进程
- 嵌入原生应用里的小型 .NET 组件
- 不想要求运行环境预装 .NET 运行时的场合
它们的共同点是,边界相对清晰,动态机制容易减少。
8. 不适合的场景
反过来,也有一些场合明确不适合一开始就把 Native AOT 当作主战场。
- WPF / WinForms 既有大型应用的主体
- 以 built-in COM interop 为前提的结构
- 以运行期加载 plugin 为主角的应用
- 强依赖用 reflection 搜索类型的框架的结构
- 把
System.Reflection.Emit和动态 proxy 用得理所当然的库 - 中间夹了 C++/CLI 的设计
这些情况下,以 JIT 为前提的普通 .NET、或者 ReadyToRun、或者重新梳理设计的边界划分,都更合理。
9. 容易踩的坑
最后,整理一下上手 Native AOT 时最容易踩的点。
- 轻视 publish warning
- 如前所述,Native AOT 的 warning 是需要认真读的对象。
- build 能过,publish 却坏掉
- publish 时会把依赖库也包含进来认真跑一遍分析,所以有些问题到这一步才第一次显现。
- 用同一种心态对待 ReadyToRun 和 Native AOT
- 词看着相似,限制的强度却相差很多。
- 一上来就从 desktop 应用主体开始
- 先从 console / worker / 小型 API 入手更稳。
- 按平时的习惯写 JSON 和配置绑定
- 以 reflection 为前提的写法,后面会反过来发作。
- 以为与平台无关就直接分发出去
- Native AOT 的发布产物是固定 RID 的。
- 以为“Native AOT = 什么都会变快”
- 主角是启动、分发、运行环境。看错这一点,期待值就会偏。
在 Native AOT 里,最终决定行不行的不是 dotnet build,而是 dotnet publish。
尽早开始跑这一步,后半程就不容易卡住。
flowchart TB
accTitle: 决定行不行的是publish
accDescr: build能通过却在publish时坏掉,是因为publish时会把依赖库也包含进来认真跑分析,最终决定Native AOT行不行的不是dotnet build而是dotnet publish。
buildok["dotnet build能通过"] --> notyet["行不行还没有定"]
pubx["dotnet publish"] --> deep["连依赖一起认真分析"]
deep --> verdict["到这里才第一次看得出行不行"]
verdict -.-> early["所以要尽早跑publish"]
图15:容易踩的坑的共同点。不要因为“build 过了”就安心,要尽早跑 publish。
10. 总结
一句话概括 Native AOT,就是 把 .NET 应用从动态的执行模型,推向容易在静态阶段确定下来的分发模型的机制。
需要留意的要点,就这五条。
- Native AOT 在 publish 时把代码预先编译成原生代码
- 对启动、内存、分发方面的需求很有效
- 代价是对 reflection、动态代码生成、built-in COM、不支持 trimming 的代码都很严格
- 第一批对象比起 desktop 主体,console / worker / 小型 API 更稳
- 一边消掉 warning,一边以 publish 为基准尽早验证,这一点很重要
Native AOT 不是所有 .NET 应用都要打开的标配开关。 但在 启动很重要、想让分发更轻、想减少运行环境的前提条件 的场景里,它是相当强的武器。
反过来,在 WPF / WinForms / COM 味道浓的世界里,目前仍有很多场合是普通的 .NET 更合理。 能分清这两边之后,Native AOT 就不再是“难搞的新功能”,而是一个用途明确的选项。
11. 参考资料
- Native AOT deployment overview - .NET
- Native AOT deployment overview - .NET (日本語)
- Introduction to AOT warnings - .NET
- Fixing trim warnings - .NET
- DynamicallyAccessedMembersAttribute Class - .NET
- Prepare .NET libraries for trimming - .NET
- Known trimming incompatibilities - .NET
- How to use source generation in System.Text.Json - .NET
- ASP.NET Core support for Native AOT
- ReadyToRun deployment overview - .NET
- Building native libraries - .NET
- ComWrappers source generation - .NET
- 相关文章:从 C/C++ 调用 C# Native AOT DLL 的方法
- 相关文章:从 C# 调用原生 DLL:C++/CLI 包装器 vs P/Invoke
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
多线程实战最佳实践 .NET 篇——增加线程之前必须先定好的事
面向 .NET/C# 梳理防止“线程一开就偶尔崩溃、偶尔卡死”的设计做法,涵盖不自己创建线程而依托 Task、减少共享可变状态、加锁的纪律、用 CancellationToken 设计停止流程,直到 UI 线程的处理方式。
业务系统的编码设计 ── 商品编码・客户编码的确定方法与校验位
确定商品编码・客户编码等业务系统编码体系的实践指南。整理了有意义编码与无意义流水号的判断表、JAN・Luhn等校验位算法及C#实现、Excel开头零丢失的应对方法,直至位数溢出与迁移。
如何理解 Windows 的会话隔离 ── Session 0・RDP・多用户同时运行
整理 Windows 应用程序开发者容易混淆的「会话」概念。说明服务无法显示 UI 的 Session 0 隔离原因、RDP 连接时会话的行为、命名对象的会话隔离,以及共享 PC・RDS 环境中常见的设计失误,从实务角度解说。
Windows 的进程间通信该怎么选 ── 命名管道 / TCP / gRPC / 共享内存 / COM 判断表
整理 Windows 应用程序之间应该如何选择通信方式。用判断表整理命名管道、本地 TCP、gRPC、共享内存、文件对接、COM 各自的优势与陷阱,并从实务角度说明 UI+服务分离・32bit/64bit 桥接・权限边界等常见架构,以及命名管道的实现示例。
Windows应用的数据存储位置怎么选 ── SQLite / JSON / 注册表 / Access 判断表
Windows桌面应用的数据该存在哪里、用什么格式保存?本文整理AppData/ProgramData的使用区分,以及SQLite、JSON文件、注册表、Access(.accdb)各自的优势与陷阱,并附判断表,从实务角度讲解防止数据损坏与位数问题等注意事项。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
32 位 / 64 位互通
整理 32 位 / 64 位互通、原生边界与相关 Windows 设计判断的主题页面。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
支持包含常驻处理、设备联动、运行日志与可维护结构的 Windows 桌面应用程序。
技术咨询 & 设计评审
协助梳理设计方向、架构边界、生命周期责任,以及既有 Windows 资产的处理方式。
常见问题
汇总了咨询这一主题时常见的问题。
- Native AOT 是什么?
- 它是把 .NET 应用在 publish 时预先编译成原生代码再分发的发布方式。因为不使用运行期 JIT,启动时间和内存占用容易变好,也更容易分发到没有安装 .NET 运行时的环境。代价是与自由的反射、动态代码生成、built-in COM 以及不支持 trimming 的库都不合拍。它与其说是让程序变快的魔法,不如说是为了启动、发布、运行环境上的需要,舍弃一部分动态世界、向静态世界靠拢的发布模型。
- Native AOT 和 ReadyToRun 有什么区别?
- ReadyToRun 是保留 IL、把 JIT 的工作稍微提前的方式,运行期仍有会用到 JIT 的场合。它的兼容性很广,动态功能大体上也照样好用。而 Native AOT 不以运行期 JIT 本身为前提,发布产物以原生可执行文件为主,启动相当容易改善,代价是限制更强。虽然都带着 AOT 这个词,但 ReadyToRun 是“让 JIT 轻松一点”的方向,Native AOT 是“不以 JIT 为前提”的方向,力度相差很多。
- WPF 或 WinForms 的应用可以用 Native AOT 吗?
- 在当前阶段最好相当谨慎地看待。Windows 上的 Native AOT 没有 built-in COM,WPF 与 trimming 配合不好,WinForms 对 built-in COM marshalling 的依赖很重,两者都不适合作为第一批 Native AOT 候选。如果确实需要 COM,有些场景下保持 JIT,或者以 ComWrappers / source-generated COM 为前提重新设计,反而更合理。作为入口,控制台程序、worker、小型 Web API 要自然得多。
- Native AOT 适合什么样的应用?
- 适合以启动为主角的 CLI 工具、在容器里大量部署的小型 API、worker 与后台服务、serverless 或短生命周期进程、嵌入原生应用里的小型 .NET 组件,以及不想要求运行环境预装 .NET 运行时的场合。它们的共同点是边界相对清晰、动态机制容易减少。反过来,以运行期加载插件为主角的应用,或者强依赖用反射搜索类型的框架的结构,就不适合。