这两天把一台服务的 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)],方法标志位里有个asyncflag(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 上的,我还没测。