Skip to content
Go back

.NET 11 Runtime Async:异步为何更快

一个 async 方法可能从头到尾都没有真正暂停:缓存已经命中,Task 已经完成,调用链只是把结果向上传递。传统实现仍可能为这段代码准备状态机、awaiter 和返回对象。

.NET 11 的 Runtime Async 尝试把这笔成本推迟到真正发生暂停的时刻。同步完成的调用尽量像普通方法一样直接返回;遇到未完成的 await 后,运行时才保存恢复所需的状态。

Kai Sawano 的文章用生成代码和微基准展示了这条新路径。结合 .NET 官方文档可以得到一个更稳妥的结论:Runtime Async 对同步完成、调用层级深的异步代码最有吸引力,但它仍是预览功能,19.63× 等数字只能说明特定微基准中的上限,不能直接换算成应用吞吐量。

传统 async 把控制流提前拆开

C# 编译器通常会把 async 方法改写成状态机。每个可能暂停的位置对应一个状态,MoveNext() 负责继续执行,method builder 负责创建并完成对外返回的 TaskValueTask

public async Task<int> GetValueAsync()
{
    await Task.Delay(1000);
    return 42;
}

概念上,它会变成以下流程:

调用 MoveNext
  ├─ await 尚未完成:保存状态并注册 continuation
  └─ await 已经完成:继续执行并完成结果

这套模型让异步代码保持顺序写法,成熟且可靠。它也让 JIT 接手时看到的代码已经是 MoveNext()、awaiter 和 method builder 之间的协作,原始调用关系被拆散。大型 MoveNext() 难以内联,跨多个异步方法的同步快路径也较难一起优化。

成本在真正等待网络或磁盘时通常不突出,因为 I/O 延迟远高于几个对象和分支。缓存命中、已完成 Task、深层包装方法和高频短操作会把这部分固定开销放大。

ValueTask、任务缓存和手工消除多余 async 已经可以降低部分分配。它们需要开发者在 API 和代码结构上主动选择,JIT 仍缺少完整的原始异步控制流。

Runtime Async 把暂停交给运行时

Runtime Async 保留熟悉的 C# 写法,编译结果和调用约定发生变化。方法在 IL 中标记为异步方法,await 通过 AsyncHelpers 表达,JIT 能直接看到原始控制流。

新的 Async Calling Convention 在普通参数之外传递一个 Continuation,返回时也多出一条 continuation 通道:

(result, continuation) = Method(continuation, args)

第一次调用时,continuationnull。如果整个调用同步完成,方法直接返回结果和 null,调用者可以继续执行。目标架构允许时,普通结果与 continuation 分别通过寄存器传递。

遇到未完成的 await 后,JIT 才创建 continuation,保存跨暂停点仍然存活的局部变量、恢复位置以及结果或异常。待操作完成,运行时带着 continuation 再次进入方法,从保存位置继续。

这形成了清楚的按需成本:

同步完成:直接返回结果,continuation = null
发生暂停:分配 continuation,保存必要状态,稍后恢复

对外部普通 C# 调用者,方法仍表现为 Task<T>ValueTask<T>。运行时使用 thunk(薄适配层)连接普通调用约定与异步调用约定。若 JIT 能把适配层内联并证明对象不会逃逸,更多包装成本也有机会消失。

作者的基准测到了什么

原文比较了写作时最新的 .NET 11 日构建 Runtime Async(Async2)与 .NET 10 传统 async(Async1)。预热后,每项执行 1 亿次。结果如下:

场景Runtime Async 相对传统 async 的速度
async 方法,不暂停19.63×
await 已完成的 Task12.02×
await 已完成的 ValueTask2.25×
Task.Yield 暂停7.00×
ThreadPool continuation3.17×
TaskCompletionSource continuation3.99×
深层 async 状态机链7.40×

分配结果也支持同一判断。作者测试中的“不暂停 async 方法”和“已完成 Task”从每次 72 B 降到 0 B;深层异步链从 301 B 降到 192 B。ThreadPool continuation 只从 160 B 降到 152 B,TaskCompletionSource 场景仍为 160 B。

这些数字最能说明同步快路径和深层调用链的潜力。它们还包含几个重要限制:

因此,19.63× 应看作特定同步完成微基准的结果。一个请求若主要等待 20 毫秒数据库查询,即使 async 框架成本缩短几十纳秒,用户看到的总延迟也不会缩短 19 倍。

官方 Preview 7 数据给出了交叉证据

.NET 11 Preview 7 的运行时发布说明记录了相似方向的改进:

官方数字同样来自针对单一行为的基准。它们证明具体优化已经进入产品代码,也提醒我们收益会随暂停方式变化:已完成 Task 的快路径改善明显,真正调度和恢复的 Task.Yield 改善相对温和。

当前如何启用

截至 2026 年 8 月,Runtime Async 是 .NET 11 的预览功能。net11.0 项目可以在项目文件中显式开启:

<PropertyGroup>
  <TargetFramework>net11.0</TargetFramework>
  <Features>runtime-async=on</Features>
</PropertyGroup>

官方文档说明,net11.0 项目不再需要额外设置 EnablePreviewFeatures。旧的 DOTNET_RuntimeAsyncUNSUPPORTED_RuntimeAsync 环境变量已经移除。若需要在项目级关闭,可设置:

<PropertyGroup>
  <UseRuntimeAsync>false</UseRuntimeAsync>
</PropertyGroup>

.NET 运行库自身已用 runtime-async=on 编译,这给功能兼容性和性能提供了大范围验证。应用项目仍需主动开启,预览阶段不宜在缺少回归测试的生产系统中直接切换。

Runtime Async 当前面向 TaskTask<T>ValueTaskValueTask<T>。规范草案仍列出 by-ref、异常处理块中的暂停点等限制,工具链和诊断支持也在继续完善。

哪些项目值得优先测试

以下特征越明显,Runtime Async 越值得建立对照实验:

主要耗时来自远程 I/O、业务算法或锁竞争时,应先处理真正的瓶颈。Runtime Async 也无法消除必须发生的暂停、线程池排队和 ExecutionContext 中确实需要传递的状态。

用 A/B 测试判断真实收益

评估时,准备两个 Release 构建:一个开启 Runtime Async,一个设置 UseRuntimeAsync=false。保持 SDK、运行时、机器、启动参数和流量完全一致,再比较:

  1. 业务端到端吞吐量与 P50、P95、P99 延迟;
  2. 每次请求分配量、Gen0 频率和 GC 暂停;
  3. CPU profile 中 async thunk、continuation 和任务相关热点;
  4. 冷启动、预热过程和稳定运行后的差异;
  5. 异常、取消、AsyncLocal、自定义 awaiter、NativeAOT 与 ReadyToRun 路径;
  6. 依赖库混用传统 async 和 Runtime Async 时的行为。

微基准可以确认某条路径是否变快,生产决策需要业务负载。若吞吐量改善小于测量噪声,继续使用默认实现更稳妥;若分配和 CPU 热点明显下降,再扩大测试范围。

Runtime Async 的关键变化很克制:保留原始异步控制流,让 JIT 看见同步快路径,并在真实暂停时才保存状态。它让 async 更接近按实际使用付费,也把优化从开发者手工选择 ValueTask 和消除包装,推进到运行时可以统一处理的层面。现阶段最合适的动作是用 .NET 11 预览版建立 A/B 数据,持续关注兼容性与正式发布状态。

Aide Hub 会继续分享 AI 助手、开发工具和软件工程实践。

参考


Tags


Previous

.NET GC 基础:栈、堆、代际与压缩

Next

数据复制的三条路:单主、多主与无主