一句话剧透:你的
async/await源码一行都不用改,但它在底层的运行方式被彻底翻新了一遍。
异步编程是现代 .NET 的命脉——几乎每个服务、每个 API、每个有界面的程序都离不开它。但很多人不知道的是,从 C# 5(2012 年)到今天,async/await 的底层实现机制其实一直没变过:靠编译器生成状态机。
.NET 11 做的事情,就是把这套已经服役十几年的机制连根换掉,搬到运行时里去。这个新方案叫 Runtime Async(早期代号 "Async 2")。
这篇文章我们就来掰开揉碎地对比一下:老的 Async/Await 是怎么工作的,Runtime Async 又新在哪,以及它为什么能让异步代码的性能逼近同步代码。
一、先回到起点:为什么异步这么难写
异步的本质问题是:同步代码和异步代码的写法完全不同。
同步代码会阻塞当前线程,等操作做完再往下走;异步代码不阻塞线程,而是在发起操作时预先登记一段"完成后要执行的逻辑",等操作真正完成时再触发它。
在 async/await 出现之前,这段"完成后的逻辑"只能用回调来表达。于是 .NET 早期的异步长这样(APM 模式,又叫 Begin/End 模式):
try
{handler.BeginDoStuff(arg, iar =>{try{Handler h = (Handler)iar.AsyncState!;int i = h.EndDoStuff(iar);Use(i);}catch (Exception e2) { /* 处理 EndDoStuff 和 Use 的异常 */ }}, handler);
}
catch (Exception e) { /* 处理 BeginDoStuff 同步抛出的异常 */ }
一旦掺进循环、条件分支这类控制流,回调就会层层嵌套成"回调地狱",错误处理被拆得支离破碎,调用栈也追踪不了。更别提你还得手动处理"操作其实是同步完成的"这种边界情况(否则递归回调会把栈撑爆,也就是所谓的 stack dive)。
中间 .NET 还试过 EAP(基于事件的异步模式),但没真正解决问题。直到 Task 类型(.NET 4.0)和 async/await(C# 5)出现,异步编程才迎来转折点。
二、老 Async/Await:编译器帮你织了一台状态机
核心思路:CPS 变换 + 可恢复状态机
async/await 的本质,是 编译器对异步方法做一次 CPS(续体传递风格)变换,把它落地成一个可以反复进入、退出的状态机。
这个思路其实早在 C# 2.0 的迭代器(yield return)里就埋下了。编译器实现迭代器和 async/await 的逻辑大约有 95% 是共享的——都是把一个方法切成若干段,用一个 MoveNext 方法配合状态字段,让代码能从上次停下的地方继续跑。
举个例子,下面这段异步拷贝流的代码:
public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{var buffer = new byte[0x1000];int numRead;while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0){await destination.WriteAsync(buffer, 0, numRead);}
}
编译器会以每个 await 为切分点,生成大致如下的东西(已简化):
// 入口方法:初始化状态机并启动
[AsyncStateMachine(typeof(<CopyStreamToStreamAsync>d__0))]
public Task CopyStreamToStreamAsync(Stream source, Stream destination)
{<CopyStreamToStreamAsync>d__0 stateMachine = default;stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();stateMachine.source = source;stateMachine.destination = destination;stateMachine.<>1__state = -1; // -1 表示从头开始stateMachine.<>t__builder.Start(ref stateMachine);return stateMachine.<>t__builder.Task; // 把代表"最终完成"的 Task 还给调用方
}// 状态机本体(结构体)
private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine
{public int <>1__state; // 记录当前跑到第几段public AsyncTaskMethodBuilder <>t__builder;public Stream source;public Stream destination;private byte[] <buffer>5__2; // 局部变量被"提升"成字段private TaskAwaiter<int> <>u__2;// MoveNext 里是一个大跳转表,把原方法逻辑按 await 切成几段...
}
几个关键角色:
- 状态字段
<>1__state:记录方法当前执行到哪个await点,下次MoveNext靠它跳转回正确位置。 - 被提升的局部变量:像
buffer这种需要跨await存活的局部变量,会从栈上"提升"为状态机的字段。 - Builder(构建器):
AsyncTaskMethodBuilder负责创建并完成返回的Task,处理SetResult/SetException,以及在遇到未完成的 await 时挂接续体。 - ExecutionContext:负责让
AsyncLocal<T>这类"环境数据"能跨越异步点正确流动,同时防止像模拟身份(impersonation)这样的上下文泄漏给调用方。
值得一提的是:如果异步方法同步就跑完了,这个状态机结构体永远不会离开栈,也就没有堆分配。只有当方法真的需要等待一个尚未完成的任务、被迫挂起时,状态机才会被"装箱"到堆上,并真正分配一个 Task。
这套机制的硬伤在哪
老 Async 用了这么多年,确实好用,但它有个绕不开的边界问题:C# 编译器是以"方法"为编译单位的。
这带来一连串后果:
-
看不穿方法边界。编译器无法洞察被调用方法的内部实现,也不会去改 managed ABI(托管调用约定)擅自修改方法签名。于是每个
async方法都得有自己独立的状态机。 -
被迫保留所有"以防万一"的路径。因为缺乏跨边界的全局信息,调用点必须生成最通用的代码来兜底:
- 即便目标方法几乎不会抛异常,调用点也得保留异常捕获/恢复路径;
- 即便目标方法大概率不会挂起,调用点也得保留挂起/恢复分支;
- 即便没有同步上下文,也得生成备份/恢复同步上下文的代码。
-
多余的 Task 分配。哪怕异步链上每一步都被
await直接消费、根本不需要把结果包进Task,但为了保持 ABI,编译器还是得老老实实把每一步的结果都装进Task。
这些都让编译后的代码 难以被 JIT 优化,并产生大量本可避免的 Task 对象分配——在每分钟成千上万次调用的高并发服务里,这种分配会实实在在地变成 GC 压力。这也是为什么社区后来要发明 ValueTask<T>、要教大家用 ConfigureAwait(false)、要缓存常用 Task ——本质上都是在给这个分配问题打补丁。
三、Runtime Async:把异步从编译器搬进运行时
一个 async 实验的曲折
.NET 团队从 .NET 8 就开始想办法改善现状。最早尝试的是 Green Thread(绿色线程) 方案,思路和 Go 的 goroutine、Java 的 Virtual Thread 一样。结果不仅性能没提升,反而在跨 runtime 边界调用的场景下出现了无法接受的性能回退和调度问题,实验宣告失败。
于是从 .NET 9 开始,团队转向了另一条路:不另起炉灶,而是直接改进 async/await 本身。这就是 Runtime Async 的由来。
新机制:状态机消失了
Runtime Async 的核心变化是:编译器不再改写方法体生成状态机,而是在运行时层面引入一套全新的 async ABI,由 runtime 直接承载异步控制流。
方法通过在签名上标注一个特殊的 async 标记(注意不是我们平常写的那种 attribute,而是直接进入方法签名的标记)来表示自己遵循异步 ABI。
来看个对比。同样一段递归调用自己的异步方法:
async Task Test()
{await Test();
}
丢给老编译器,得到一个完整的状态机;丢给启用了 Runtime Async 的新编译器,得到的 IL 是这样的:
.method public hidebysiginstance class [System.Runtime]System.Threading.Tasks.Task Test() cil managed async
{ldarg.0call instance class [System.Runtime]System.Threading.Tasks.Task Program::Test()call void [System.Runtime]System.Runtime.CompilerServices.AsyncHelpers::Await(...)ret
}
状态机彻底没了,只剩下几个 runtime helper 调用,以及签名上那个醒目的 async 标记。
更微妙的是:这段 IL 本身也只是个"参考实现",并不是真正会被执行的代码。 你写的这个 C# 方法相当于一个"启动器"(trunk),真正在异步调用链里执行的代码并没有对应的 IL 表示形式,而是由 JIT/runtime 动态生成。返回类型上写的 Task 也只是个参考,runtime 运行时不一定真会为它产生代码。
续体链:付出的代价只是判断一次 null
新模型下,当一个异步方法等待另一个异步方法时:
- JIT 生成挂起逻辑,把当前状态捕获进一个 continuation(续体)对象;
- 需要"传递"挂起时,方法返回一个 非空的 continuation;
- 调用方收到非空 continuation 后,相应地挂起自己、创建自己的 continuation 并返回——如此形成一条沿调用层次串起来的 续体链;
- 恢复执行时,通过参数传入一个非空 continuation,根据里面记录的挂起点跳转到对应位置继续;若传入的是空 continuation,则表示从方法开头开始。
注意这里的精妙之处:整个机制额外付出的开销,仅仅是判断 continuation 对象是不是 null。 这点成本几乎可以忽略不计。
runtime 现在能做哪些以前做不到的优化
因为异步控制流不再受 managed ABI 的束缚,runtime 可以跨越方法边界做更激进的全局优化:
| 以前(编译器视角,必须兜底) | 现在(runtime 全局视角) |
|---|---|
| 不知道目标会不会抛异常 → 保留异常路径 | 确定不抛?异常处理路径直接删掉 |
| 不知道有没有同步上下文 → 生成备份/恢复代码 | 确定没用?备份/恢复逻辑删掉 |
| 不知道会不会挂起 → 保留挂起/恢复分支 | 确定不挂起?分支跳过 |
| 局部变量统统提升为字段 | 后续不再用?提前结束变量生命周期,释放内存 |
| 每一步结果都得包进 Task | 整条链不需要 Task?彻底消除 Task 抽象 |
在很多 await 链里,结果根本不需要显式用 Task 包装。Runtime Async 可以在整条链路上 消除 Task 抽象,JIT 直接传递结果本身——从而在热路径上实现 零分配或接近零分配。除此之外,runtime 还有能力 完全内联(inline)异步方法,进一步带来大量性能提升。
四、性能到底差多少?一个递归斐波那契的例子
用一个递归计算斐波那契数列的异步版本来测(特意不用 ValueTask,且递归层数很深):
static async Task<int> FibAsync(int n)
{if (n <= 1) return n;return await FibAsync(n - 1) + await FibAsync(n - 2);
}static int Fib(int n) // 同步版做对照
{if (n <= 1) return n;return Fib(n - 1) + Fib(n - 2);
}
测试结果(来自 hez2010 在 .NET 10 上的实测):
同步 Fib(40) = 102334155 in 250ms
老 Async FibAsync(40) = 102334155 in 1412ms
新 Async FibAsync(40) = 102334155 in 730ms ← 比老 Async 快了约 100%
而在那些因 bug 被临时关闭的优化全部打开后,作者自行编译源码测得:
新 Async FibAsync(40) = 102334155 in 255ms ← 几乎追平同步代码!
也就是说,在这个深度递归、还没用 ValueTask 的极端场景里,异步代码做到了和同步代码同样的性能,相比老 Async 提升接近 500%。
当然要泼盆冷水:真实世界的重 I/O 应用里,大量时间其实消耗在真正的 I/O 操作本身上,所以整体不会有这么夸张的提升。但对于想用 async/await 来做并行计算、或调用链特别深的场景,Runtime Async 的意义就非常大了。
五、那个总被提起的"染色问题"
每次聊 async/await,总有人来复读"函数染色"(异步类型沿调用链传播)的问题。说句公道话,这个"问题"其实是同一套代码要同时承载同步与异步两种语义的必然结果,而 async/await 的取舍其实相当聪明:
- 纯回调式异步:逻辑分散、可读性差、维护成本高,不符合直觉;
- 全面协程化(如 goroutine):在异步 runtime 内部表现很好,但跨越 runtime 边界与原生世界交互(FFI)时,在性能、调度、线程亲和性上都会"水土不服"——这对客户端、游戏等场景是硬伤;
async/await:让你"看起来像写同步",同时让异步走一套有别于同步的 ABI。它既保留了回调式的性能优势,又有完整的调度灵活性(超时、取消、WhenAll/WhenAny一应俱全),还能在需要时精细控制线程亲和性、为跨 FFI 调用保留清晰边界。
代价就是要把结果包装成 Task 等异步类型,也就是所谓"染色"。从抽象上看,这相当于用 Monad 的方式对异步建模——好处是允许同一异步结果被多方同时等待,且能在操作结束后随时访问结果。
正因为这种平衡得当,async/await 才被 C++、C#、F#、Rust、Kotlin、JavaScript、Python 等一众语言广泛采用。而 Runtime Async 并没有改变这个编程模型,它只是让同样的模型跑得更快。
六、新旧对比速查表
| 维度 | 老 Async/Await(C# 5 ~ .NET 10) | Runtime Async(.NET 11) |
|---|---|---|
| 实现层 | 编译器(Roslyn)做 CPS 变换 | 运行时(JIT/CoreCLR)直接承载 |
| 产物 | 每方法一个状态机 + MoveNext 跳转表 |
极简 IL + runtime helper 调用 |
| 挂起/恢复 | 状态字段 + 提升的局部变量 | runtime 生成的 continuation 链 |
| 跨方法优化 | 受 managed ABI 限制,无法做 | 可跨边界做全局优化、内联 |
| Task 分配 | 异步完成时几乎必有分配 | 整条链可消除 Task,零/近零分配 |
| 异常路径/上下文 | 必须保留兜底代码 | 确定不需要时可删除 |
| 调用栈/诊断 | 夹杂大量编译器生成的状态机基础设施 | 栈更干净、可读,断点能正确绑定 |
| 调试体验 | 单步会跳进编译器生成的代码 | 可跨 await 边界单步,不跳进基础设施 |
| 源码改动 | — | 零改动,重新编译即可 |
七、在 .NET 11 里怎么用
这部分是最关键的变化,也是它和 hez2010 那篇(基于 .NET 10 RC1 的实验版)最大的区别。
在 .NET 10 里,Runtime Async 还是实验性预览特性,需要手动开启一堆开关、设置环境变量 DOTNET_RuntimeAsync=1,而且标准库没有用它重新编译,还存在不少 bug,不适合生产。
到了 .NET 11,情况完全不同了(以下基于官方文档与 Preview 阶段信息,.NET 11 正式版预计 2026 年 11 月发布):
- 默认启用。针对
net11.0的项目,Runtime Async 不再需要<EnablePreviewFeatures>true</EnablePreviewFeatures>,CoreCLR 的运行时支持开箱即用,也不用再设环境变量。 - 标准库自身已用
runtime-async=on重新编译。运行时库里不再有编译器生成的状态机,完全依赖运行时提供的 async——这意味着只依赖库的整个应用都能迁移到新模型,也为这个特性提供了广泛的功能与性能验证。 - 支持 NativeAOT 和 ReadyToRun。这把 Runtime Async 从 JIT 编译场景扩展到了提前编译(AOT)场景——这正是 .NET 10 实验版当时还没来得及做的部分。
- 诊断与调试大幅改善。它惠及一切检查"实时执行栈"的工具:性能分析器、诊断日志、调试器的调用栈窗口都更干净。断点能正确绑定到 runtime-async 方法内部,单步调试可以跨越 await 边界而不跳进编译器生成的基础设施。
- 更省内存。运行时会更积极地复用 continuation 对象,并跳过保存那些没变化的局部变量,进一步降低异步密集代码的分配压力。
如果你想在自己的代码里抢先体验(在尚未默认开启的预览版上),项目文件大致这样配:
<PropertyGroup><TargetFramework>net11.0</TargetFramework><EnablePreviewFeatures>true</EnablePreviewFeatures><Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
顺带一提:未经重新编译的旧程序集不会自动升级到 Runtime Async。新机制对源码没有破坏性更改,但要享受它的好处,得用支持 Runtime Async 的新编译器把代码重新编译一遍。
八、总结
把这件事浓缩成几句话:
- 编程模型没变。你熟悉的
async/await写法、Task/ValueTask、WhenAll/取消/超时 等等,统统照旧。源码一行不用改。 - 底层实现搬家了。异步的实现从"编译器生成状态机"搬到了"运行时直接承载",由此摆脱了 managed ABI 的束缚。
- 性能因此解放。跨方法的全局优化、异步方法内联、消除多余 Task 分配……这些以前编译器做不到的事,runtime 现在都能做,让异步代码性能逼近甚至追平同步代码,同时显著降低 GC 压力。
- 诊断和调试还顺带变好了。调用栈更干净,断点和单步调试更靠谱。
- .NET 11 是分水岭。从实验性预览(.NET 10)正式转为默认启用、标准库全面采用、支持 AOT(.NET 11)。
老 Async/Await 是一项了不起的工程——它用编译器的智慧,把"回调地狱"变成了"看起来像同步"的优雅代码。而 Runtime Async 则是在保留这份优雅的同时,把它推向了更高的性能和工程效率。对于大规模异步 I/O、链式调用、微服务/云原生等场景,预计会带来更好的延迟与吞吐;而在高性能并行计算场景,async/await 也终于能有自己的一席之地。
同样的开发体验,更强的底层引擎——这大概就是 Runtime Async 想讲的故事。
参考资料
- hez2010,《Runtime Async - 步入高性能异步时代》,博客园
- Stephen Toub,《How Async/Await Really Works in C#》(中文翻译版载于 .NET 中文官方博客)
- Microsoft Learn,《What's new in .NET 11》/《What's new in .NET 11 runtime》
- Laurent Kempé、Syncfusion、NetMentor 等关于 .NET 11 Preview Runtime Async 的解读
注:.NET 11 目前仍处于预览阶段,正式版预计 2026 年 11 月发布,部分细节在正式发布前可能仍有调整。
