當前位置: 妍妍網 > 碼農

.NET NativeAOT 指南

2024-02-02碼農

隨著 .NET 8 的釋出,一種新的「時尚」套用模型 NativeAOT 開始在各種真實世界的套用中廣泛使用。

除了對 NativeAOT 工具鏈的基本使用外,「NativeAOT」一詞還帶有原生世界的所有限制,因此您必須知道如何處理這些問題才能正確使用它。

在這篇部落格中,我將討論它們。

基本用法

使用 NativeAOT 非常簡單,只需要在釋出套用時使用 MSBuild 傳遞一個內容 PublishAot=true 即可。

通常,它可以是:

dotnet publish -c Release -r win-x64 /p:PublishAot=true

其中 win-x64 是執行時識別元,可以替換為 linux-x64 osx-arm64 或其他平台。您必須指定它,因為 NativeAOT 需要為您指定的執行時識別元生成原生程式碼。

然後釋出的套用可以在 bin/Release/<target framework>/<runtime identifier>/publish 中找到

關於編譯

在討論使用 NativeAOT 時可能遇到的各種問題的解決方案之前,我們需要稍微深入一點,看看 NativeAOT 是如何編譯程式碼的。

我們經常聽說 NativeAOT 會剪裁掉沒有被使用的程式碼。而實際上,它並不像 IL 剪裁那樣從程式集中剪裁掉不必要的程式碼,而是只編譯程式碼中參照的東西。

NativeAOT 編譯包括兩個階段:

  1. 掃描 IL 程式碼,構建整個程式檢視(一個依賴圖),其中包含所有需要編譯的必要依賴節點。

  2. 對依賴圖中的每個方法進行實際的編譯,生成程式碼。

請註意,在編譯過程中可能會出現一些「延遲」的依賴,因此上述兩個階段可能會交錯出現。

這意味著,在分析過程中沒有被計算為依賴的任何東西最終都不會被編譯。

反射

依賴圖是在編譯期間靜態構建的,這也意味著任何無法靜態分析的東西都不會被編譯。不幸的是,反射,即在不事先告訴編譯器的情況下在執行時獲取東西,正是編譯器無法弄清楚的一件事。

NativeAOT 編譯器有一些能力可以根據編譯時的字面量來推斷出反射呼叫需要什麽東西。

例如:

var type = Type.GetType("Foo");
Activator.CreateInstance(type);
classFoo
{
public Foo() => Console.WriteLine("Foo instantiated");
}

上面的反射目標(即 Foo )可以被編譯器弄清楚,因為編譯器可以看到你試圖獲取型別 Foo ,所以型別 Foo 會被標記為一個依賴,這導致 Foo 被編譯到最終的產物中。

如果你執行這個程式,它會如預期地打印 Foo instantiated

但是如果我們將程式碼改為如下:

var type = Type.GetType(Console.ReadLine());
Activator.CreateInstance(type);
classFoo
{
public Foo() => Console.WriteLine("Foo instantiated");
}

現在讓我們用 NativeAOT 構建並執行這個程式,然後輸入 Foo 來建立一個 Foo 的例項。你會立刻得到一個異常:

Unhandled Exception: System.ArgumentNullException: Value cannot be null. (Parameter 'type')
at System.ArgumentNullException.Throw(String) + 0x2b
at System.ActivatorImplementation.CreateInstance(Type, Boolean) + 0xe7
...

這是因為編譯器無法看到你在哪裏使用了 Foo ,所以它根本不會為 Foo 生成任何程式碼,導致這裏的 type null

此外,依賴分析是精確到單個方法的,這意味著即使一個型別被認為是一個依賴,如果該型別中的任何方法沒有被使用,該方法也不會被包含在程式碼生成中。

雖然這可以透過將所有型別和方法添加到依賴圖中來解決,這樣編譯器就會為它們生成程式碼。這就是 TrimmerRootAssembly 的作用:透過提供 TrimmerRootAssembly ,NativeAOT 編譯器會將你指定的程式集中的所有東西都作為根。

但是涉及泛型的情況就不是這樣了。

動態泛型例項化

在 .NET 中,我們有泛型,編譯器會為每個非共享的泛型型別和方法生成不同的程式碼。

假設我們有一個型別 Point<T>

structPoint<T>
{
public T X, Y;
}

如果我們有一段程式碼試圖使用 Point<int> ,編譯器會為 Point<int> 生成專門的程式碼,使得 Point.X Point.Y 都是 int 。如果我們有一個 Point<float> ,編譯器會生成另一個專門的程式碼,使得 Point.X Point.Y 都是 float

通常情況下,這不會導致任何問題,因為編譯器可以靜態地找出你在程式碼中使用的所有例項化,直到你試圖使用反射來構造一個泛型型別或一個泛型方法:

var type = Type.GetType(Console.ReadLine());
var pointType = typeof(Point<>).MakeGenericType(type);

上面的程式碼在 NativeAOT 下不會工作,因為編譯器無法推斷出 Point<T> 的例項化,所以編譯器既不會生成 Point<int> 的程式碼,也不會生成 Point<float> 的程式碼。

盡管編譯器可以為 int float ,甚至泛型型別定義 Point<> 生成程式碼,但是如果編譯器沒有生成 Point<int> 的例項化程式碼,你就無法使用 Point<int>

即使你使用 TrimmerRootAssembly 來告訴編譯器將你的程式集中的所有東西都作為根,也仍然不會為像 Point<int> Point<float> 這樣的例項化生成程式碼,因為它們需要根據型別參數來單獨構造。

解決方案

既然我們已經找出了在 NativeAOT 下可能發生的潛在問題,讓我們來談談解決方案。

在其他地方使用它

最簡單的想法是,我們可以透過在程式碼中使用它來讓編譯器知道我們需要什麽。

例如,對於程式碼

var type = Type.GetType(Console.ReadLine());
var pointType = typeof(Point<>).MakeGenericType(type);

只要我們知道我們要使用 Point<int> Point<float> ,我們可以在其他地方使用它一次,然後編譯器就會為它們生成程式碼:

// 我們使用一個永遠為假的條件來確保程式碼不會被執行
// 因為我們只想讓編譯器知道依賴關系
// 註意,如果我們在這裏簡單地使用一個 `if (false)`
// 這個分支會被編譯器完全移除,因為它是多余的
// 所以,讓我們在這裏使用一個不平凡但不可能的條件
if (DateTime.Now.Year < 0)
{
var list = new List<Type>();
list.Add(typeof(Point<int>));
list.Add(typeof(Point<float>));
}

DynamicDependency

我們有一個內容 DynamicDependencyAttribute 來告訴編譯器一個方法依賴於另一個型別或方法。

所以我們可以利用它來告訴編譯器:「如果 A 被包含在依賴圖中,那麽也添加 B」。

下面是一個例子:

classFoo
{
readonly Type t = typeof(Bar);
[DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(Bar))]
publicvoid A()
{
foreach (var prop in t.GetProperties())
{
Console.WriteLine(prop);
}
}
}
classBar
{
publicint X { get; set; }
publicint Y { get; set; }
}

現在只要編譯器發現有任何程式碼路徑呼叫了 Foo.A Bar 中的所有公共內容都會被添加到依賴圖中,這樣我們就能夠對 Bar 的每個公共內容進行動態反射呼叫。

這個內容還有許多多載,可以接受不同的參數來適應不同的用例,您可以在這裏檢視文件。

此外,現在我們知道 Foo.A 中的動態反射在剪裁和 NativeAOT 下不會造成任何問題,我們可以使用 UnconditionalSuppressMessage 來抑制警告資訊,這樣在構建過程中就不會再產生任何警告了。

classFoo
{
readonly Type t = typeof(Bar);
[DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(Bar))]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2080",
Justification = "The properties of Bar have been preserved by DynamicDependency.")]
publicvoid A()
{
foreach (var prop in t.GetProperties())
{
Console.WriteLine(prop);
}
}
}

DynamicallyAccessedMembers

有時我們試圖動態地存取型別 T 的成員,其中 T 可以是一個型別參數或一個 Type 的例項:

void Foo<T>()
{
foreach (var prop intypeof(T).GetProperties())
{
Console.WriteLine(prop);
}
}
classBar
{
publicint X { get; set; }
publicint Y { get; set; }
}

如果我們呼叫 Foo<Bar> ,很不幸,這在 NativeAOT 下不會工作。編譯器確實看到你是用型別參數 Bar 呼叫 Foo 的,但在 Foo<T> 的上下文中,編譯器不知道 T 是什麽,而且沒有其他程式碼直接使用 Bar 的內容,所以編譯器不會為 Bar 的內容生成程式碼。

這裏我們可以使用 DynamicallyAccessedMembers 來告訴編譯器為 T 的所有公共內容生成程式碼:

void Foo<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>()
{
// ...
}

現在當編譯器編譯呼叫 Foo<Bar> 時,它知道 T (特別的,這裏指 Bar )的所有公共內容都應該被視為依賴。

這個內容也可以套用在一個 Type 上:

Foo(typeof(Bar));
void Foo([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type t)
{
foreach (var prop in t.GetProperties())
{
Console.WriteLine(prop);
}
}

甚至在一個 string 上:

Foo("Bar");
void Foo([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] string s)
{
foreach (var prop in Type.GetType(s).GetProperties())
{
Console.WriteLine(prop);
}
}

所以在這裏你可能會發現我們有一個替代方案,用於我們在 DynamicDependency 一節中提到的程式碼範例:

classFoo
{
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
readonly Type t = typeof(Bar);
publicvoid A()
{
foreach (var prop in t.GetProperties())
{
Console.WriteLine(prop);
}
}
}

順便說一句,這也是推薦的方法。

TrimmerRootAssembly

如果你不擁有程式碼,但你仍然希望程式碼在 NativeAOT 下工作。你可以嘗試使用 TrimmerRootAssembly 來告訴編譯器將一個程式集中的所有型別和方法都作為依賴。但請註意,這種方法不適用於泛型例項化。

<ItemGroup>
<TrimmerRootAssemblyInclude="MyAssembly"/>
</ItemGroup>

TrimmerRootDescriptor

對於高級使用者,他們可能想要控制從一個程式集中包含什麽。在這種情況下,可以指定一個 TrimmerRootDescriptor

<ItemGroup>
<TrimmerRootDescriptorInclude="link.xml"/>
</ItemGroup>

TrimmerRootDescriptor 檔的文件和格式可以在這裏找到。

Runtime Directives

對於泛型例項化的情況,它們無法透過 TrimmerRootAssembly 或 TrimmerRootDescriptor 來解決,這裏需要一個包含 runtime directives 的檔來告訴編譯器需要編譯的東西。

<ItemGroup>
<RdXmlFileInclude="rd.xml"/>
</ItemGroup>

rd.xml 中,你可以為你的泛型型別和方法指定例項化。

rd.xml 檔的文件和格式可以在這裏找到。

這種方法不推薦,但它可以解決你在使用 NativeAOT 時遇到的一些難題。請在使用 trimmer descriptor 或 runtime directives 之前,總是考慮用 DynamicallyAccessedMembers DynamicDependency 來註釋你的程式碼,使其與剪裁/AOT 相容。

結語

NativeAOT 是 .NET 中一個非常棒和強大的工具。有了 NativeAOT,你可以以可預測的效能構建你的套用,同時節省資源(更低的記憶體占用和更小的二進制大小)。

它還將 .NET 帶到了不允許 JIT 編譯器的平台,例如 iOS 和主機平台。此外,它還使 .NET 能夠執行在嵌入式裝置甚至裸機裝置上(例如在 UEFI 上執行)。

在使用工具之前了解工具,這樣你會節省很多時間。