.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
  • 使用動態 proxy 的函式庫

這些都容易讓事情變麻煩。

換句話說,做 Native AOT 的時候,與其「執行期聰明處理」,不如 往建置期就能看清楚的形式靠攏 比較好控制。

5. Windows 桌面端要稍微謹慎一點

在 Windows 上,Native AOT 沒有 built-in COM。
再加上 WPF 和 trimming 的搭配並不好,WinForms 也相當仰賴 built-in 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 既有的大型應用主體、以 plugin 載入為核心的結構、重度依賴 COM 的結構,做為首波對象並不太和平。

8. 總結

Native AOT 是在 publish 時把 .NET 做得相當靜態的機制。

  • 對啟動有效
  • 發布也會變輕鬆
  • 代價是對動態機制會變嚴苛

先掌握這三點,視野會清楚很多。

Native AOT 不是「所有 .NET 應用都該套上的標配開關」。
但在啟動重要、想讓發布更輕、想降低對執行環境的前提要求時,它是相當強的選項。

反過來,在 Windows 桌面或 COM 味濃的世界中,繼續走一般 .NET 往往比較順。
能分得出這兩邊之後,Native AOT 就不再是「難搞的新功能」,而是位置清楚的工具。

9. 參考資料

相關文章

共用相同標籤的最新文章。能以相近的主題延伸理解。

相關主題

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

與本主題相關的服務

本文連結到以下服務頁面,歡迎從最接近的入口查看。

回到部落格一覽