从编译器状态机到 Runtime Async:.NET 11 前后的异步差异
Runtime Async V2 在 .NET 11 中仍是预览功能。本文基于 .NET 11 Preview 6 和当前设计草案,后续版本的配置、限制与实现细节仍可能变化。
1. 相同的写法,不同的实现
.NET 11 前后,业务代码中的 async 和 await 基本不变:
static async Task<User> GetUserAsync(
int id,
CancellationToken cancellationToken)
{
User user = await LoadUserAsync(id, cancellationToken);
return user;
}
变化发生在编译和执行阶段:
- .NET 11 之前,C# 编译器把异步方法改写为状态机。
- 启用 Runtime Async V2 后,编译器生成运行时可识别的异步 IL,由运行时和 JIT 管理挂起与恢复。
这不是新的异步 API,也不需要重写现有的 await 调用,而是异步方法底层实现模型的变化。
2. .NET 11 之前:编译器生成状态机
传统异步方法会被编译器拆成入口方法和状态机。状态机通常包含:
- 表示当前执行位置的状态字段。
- 用于完成
Task的异步方法构建器。 - 需要跨越
await保存的参数、局部变量和 awaiter。 - 负责继续执行方法的
MoveNext()。
下面是简化后的概念代码,不是编译器生成结果的逐行还原:
struct GetUserStateMachine : IAsyncStateMachine
{
int state;
TaskAwaiter<User> awaiter;
AsyncTaskMethodBuilder<User> builder;
public void MoveNext()
{
// 根据 state 恢复到对应位置,
// 未完成时注册 continuation,完成后设置结果或异常。
}
}
这种改写让 C# 很早就能支持完整的异步语义,但运行时看到的主要是普通方法、状态机和 continuation,难以直接理解原始异步方法的完整结构。
3. .NET 11:Runtime Async V2
Runtime Async V2 把异步方法提升为运行时能够识别的执行模型。编译器不再为这类方法生成传统的状态机类型,而是在 IL 中标记异步方法和挂起点,由运行时负责:
- 在任务未完成时挂起当前方法。
- 保存恢复执行真正需要的状态。
- 在任务完成后从对应位置继续执行。
- 为 JIT、ReadyToRun 和 NativeAOT 提供统一的异步代码生成能力。
从调用者角度看,方法仍返回 Task 或 ValueTask;从运行时角度看,它不再只是一个包装状态机的普通方法。
4. 两种实现的主要差异
| 对比项 | .NET 11 之前 | .NET 11 Runtime Async V2 |
|---|---|---|
| 实现主体 | C# 编译器改写 | 运行时与 JIT 管理 |
| 挂起与恢复 | 状态机、MoveNext()、方法构建器 | 运行时识别异步方法和挂起点 |
| 生成类型 | 通常生成专用状态机类型 | 不再生成传统编译器状态机 |
| 实时调用栈 | 容易出现状态机和构建器帧 | 更接近源码中的真实调用链 |
| 优化范围 | 编译器围绕状态机优化 | JIT、ReadyToRun、NativeAOT 可直接理解异步语义 |
| 成熟度 | 已长期用于生产环境 | .NET 11 中仍为预览功能 |
5. 性能与诊断变化
5.1 更清晰的实时调用栈
传统实现会在实时调用栈中暴露状态机和 AsyncMethodBuilder 等基础设施。Runtime Async 可以直接保留原始异步方法的调用关系,更方便分析 profiler、调试器调用栈以及 new StackTrace() 的结果。
官方示例中,实时调用栈从 13 帧减少到 5 帧。这个数字只对应示例代码,实际结果取决于调用层级和优化情况。
异常对象中的 stack trace 原本就会经过异步栈整理,因此两种实现下可能看起来相近。Runtime Async 改善的重点是程序运行期间看到的实时调用栈。
5.2 减少异步基础设施开销
.NET 11 当前实现包含以下优化:
- 更积极地缓存和复用 continuation。
- 避免保存没有发生变化的局部状态。
- 合并多个挂起点的公共代码,减小生成代码体积。
- 为同步返回任务的方法生成专用的 runtime-async 版本,减少额外转发。
- 支持 ReadyToRun 和 NativeAOT。
- 允许没有实际挂起的同步快速路径被内联。
这些能力为降低分配、代码体积和调度开销提供了空间,但不代表每个异步方法都会更快。任务是否同步完成、挂起次数、ExecutionContext、I/O 延迟和调用频率都会影响结果。
5.3 不要混淆 ExecutionContext 优化
.NET 11 还会在没有环境状态需要保留时,跳过 continuation 的 ExecutionContext 捕获和恢复。Task、Task<T>、ValueTask 和 ValueTask<T> 都能受益。
这项优化会帮助 Runtime Async 路径,但它属于 .NET 11 的通用异步优化,不是只有开启 Runtime Async 才能获得的能力。
6. 启用与关闭
在 .NET 11 项目中显式启用:
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Features>runtime-async=on</Features>
</PropertyGroup>
net11.0 项目不再需要额外设置:
<EnablePreviewFeatures>true</EnablePreviewFeatures>
.NET 11 运行时库本身已经使用 runtime-async=on 编译。需要按项目关闭时,使用:
<PropertyGroup>
<UseRuntimeAsync>false</UseRuntimeAsync>
</PropertyGroup>
此前用于控制 Runtime Async 的 DOTNET_RuntimeAsync 和 UNSUPPORTED_RuntimeAsync 环境变量已经移除,不应继续依赖。
7. 支持范围与当前限制
Runtime Async 当前支持 Task、Task<T>、ValueTask 和 ValueTask<T> 四类返回类型。
当前规范仍是草案,包含一些 IL 和状态保存限制,例如:
- by-ref 局部变量不能跨挂起点保存。
- 挂起点不能位于异常处理块中。
- 暂时禁止使用
localloc指令和tail前缀。
这些是运行时、编译器和底层工具需要处理的约束。普通 C# 业务代码通常不需要直接操作相关 IL,但使用 IL weaving、自定义编译器、代码生成器或底层诊断工具时应重点验证兼容性。
8. 是否应该立即迁移
现有代码不需要为了 Runtime Async 重写 async 和 await。更合适的验证方式是:
- 在 .NET 11 测试分支中开启
runtime-async=on。 - 运行单元测试、集成测试以及取消、异常和超时场景。
- 对异步热路径比较吞吐量、延迟、分配量和程序集体积。
- 检查 profiler、调试器、日志和 IL 工具是否正常工作。
- 如果收益不稳定或工具链不兼容,使用
UseRuntimeAsync暂时关闭。
Runtime Async V2 的意义不只是少生成一个状态机类型,而是让运行时第一次能够直接理解和优化异步方法。它为后续优化打开了更大的空间,但在预览阶段,是否采用仍应由真实负载和兼容性测试决定。