Async 状态机要搬进运行时了:.NET 10 Runtime Async 实测,快的地方快 1.5 倍,贵的地方贵 5 倍

这两天把一台服务的 async 热路径翻出来量了一遍。本来只想确认 P99 抖动跟 Task 分配有没有关系,顺手把 .NET 10 的 runtime-async 打开做了个 A/B。

结果有点反直觉。

同步完成的那条路径,确实快了 1.5 倍。但真正发生挂起的路径,每次挂起的分配量从 112 B 涨到了 576 B ------ 贵了 5 倍。

先说清楚,这不是标题党,也不是"新特性翻车"。下面每个数字都是我在本机跑出来的,工程文件、测量代码、环境版本全部贴出来,你可以自己复现一遍再决定信不信。


一、先把旧账算清楚:今天的 async 到底分配了什么

不搞清楚现状,就没法判断新方案改了什么。

C# 编译器处理 async 方法的方式是方法体重写 :给你生成一个 struct 状态机(名字长这样 <Foo>d__0),实现 IAsyncStateMachine,方法本体变成"创建状态机 → 调 builder.Start → 返回 builder.Task"。

csharp 复制代码
// 你写的
public static async Task<int> FooAsync(int v)
{
    await Task.CompletedTask;
    return v + 1;
}

// 编译器实际生成的(简化)
[AsyncStateMachine(typeof(<FooAsync>d__0))]
public static Task<int> FooAsync(int v)
{
    var sm = new <FooAsync>d__0();
    sm.<>4__this = ...;
    sm.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
    sm.<>t__builder.Start(ref sm);   // 注意:ref,状态机在栈上
    return sm.<>t__builder.Task;
}

这里有个经常被误解的点:await 一个已完成的 Task,是不分配的。

因为状态机是 struct,Start(ref sm) 传的是引用,状态机一直待在栈上;await 发现 IsCompleted == true 就直接往下走,从头到尾没上过堆。我测下来 async Task Foo() => await Task.CompletedTask; 的分配是 0 B/call,两种模式下都是。

真正上堆是在挂起 那一刻。AwaitUnsafeOnCompleted 会调 GetStateMachineBox(),分配一个 AsyncStateMachineBox<TStateMachine>。这个类的关键设计是:它继承 Task<TResult>,同时把状态机 struct 内联在自己身上,还实现了 IAsyncStateMachineBox。

一个对象,三个身份:Task、状态机、续体。

所以老模型的挂起成本非常干净------就是一个对象。我在本机量到 112 B (一个挂起一次的 async Task,返回非泛型 Task,不掺 Task 分配)。

这也解释了为什么 .NET 的 async 在高并发下没那么可怕:不是"每次 await 都分配",而是"每次真正挂起才分配一个对象"。


二、Runtime Async 换掉了什么

改动的核心是把"方法能否挂起"从语言层的重写约定 变成运行时的一等公民。

dotnet/runtime 仓库里那份 ECMA-335 修订草案写得很清楚,我摘几条关键的:

  • async 方法在 IL 里带 [MethodImpl(MethodImplOptions.Async)],方法标志位里有个 async flag(0x2000)
  • 挂起不再靠 builder,而是调 System.Runtime.CompilerServices.AsyncHelpers.Await(...)
  • 编译器最理想的发码是两条连续的 call:先调异步方法,紧接着调 AsyncHelpers.Await
  • 局部变量是 "hoisted" 的,但 byref 局部、ref-like 局部(Span<T> 这类)、pinned 局部不能跨挂起点,跨过去就是 null/default
  • 草案里的临时限制:不允许 tail. 前缀,不允许 localloc

这套设计意味着:方法就是用普通栈帧在跑,挂起时才由运行时把"还活着的部分"搬走,恢复时再搬回来。不再有编译期算好的固定布局。

我拿反射验了一下,两条证据。

证据一:状态机类型没了,方法标志变了。

复制代码
# 关闭 runtime-async
IMPLFLAGS=IL
ILSIZE=55
NESTED=<>c,<SuspendInt>d__2,<SuspendVoid>d__1,<SuspendVoidTwice>d__3,<SyncVoid>d__0

# 打开 runtime-async
IMPLFLAGS=Async
ILSIZE=13
NESTED=<>c

三个 <...>d__N 全消失,IL 从 55 字节缩到 13 字节------基本就是"装参数 → call AsyncHelpers.Await → ret"。

证据二:JIT 把它编译成了普通帧。

用 DOTNET_JitDisasm 反汇编同一个方法:

复制代码
; Assembly listing for method Probe:SuspendVoid(System.Threading.Tasks.Task):System.Threading.Tasks.Task (FullOpts)
; fp based frame
G_M000_IG01:
            stp     fp, lr, [sp, #-0x40]!
            str     x19, [sp, #0x38]
            mov     fp, sp
...
G_M000_IG05:
            str     x0, [fp, #0x18]     ; 局部变量落在物理帧槽位里

fp based frame,局部变量直接扔在 [fp+0x18] 这种栈槽位上。这就是"帧"而不是"状态机"的样子------挂起时运行时要知道哪些槽位还活着,才能把它们搬进堆上的续体。


三、怎么测才不算作假

微基准最容易骗人,先把条件摆出来。

环境

项 值
机器 Apple Silicon (arm64),macOS 26.7
SDK .NET SDK 10.0.201
运行时 10.0.5(CoreCLR)
配置 Release,工作站 GC,TieredPGO 开启
每项样本 20 万 ~ 200 万次调用,跑三轮取稳定值

两个工程的唯一差别就是 csproj 里这几行:

xml 复制代码
<!-- on.csproj:打开 runtime-async -->
<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <EnablePreviewFeatures>true</EnablePreviewFeatures>
  <NoWarn>$(NoWarn);SYSLIB5007</NoWarn>
  <Features>$(Features);runtime-async=on</Features>
</PropertyGroup>

跑的时候还要加环境变量,两个条件缺一不可:

bash 复制代码
DOTNET_RuntimeAsync=1 dotnet bin/Release/net10.0/on.dll

测量方法 :GC.GetAllocatedBytesForCurrentThread() 前后差值除以迭代次数。这个 API 只统计当前线程,所以能精确到字节,不受后台线程干扰。

踩坑提醒 :如果循环体算出来的结果没人读,JIT 会把整个循环当死代码删掉,你会量到一个漂亮的 0。我第一版就栽在这上面------Task<int> 明明该分配 72 B,量出来 0。加上 _sink / GC.KeepAlive 才正常。


四、数据

4.1 静态结构

指标 编译器状态机 runtime-async
方法 IL 字节数 55 13
MethodImplFlags IL Async
生成的 <...>d__N 类型数 3 0

4.2 分配(B/call)

TCS 基线 = 96 B,这是循环里 new TaskCompletionSource<int>() 本身的开销,下面扣掉它看增量。

场景 编译器状态机 runtime-async 变化
同步完成,返回 Task 0 0 持平
同步完成,返回 Task<int>(循环变量入参) 72 72 持平
挂起一次(含 TCS 96 B) 208 672 +223%
挂起一次增量(扣 TCS) 112 576 ×5.1
线程池恢复(含 TCS) 209 713 +241%

4.3 耗时

场景 编译器状态机 runtime-async
同步完成热路径 18.2 -- 18.4 ns 11.7 -- 11.9 ns
挂起一次(含 TCS 分配) 213 -- 216 ns 227 -- 235 ns

4.4 挂起成本随帧规模的变化

方法里放 N 个 long 局部变量,全部跨 await 使用:

局部变量数 编译器状态机 runtime-async 差值
4 176 B 648 B +472
32 400 B 872 B +472
128 1168 B 1648 B +480

每多一个 long 局部,两边都涨 8 B。斜率完全一样。


五、这四张表说明了什么

1. 同步完成路径确实快了,但没到"数量级"

18.3 ns → 11.7 ns,约 1.56 倍。

省下来的是 builder 的启动开销:老方案每次调用都要初始化 AsyncTaskMethodBuilder、走 Start、处理 ExecutionContext,最后从 builder 拿 Task。新方案就是一个普通方法调用,await 一个已完成的 Task 变成几次比较和跳转。

如果你的服务里大量 async 方法其实是"同步完成"的(缓存命中、内存查询、ValueTask 快路径),这个提升是白拿的。

2. Task<T> 那 72 字节,一分没省

这是最容易被宣传口径带偏的地方。

async Task<int> 每次调用还是要分配一个 72 B 的 Task<int>,开不开 runtime-async 都一样。因为它跟状态机没关系------只要你的方法签名返回 Task<T>,就得有个对象承载结果。

想在这条路径上零分配,解法还是老两样:同步快路径(先 TryGet,命中就直接返回) ,或者 ValueTask<T>。runtime async 不改变"返回类型决定分配"这件事。

3. 挂起路径在预览版里反而更贵

112 B → 576 B,多出来约 470 B 的固定开销,跟帧里有多少局部变量无关(4 个局部时差 472,128 个局部时也只差 480)。

钱花在哪,其实能推出来:

老方案里,那个 AsyncStateMachineBox 同时是 Task 和续体,一个对象全包。新方案里没有状态机盒子了,返回值的 Task 必须单独分配(72 B),再加上运行时物化的那块续体对象,固定开销就到 470 B 上下了。

延迟上的差距倒不大(215 ns vs 231 ns),因为这点分配在 Gen0 里根本不算事。真正要担心的是吞吐:如果每秒几百万次挂起,470 B × 300 万 = 1.4 GB/s 的额外分配速率,Gen0 回收频率会肉眼可见地上去。

再说一遍:这是 10.0.5 这个预览实现的数字。dotnet/runtime 里 R2R、PGO、NativeAOT、MultiCoreJIT 的 runtime-async 支持都还挂在 open issue 上,这块空间大概率会被填掉。

4. 两种方案都在搬"活着的局部变量"

斜率都是 8 B/long,说明底层逻辑是一样的:挂起时把活数据搬走。区别只是谁来决定"哪些是活的"------

  • 编译器方案:编译期算死布局,运行时只是 new 一个已知大小的对象
  • 运行时方案:JIT 知道帧布局,运行时按 GC 引用图去搬运,灵活但要多一层簿记

灵活性的代价就是那 470 B。这就是一笔很典型的交易:用每次挂起的固定开销,换掉每个方法的编译期布局约束。


六、实测踩到的坑

坑 1:少一个环境变量,直接 TypeLoadException

只加 Features 不加环境变量,运行时报这个:

复制代码
Unhandled exception. System.TypeLoadException: Could not load type 'Probe' from assembly 'on,
Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' because the format is invalid.

"format is invalid" 这个措辞极具误导性------你会以为是 IL 坏了,其实就是运行时没开开关。CI 里部署忘了带 DOTNET_RuntimeAsync=1,报错信息不会给你任何提示。

坑 2:草案说 catch/finally 里不能挂起,实测可以

ECMA 草案明确写着"挂起点不能出现在 handler 块(catch / filter / finally / fault)里"。我照着写了个反例:

csharp 复制代码
try
{
    await gate.ConfigureAwait(false);
    throw new InvalidOperationException("boom");
}
catch (InvalidOperationException)
{
    await other.ConfigureAwait(false);   // 草案说这里不该有挂起点
}
finally
{
    await gate.ConfigureAwait(false);    // 这里也不该有
}

两个 gate 都用 RunContinuationsAsynchronously 真挂起,输出:

复制代码
Runtime=10.0.5
  catch 内 await 成功
  finally 内 await 成功
  RESULT=ok

方法标志是 Async,也没有回退成状态机。所以实现已经跑到草案前面去了。

结论:别拿草案当规范读。要看行为,自己跑。

坑 3:栈回溯会多一层 thunk,还会丢帧

同一个调用链 Main → Outer → Middle → Inner,Inner 抛异常:

复制代码
# 老方案
   at Probe.Inner(Task gate) in Program.cs:line 33
   at Probe.Middle(Task gate) in Program.cs:line 39
   at Probe.Outer(Task gate) in Program.cs:line 44
   at Probe.Main() in Program.cs:line 60

# runtime-async
   at Probe.Inner(Task gate) in Program.cs:line 33
   at Probe.Middle(Task gate) in Program.cs:line 39
   at System.Runtime.CompilerServices.AsyncHelpers.ThunkTaskCore.MoveNext[T,TOps](T task)
--- End of stack trace from previous location ---
   at Probe.Main() in Program.cs:line 60

Outer 那一帧被 ThunkTaskCore.MoveNext 顶掉了。这跟 dotnet/runtime#126574 描述的现象一致:跨程序集/框架方法返回 Task<T> 时会生成一个非 async 的 thunk,把结果包成 Task。

线上排查问题的时候,这种"少一层业务帧"是很要命的。日志里看到异常来自 ThunkTaskCore,你得先知道有这么个东西,才不至于怀疑人生。

坑 4:只有你自己写的代码受益

.NET 10 的 BCL 没有用 runtime-async 重新编译。所以你 await 一个 File.ReadAllTextAsync、HttpClient.GetAsync,走的还是老状态机。提升只发生在你编译进去的那部分代码里。

想看端到端效果,得等到 .NET 11。

坑 5:返回类型和运行时的硬限制

  • 返回类型只能是 Task、Task<T>、ValueTask、ValueTask<T> 四种
  • byref 局部、Span<T> 这类 ref-like 局部不能跨挂起点(跨了就是 default/null,草案写得很直白)
  • NativeAOT、Mono 不支持;R2R 和 PGO 还没做
  • MethodImplOptions.Async 和 Synchronized 不能同时用

版本矩阵

.NET 10 .NET 11
目标框架 必须 net10.0 必须 net11.0
SDK .NET 10 .NET 11
EnablePreviewFeatures 需要 不需要
Features=runtime-async=on 需要 需要
DOTNET_RuntimeAsync=1 需要 不需要
运行时 仅 CoreCLR 仅 CoreCLR

注意第一行:必须编译目标就是 net10.0 ,光在 net10.0 上跑不够。这条我在 issue 里看到时还以为是笔误,实际就是这样------runtime-async 的 IL 形态依赖目标框架的 corlib 里有没有 RuntimeFeature.Async 常量。


七、现在到底要不要开

我的判断标准,按顺序过一遍:

可以试

  • 你的热路径里有大量"同步完成"的 async 方法(内存缓存、ValueTask 快路径、内存字典查询)
  • 你已经被状态机分配困扰很久,dotnet-counters 里 Gen0 的分配速率明显跟 async 调用量正相关
  • 你在做前瞻性技术验证,能接受预览版的不确定性

先别碰

  • 生产环境跑着关键业务。少个环境变量就是 TypeLoadException,这个失败模式太脆
  • 依赖 NativeAOT 或 Mono
  • 重度依赖异常栈回溯定位问题(thunk 丢帧那条会折磨你)
  • 每秒百万级挂起,且对 GC 压力敏感(470 B 的固定开销还没优化掉)

我自己的做法是:拿一个非核心服务开一周,开 DOTNET_GCHeapHardLimit 卡住内存上限,盯着 Gen0 回收次数和 P99 看。跑通了再谈推广。


写在最后

runtime async 真正的价值,不在"现在快多少"。

它把 async 从"每个语言各自实现的语法糖"变成了"运行时的一等公民":F#、VB、以后的新语言共享同一套挂起语义;调试器、Profiler、dotnet-stack 终于能看见真实的调用栈,而不是一堆 <Foo>d__12.MoveNext();跨语言调用 async 方法不用再翻译 builder。

代价也很清楚:状态布局从编译期推到了运行时。编译期能算死的东西,运行时就得现场算------那 470 B 就是这笔账的第一期账单。

至于这笔账划不划算,等 .NET 11 把 R2R、PGO、AOT 都补齐了再看。现在这个阶段,把它当成"能跑、能测、别上线"的东西就好。

代码全部在上面,/tmp/asyncprobe 里两个工程,改个参数就能自己跑。你要是量出不一样的数,欢迎评论区甩过来------尤其是 AMD64 上的,我还没测。

相关推荐
hixiong1231 小时前
C# 使用 ZXingCpp.Angle 进行条码、二维码识别
开发语言·c#·扫码
步步为营DotNet2 小时前
System.Text.Json 源生成:三个“看起来已经生效“的静默失效
c#·.net·native aot·源生成
CCblandy12 小时前
雪花 ID 四个边界实测:时钟回拨重号 500 个,前端 JSON.parse 直接丢精度
后端·python·算法·性能优化·架构
Highcharts.js14 小时前
Highcharts 发布16项最新安全公告
人工智能·安全·性能优化·highcharts·可视化图表
我不会起名字32216 小时前
MySQL 深分页 LIMIT 1000000 卡死:3 种改写与 4 组实测对比
mysql·性能优化·索引·分页·limit
用户7884773163417 小时前
用 C# 把每周 6 小时的 PDF 录入干到 20 分钟:一份可复制的桌面 OCR 方案
c#
加宝的起点雷达18 小时前
扩散语言模型跑出 2788 tokens/s,单卡就够,怎么做到的
人工智能·性能优化
csdn_aspnet18 小时前
C# .NetCore Swagger ISchemaFilter 处理枚举、特殊类型
c#·.netcore·swagger