.NET 7 中的 NativeAOT

浏览量:2,490

什么是 NativeAOT ?

2022年11月9日,.NET Conf 2022,微软正式推出了 .NET 7, 此版本带来了更高的性能和许多新功能,适用于 C#11/F# 7、.NET MAUI、ASP.NET Core/Blazor、ML.NET、Web API、WinForms、WPF等。一直处于实验状态的 NativeAOT 也在此版本转移到了 .NET 7 的主线中。从实验性的 dotnet/runtimelab 存储库中移出,并进入 dotnet/runtime 存储库。

NativeAOT 使 .NET 代码能够提前编译为一个独立的本机二进制文件,该应用已提前 (AOT) 编译为本机代码。

gh

NativeAOT 与 .NET 6 托管运行时相比,冷启动速度提高了 86%, NativeAOT 应用具有更快的启动速度更小的程序体积更少的内存占用。 应用程序的用户可以在未安装 .NET Runtime的计算机上运行该应用程序。NativeAOT 的快速执行和较低的内存消耗也可以降低 Lambda 成本。

无反射模式下,完全独立的 “Hello world” 应用在 x64 上可编译为 1 MB 的文件 (最小 400 kB)

AOT vs JIT 编译

JIT (Just In Time) 即时编译

JIT 在最终用户/服务器计算机上执行,在 Roslyn 编译后,通过 Runtime 进行即时编译。

优点

  • 可以根据当前硬件情况实时编译生成最优机器指令
  • 可以根据当前程序的运行情况生成最优的机器指令序列
  • 当程序需要支持动态链接时,只能使用JIT
  • 可以根据进程中内存的实际情况调整代码,使内存能够更充分的利用

缺点

  • 编译需要占用运行时资源,会导致进程卡顿
  • 由于编译时间需要占用运行时间,对于某些代码的编译优化不能完全支持,需要在程序流畅和编译时间之间做权衡
  • 在编译准备和识别频繁使用的方法需要占用时间,使得初始编译不能达到最高性能

AOT (Ahead Of Time) 提前编译

AOT 通常在开发人员计算机上执行,在 Roslyn 编译后,通过 ILC 将 IL 编译为机器码。

优点

  • 在程序运行前编译,可以避免在运行时的编译性能消耗和内存消耗
  • 可以在程序运行初期就达到最高性能
  • 可以显著的加快程序的启动

缺点

  • 在程序运行前编译会使程序安装的时间增加
  • 无法一次编译,多平台运行
  • 提前编译的内容会占用更多空间

jit&aot

.NET NativeAOT 使用 ILC 将 Roslyn 生成的 IL 编译为机器码,开发者可以在开发阶段使用 JIT 进行调试,发布时采用 NativeAOT 以提高应用程序性能。

.NET NativeAOT 部署限制

本机 AOT 应用程序附带一些基本限制和兼容性问题。 关键限制包括:

  • 无动态负载(例如Assembly.LoadFile)
  • 无法运行时生成代码(例如System.Reflection.Emit)
  • 不支持 无约束的反射
  • 发布过程将分析整个项目及其依赖项,并在运行时可能会受到已发布应用程序的限制时生成警告。

.NET 7 中本机 AOT 的第一个版本具有额外的限制。 其中包括:

  • 并非所有运行时库都完全注释为本机 AOT 兼容的 (,即运行时库中的某些警告不能由最终开发人员) 操作。
  • 有限的诊断支持 (调试和分析) 。

平台/体系结构限制

下表显示了面向 .NET 7 时支持的编译目标。

平台 支持的体系结构
Windows x64、Arm64
Linux x64、Arm64
MacOS x64、Arm64

NativeAOT 路线

完整计划如下:

  • 将 NativeAOT 项目移出和移入dotnet/runtimelabdotnet/runtime
    • 构建
    • 集成测试
    • 打包
  • 切换到运行时构建的本机 AOT 包
  • 使用 NativeAOT 载入第一方 dotnet 控制台应用程序
  • 使用 NativeAOT 构建 Crossgen 并修剪
  • 支持 dotnet-trace
  • 性能:
    • 至少与JSON和Plaintext TechEmpower一样快
    • YARP
      • YARP 启动时间 小于50ms
    • 开始跟踪构建时间
  • 诊断
    • 引入 AOT 兼容性注释
      • 添加需求动态代码属性
    • 事件管道
    • 探索 SOS 扩展子集
  • 可重复且可验证的 NativeAOT 构建
    • 断续器
    • CET(阴影堆栈)
  • 开发工具包支持
    • 用于启用 AOT 编译的 MSBuild 属性
  • 测试
    • 测试与链接器的诊断兼容性
    • 类库单元测试
      -文档
    • 构建应用
    • 构建库

.NET 7 中不包含的计划 (.NET 8),尤其是在不适合修整的应用程序中:

  • 复杂的反射依赖框架,如 ASP.NET MVC 和 WPF
  • 具有动态加载程序集的插件模型的应用,如 MSBuild
  • 移动,WASM(已经由Mono提供)

使用 Native AOT

编译

在 .NET 7 正式发布时 Native AOT 将会被集成在 .NET SDK 中,你可以直接使用 dotnet publish 命令发布本机 AOT 应用程序。

确保发布本机 AOT 应用程序所需的先决条件位于计算机中。

  • 在 Windows 上安装 Visual Studio 2022,包括使用 C++ 工作负载进行桌面开发。

  • 在 Linux 上,为 .NET 运行时所依赖的库安装 clang 和开发人员包。

Ubuntu (18.04+)

sudo apt-get install clang zlib1g-dev

Alpine (3.15+)

sudo apk add clang gcc lld musl-dev build-base zlib-dev

如果你使用 .NET 6 你需要在发布前通过 Nuget 安装 Microsoft.DotNet.ILCompiler

将 AOT 标识添加到项目文件。

这将生成本机 AOT 应用,并在生成期间显示 PublishAot 兼容性警告。

<PropertyGroup>
    <PublishAot>true</PublishAot>
</PropertyGroup>

使用 dotnet publish 为特定的运行时发布应用.

以下示例将Windows的应用发布为计算机上安装所需的先决条件的本机 AOT 应用程序。

dotnet publish -r win-x64 -c Release

以下示例将适用于 Linux 的应用发布为本机 AOT 应用程序。 在 Linux 计算机上生成的本机 AOT 二进制文件仅适用于相同或更新的 Linux 版本。 例如,在 Ubuntu 20.04 上生成的本机 AOT 二进制文件将在 Ubuntu 20.04 及更高版本上运行,但它不会在 Ubuntu 18.04 上运行。

dotnet publish -r linux-arm64 -c Release

该应用将在发布目录中可用,并包含在发布目录中运行所需的所有代码,包括 coreclr 运行时的剥离版本。

使用每日构建

若要使用每日生成,需要确保项目的文件在元素下包含以下包源:

<add key="dotnet7" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet7/nuget/v3/index.json" />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />

如果您的项目没有文件,则可以通过以下命令创建 nuget.config

dotnet new nugetconfig

从项目的根目录。如果决定保留元素,则必须在元素之后添加新的包源。<clear />

添加包源后,通过运行

dotnet add package Microsoft.DotNet.ILCompiler -v 7.0.0-*

或通过将以下元素添加到项目文件中:

<ItemGroup>
    <PackageReference Include="Microsoft.DotNet.ILCompiler" Version="7.0.0-*" />
</ItemGroup>

跨架构编译

原生AOT工具链允许在x64主机上定位ARM64,反之亦然。不支持跨操作系统编译,例如在 Windows 主机上定位 Linux。若要在 Windows x64 主机上面向 win-arm64,除了程序包引用之外,还要添加程序包引用以获取 x64 托管的编译器:Microsoft.DotNet.ILCompiler runtime.win-x64.Microsoft.DotNet.ILCompiler

<PackageReference Include="Microsoft.DotNet.ILCompiler; runtime.win-x64.Microsoft.DotNet.ILCompiler" Version="7.0.0-preview.2.22103.2" />

请注意,对两个包使用相同的版本非常重要,以避免潜在的难以调试的问题(使用dotnet7中的最新版本)。添加包引用后,您可以像往常一样发布 win-arm64:

dotnet publish -r win-arm64 -c Release

同样,要在 Linux x64 主机上面向 linux-arm64,除了包引用之外,还要添加包引用以获取 x64 托管的编译器:Microsoft.DotNet.ILCompiler runtime.linux-x64.Microsoft.DotNet.ILCompiler

<PackageReference Include="Microsoft.DotNet.ILCompiler; runtime.linux-x64.Microsoft.DotNet.ILCompiler" Version="7.0.0-preview.2.22103.2" />

您还需要使用该属性为 Clang 指定 sysroot 目录。例如,假设您正在使用此存储库用于交叉编译的面向 ARM64 的 Docker 映像 之一,则可以使用以下命令针对 linux-arm64 进行发布:SysRoot

> dotnet publish -r linux-arm64 -c Release -p:CppCompilerAndLinker=clang-9 -p:SysRoot=/crossrootfs/arm64

调试

NativeAOT提前编译器生成完全原生的可执行文件,这些文件可以由您选择的平台上的本机调试器进行调试(例如,Windows上的WinDbg或Visual Studio,类Unix系统上的gdb或lldb)。

NativeAOT 编译器生成有关行号、类型、局部变量和参数的信息。本机调试器将允许您检查堆栈跟踪和变量、单步执行/单步执行源代码行或设置行断点。

若要调试托管异常,请在方法上设置断点 – 每当引发托管异常时,都会调用此方法。RhThrowEx

无反射模式

无反射模式是 NativeAOT 编译器和运行时的一种模式,它大大降低了反射 API 的功能,并因此带来了一些有趣的好处。此模式的优点是:

  • 大大减小了独立部署的大小 – 完全独立的“Hello world”应用可编译为 1 MB 文件(在 x64 上),没有依赖项。

  • 减少工作集和更好的代码局部性 – 程序的各个部分更紧密地打包在一起。

  • 人们进行逆向工程的元数据更少 – 在无反射模式下编译的应用程序与以C++编写的应用程序一样难以进行反向工程。

当然,好处也有一个缺点:并非所有 .NET 代码都可以在这样的环境中工作,请谨慎使用此模式。

若要在已使用 NativeAOT 的项目中启用无反射模式,请将以下属性添加到项目文件中的 中:PropertyGroup

<PropertyGroup>
    <IlcDisableReflection>true</IlcDisableReflection>
</PropertyGroup>

禁用反射时编译时有何不同

禁用反射后,AOT 编译器将停止发出在运行时使反射工作所需的数据结构,并停止强制实施使代码更易于反射的策略。

  • 方法、类型、参数的名称不再生成到可执行文件中。
  • 不再生成其他元数据,如方法参数类型列表。
  • 编译器不再生成适合反射调用的独立方法体。
  • 不再生成将方法名称映射到本机代码的映射表。
  • 不再编译反射的实现。

在无反射模式下工作的反射 API

无反射模式支持一组有限的反射 API,这些 API 保留其预期的语义。

  • typeof(SomeType) 可与其他表达式的结果或调用的结果进行比较。泛型代码性能优化中常用的模式将正常工作。System.Type typeof Object.GetType() typeof(T) == typeof(byte) obj.GetType() == typeof(SomeType)

  • 以下 API 工作:System.Type TypeHandle UnderlyingSystemType BaseType IsByRefLike IsValueType GetTypeCode GetHashCode GetElementType GetInterfaces HasElementType IsArray IsByRef IsPointer IsPrimitive IsAssignableFrom IsAssignableTo IsInstanceOfType

  • Activator.CreateInstance<T>()将工作。编译器在编译时对其进行静态分析并将其扩展为高效代码。运行时不涉及反射。

  • Assembly.GetExecutingAssembly() 将返回可与其他运行时实例进行比较的 。这主要是为了使 System.Reflection.Assembly Assembly NativeLibrary.SetDllImportResolver 可用。

详情可见此处

AOT 模式下的反射

提前编译 .NET 代码时,编译器提前面临的一个典型问题是决定要编译哪些代码以及要生成的数据结构。

对于静态语言(如 C 或 C++),决定在最终可执行文件中包含哪些内容的问题非常简单:首先要包含并建立其他方法和数据结构引用的内容。然后包括这些引用,引用的引用等等,直到没有引用可以包含为止。这个概念很容易理解,非常适合C或C++等语言。这种方法的不错副作用是生成的程序很小。如果代码不调用函数,则不会在最终的可执行文件中生成该函数。

这种方法的问题开始出现在允许不受约束的反思的平台上。反射是 .NET 提供的一种机制,它允许开发人员在运行时检查程序的结构并访问/调用类型及其成员。通过不受约束的反思,“程序”的定义包括“在编译程序时可以访问的所有内容”。

案例:

class Program
{
    public static void Main()
    {
        Console.Write("Name of type: ");
        string typeName = Console.ReadLine();

        // Allow to exit the program peacefully
        if (String.IsNullOrEmpty(typeName))
            return;

        Console.Write("Name of method: ");
        string methodName = Console.ReadLine();

        Type.GetType(typeName).GetMethod(methodName).Invoke(null, null);
    }

    public static void SayHello()
    {
        Console.WriteLine("Hello!");
    }
}

虽然上面的示例程序在现实中并不实用,但类似的模式存在于例如基于反射的序列化和反序列化库中,这些库根据成员的名称访问成员,这些名称可以从互联网上下载。反射的动态特性不会仅对完全 AOT .NET 运行时造成问题。当使用 IL 链接器等工具删除不必要的代码时,这也是一个问题。

在外部向编译器提供提示

如果编译器无法检测应用程序使用的类型,则可以对 rd.xml 文件进行补充,以帮助 ILCompiler 找到应分析的类型。为此,应创建文件并将以下行添加到项目文件中 rd.xml

<ItemGroup>
    <RdXmlFile Include="rd.xml" />
</ItemGroup>

你可以在此处找到此文件的配置说明。

实践

JsDelivrCLI NativeAOT 改造

这是一个从 JsDelivr 安装和使用第三方客户端库的命令行工具,本质只是调用 JsDelivr API 下载第三方客户端库,有点类似 npm. 整体迁移到 NativeAOT 应该只需要考虑 JSON 序列化与反序列化.

delivr
  JsDelivr CLI

Usage:
  delivr [options] [command]

Options:
  --version       Show version information
  -?, -h, --help  Show help and usage information

Commands:
  init               Initialize a package configuration file
  install <library>  install a package from jsdelivr
  remove <library>   remove a package from local
  search <library>   search package from npm
  info <library>     get library version info
  restore            restore client side package

在没有反射的情况下将json映射到对象几乎是不可能的,除非根据key一个一个进行处理,但这样会导致代码冗余,逻辑性不强。

所以我们需要借助到一个 .NET 6 中引入的一个功能,C# 源生成器(Source Generator)

C# Source Generator

源生成器是 .NET 6 中引入的一项语言功能。它允许在编译时生成部分代码,而不是在运行时依赖于反射,你可以查看这里了解更多.

源生成器作为编译阶段运行,如下所示:

sg

若要使用 JSON源生成器,必须定义一个新的空分部类,该类继承自 System.Text.Json.JsonSerializerContext。在空分部类上,为序列化的类型添加 JsonSerializable 属性。

internal class Library
{
    ...
}

[JsonSerializable(typeof(Library))]
internal partial class LibraryJsonContext : JsonSerializerContext { }

编译应用程序时,源生成器将生成静态类、属性和方法的代码来执行反序列化。

  • 反序列化
HttpResponseMessage response = await httpClient.GetAsync(path);
string jsonStr = await response.Content.ReadAsStringAsync();
JsonSerializer.Deserialize(jsonStr, LibraryJsonContext.Default.Library);

如何在 ASP.NET 中使用 NativeAOT ?

ASP.NET 中大量的使用到了反射 (e.g. Hosting, DI, middleware etc). 但 ASP.NET 是一个模块化的框架,我们可以根据需要剔除大量依赖反射的部分,也可以使用针对 NativeAOT 优化的库进行替换 (例如编译时依赖注入).

这是一个简化 ASP.NET 并通过 NativeAOT 改造的案例,最终可以生成一个 ~3MB size 的 Web Server.

https://github.com/lixinyang123/Ben.Http.NativeAOT

使用 NativeAOT 在 AWS 上构建 Serverless 应用程序

要使应用程序在 AWS Lambda 中可用,程序中需要包含 Lambda runtime client。Lambda runtime client 会将您的应用程序与 AWS Lambda Runtime API 集成,从而使您的应用程序代码能够由 AWS Lambda 调用。

您可以使用 dotnet CLI (Amazon.Lambda.Tools) 或 AWSToolkit for Visual StudioAWSServerless Application Model (AWS SAM 用于构建无服务器应用程序的开源框架) 来构建和部署 AWS Lambda。

本机 AOT 为特定操作系统编译代码。如果在计算机上运行 dotnet publish 命令,则编译的代码仅支持在对应体系结构的系统上运行。要使用 NativeAOT 在 Lambda 中运行您的应用程序,必须在 Amazon Linux 2 (AL2) 操作系统上编译代码。新工具支持在基于 AL2 的 Docker 映像中编译 Lambda 函数,并将编译的应用程序存储在本地硬盘驱动器上。您也可以在本地编译代码后上传到 Lambda。

环境

创建

首先使用 .NET CLI 创建一个 NativeAOT Lambda 项目。

dotnet new lambda.NativeAOT -n LambdaNativeAot
cd ./LambdaNativeAot/src/LambdaNativeAot/
dotnet add package Amazon.Lambda.APIGatewayEvents
dotnet add package AWSSDK.Core

若要查看项目设置,请打开 LambdaNativeAot.csproj 文件。此模板中的目标框架为net7.0 。若要启用 NativeAOT,请添加一个名为 PublishAot 新属性并标识为 true。此标志是 .NET SDK 所需的 MSBuild 属性,以便编译器执行本机 AOT 编译。

将 Lambda 与自定义运行时配合使用时,Lambda 服务会在打包的 ZIP 文件中查找名为 bootstrap 的可执行文件。要启用此功能,需要将 OutputType 设置为 exe 并将 AssemblyName 设置为 bootstrap

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net7.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <AWSProjectType>Lambda</AWSProjectType>
        <AssemblyName>bootstrap</AssemblyName>
        <PublishAot>true</PublishAot>
    </PropertyGroup> 
    …
</Project>

代码

如果要使用自定义运行时运行 .NET 应用程序。您的代码必须定义静态 Main 方法。在 Main 方法中,您必须初始化 Lambda 运行时客户端,并配置要在处理 Lambda 事件时使用的函数处理程序和 JSON 序列化程序。

您需要将 Amazon.Lambda.RuntimeSupportNuget 包将添加到项目中以启用此运行时初始化。并使用 LambdaBootstrapBuilder.Create() 方法配置处理程序和实现 ILambdaSerializer 用于(反)序列化。

private static async Task Main(string[] args)
{
    Func<string, ILambdaContext, string> handler = FunctionHandler;
    await LambdaBootstrapBuilder.Create(handler, new DefaultLambdaJsonSerializer())
        .Build()
        .RunAsync();
}

程序集修剪

NativeAOT 修剪应用程序代码以优化编译的二进制文件,这可能会导致两个问题。

  • .NET 中常见的 JSON 库如 (Newtonsoft.JsonSystem.Text.Json) 依赖于反射。

  • 编译器可能会裁剪掉未对 AOT 优化的第三方库所需的依赖。

目前这两个问题都有对应的解决方案。

使用 JSON

上面已经介绍过了,使用源生成器.

在此示例中,Lambda 函数需要从 API 网关接收事件。在项目中创建一个 HttpApiJsonSerializerContext 类并复制以下代码:

[JsonSerializable(typeof(APIGatewayHttpApiV2ProxyRequest))]
[JsonSerializable(typeof(APIGatewayHttpApiV2ProxyResponse))]
public partial class HttpApiJsonSerializerContext : JsonSerializerContext { }

编译应用程序时,源生成器将生成静态类、属性和方法的代码来执行反序列化。

现在还必须将此自定义序列化程序传入 Lambda 运行时,以确保正确序列化和反序列化事件输入和输出。为此,请在引导时将序列化程序上下文的新实例传递到运行时。

using System.Text.Json.Serialization;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
namespace LambdaNativeAot;
public class Function
{
    /// <summary>
    /// The main entry point for the custom runtime.
    /// </summary>
    private static async Task Main()
    {
        Func<APIGatewayHttpApiV2ProxyRequest, ILambdaContext, Task<APIGatewayHttpApiV2ProxyResponse>> handler = FunctionHandler;
        await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer<HttpApiJsonSerializerContext>())
            .Build()
            .RunAsync();
    }

    public static async Task<APIGatewayHttpApiV2ProxyResponse> FunctionHandler(APIGatewayHttpApiV2ProxyRequest apigProxyEvent, ILambdaContext context)
    {
        // API Handling logic here
        return new APIGatewayHttpApiV2ProxyResponse()
        {
            StatusCode = 200,
            Body = "OK"
        };
    }
}

第三方库

.NET 编译器提供了控制修剪应用程序的功能。对于未对 NativeAOT 优化的第三方程序集,我们可以通过配置排除对此程序集的修剪。这对于任何 Lambda 事件源 NuGet 包(如 Amazon.Lambda.ApiGatewayEvents)都很重要。如果不对此进行控制,则会修剪 Amazon API 网关事件源的 C# 对象,从而导致运行时出现序列化错误。

若要控制程序集修整,请在项目根目录中创建一个名为 rd.xml 的新文件。有关 rd.xml 格式的完整详细信息,请参见 Microsoft Docs。将程序集添加到 rd.xml 文件会将其排除在修整之外。

以下为排除 AWS SDK 的 rd.xml 示例。

<Directives xmlns="http://schemas.microsoft.com/netfx/2013/01/metadata">
    <Application>
        <Assembly Name="AWSSDK.Core" Dynamic="Required All"></Assembly>
        <Assembly Name="Amazon.Lambda.APIGatewayEvents" Dynamic="Required All"></Assembly>
        <Assembly Name="bootstrap" Dynamic="Required All"></Assembly>
    </Application>
</Directives>

添加 rd.xml 后,必须更新 csproj 文件以引用 rd.xml 文件。

<ItemGroup>
    <RdXmlFile Include="rd.xml" />
</ItemGroup>

编译函数时,程序集修整将跳过指定的三个库。如果您将 .NET 7 本机 AOT 与 Lambda 配合使用,建议您排除 Lambda 函数使用的库。

打包并部署 NativeAOT 编译的 Lambda 函数

使用 dotnet CLI

dotnet lambda deploy-function

使用 Lambda 工具 CLI 编译和打包 Lambda 函数代码时,该工具会检查项目中的 PublishAot 标志。如果设置为 true

该工具将拉取基于 AL2 的 Docker 映像并在其中编译代码。它将本地文件系统挂载到正在运行的容器,允许将编译的二进制文件存储回本地文件系统,以便进行部署。默认情况下,生成的 ZIP 文件将输出到目录 bin/Release

部署完成后,您可以执行以下命令来调用创建的函数

dotnet lambda invoke-function FUNCTION_NAME

使用 Visual Studio

使用 AWS Toolkit for Visual Studio 从 Visual Studio 中编译和部署基于 AOT 的本机 Lambda 函数。

在 Visual Studio 中,选择 文件->新建项目。搜索 Lambda function .NET 7 native AOT 并创建项目。

创建项目后,在 Visual Studio 中右键单击该项目,然后选择发布到 AWS Lambda。

完成发布向导中的步骤并点击上传,等待编译完成….

从 Visual Studio 中调用 API Gateway 来运行已部署的 Lambda。

参考

留下评论