Skip to content
Published on

.NET 11 runtime-async — 把 async 状态机从编译器搬到运行时的中期报告

分享
Authors

引言 — async 原本是运行时压根不知道的功能

用 C# 写 async Task M() 时到底发生了什么,你大概曾经反编译看过一次。Roslyn 会把这个方法整个抹掉,生成一个实现 IAsyncStateMachine 的结构体和一个 MoveNext(),原方法的位置只留下一个用来启动那个状态机的桩(stub)。局部变量变成字段,每个 await 点都挂上一个 state 整数,异常则由 AsyncTaskMethodBuilder 接住。

关键在这里 — 运行时根本不知道这个方法是 async。 JIT 看到的只是一个普通的类和一个普通的 MoveNext 方法。async 纯粹是编译器一侧的源码重写(rewrite),对 CLR 而言这个概念压根不存在。自 C# 5 在 2012 年推出这套模型以来,14 年来一直如此。

这种设计是有实打实代价的。堆栈跟踪里直接暴露出状态机的基础设施,调试器不得不在编译器生成的代码里翻找,而 JIT 因为没有「这是一个 async 挂起点」的信息,也就没有空间去做相应优化。runtime-async 要推翻的正是这个前提。用规范文档自己的话说,用编译器重写来实现固然有效,但 直接在 .NET 运行时里实现,预计能带来改进,尤其是在性能方面(runtime-async 规范草案)。

本文梳理的是截至 2026 年 7 月 14 日发布的.NET 11 预览版 6为止的进展。.NET 11 是 STS 版本,支持期从 2026 年 11 月 10 日到 2028 年 11 月 9 日(发行说明 README),现在距离 GA 还有四个月。

runtime-async 实际改变了什么 — 一个标记

这套机制的起点简单到令人意外。ECMA-335 的修订草案把方法分成「sync」和「async」两类,并把 async 方法定义为带有 [MethodImpl(MethodImplOptions.Async)] 的方法定义。这个标记以值 0x2000 加入 MethodImplAttributes,在 IL 里用 async 关键字表示,ilasm/ildasm 都能识别。

于是 Roslyn 所做的事情就变了样。

// 开发者写的代码
async Task M()
{
    // ...
}
// 用 runtime-async 编译后的形态(概念性表示)
[MethodImpl(MethodImplOptions.Async)]
Task M()
{
    // 没有状态机类。方法体原样留在这里。
}

状态机类消失了,方法体原地保留。挂起点用对 System.Runtime.CompilerServices.AsyncHelpers 中辅助方法的调用来标记。

// 规范定义的挂起辅助方法(部分)
namespace System.Runtime.CompilerServices
{
    public static class AsyncHelpers
    {
        [MethodImpl(MethodImplOptions.Async)]
        public static void Await(Task task);
        [MethodImpl(MethodImplOptions.Async)]
        public static T Await<T>(Task<T> task);
        [MethodImpl(MethodImplOptions.Async)]
        public static void AwaitAwaiter<TAwaiter>(TAwaiter awaiter)
            where TAwaiter : INotifyCompletion;
        // 还有针对 ValueTask、ConfiguredTaskAwaitable 等的变体
    }
}

于是 await C.M() 会被降低成这样的 IL:

call     [System.Runtime]System.Threading.Tasks.Task C::M()
call     void [System.Runtime]System.Runtime.CompilerServices.AsyncHelpers::Await(class [System.Runtime]System.Threading.Tasks.Task)

规范明确写明它偏好这种「连续两次 call」的形态 — 先调用 async 方法、紧接着调用 Await 的这套 IL 序列能拿到最好的性能,因为 JIT 能识别出这个模式,直接把两者串联起来,而不需要一个中间的任务对象。

跨越挂起点的局部变量会被「提升(hoist)」以保存状态。这原本是状态机结构体字段干的活,现在由运行时管理的一个 续体(continuation) 对象接手。预览说明里反复出现的「续体复用」「避免保存未变化的局部变量」这类说法,说的都是这个对象。

Roslyn 一侧的设计文档钉死的一条原则值得记住 — 这项功能的目标是 在用户层面不可见,初始绑定几乎不受影响,甚至 编译器根本不知道引用的程序集里某个方法是否是用 runtime-async 编译的(Roslyn Runtime Async Design)。手动挂上 MethodImplOptions.Async 也是被禁止的 — 真去试的话,编译器会报错。

第一个看得见的成果 — 活的堆栈跟踪

在谈性能之前,这一点最先让人有感觉。预览版 2 的发行说明给出的例子是一段依次经过 OuterAsyncMiddleAsyncInnerAsync 三层、最后打印 new StackTrace() 的代码。

没有 runtime-async 时会得到 13 帧。

   at Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__InnerAsync|0_2()
   at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__MiddleAsync|0_1()
   at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__OuterAsync|0_0()
   at Program.<Main>$(String[] args) in Program.cs:line 3
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<Main>$(String[] args)
   at Program.<Main>(String[] args)

打开之后是 5 帧。

   at Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24
   at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14
   at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8
   at Program.<Main>$(String[] args) in Program.cs:line 3
   at Program.<Main>(String[] args)

(两段输出都取自预览版 2 运行时说明。方法名里的 $>g__ 是因为它们是本地函数,和是不是 async 无关。)

这里不能漏掉发行说明自己留下的一条线索。得到改善的是「活的」堆栈跟踪 — 也就是性能分析器、调试器,以及运行期间调用的 new StackTrace() 所看到的那种。而 catch (Exception ex) 里打印的异常堆栈跟踪,不论有没有 runtime-async 早就长得一样了,这要归功于编译器生成代码里既有的 ExceptionDispatchInfo 清理逻辑。

也就是说,「async 的堆栈跟踪终于变干净了」这句话只对了一半。你每天在日志里看到的那种异常跟踪本来就没问题,这次变好的是性能分析器和调试器的调用栈窗口。而把 runtime-async 帧纳入 Environment.StackTraceSystem.Diagnostics.StackFrame 这件事,目前还停留在API 提案阶段。诊断这一侧,在史诗级(epic)issue里也仍然是未完成项。

调试方面,预览版 2 让断点能够正确绑定到 runtime-async 方法内部,并且可以跨越 await 边界单步调试,而不会跳进编译器生成的基础设施里(dotnet/runtime #123644)。

预览版 1 到 6 — 实际发生了什么

把日期和提交对上号,这项功能现在走到哪一步会清楚得多。

预览版 1(2026-02-10)。 CoreCLR 一侧的运行时支持默认启用,意味着不再需要设置环境变量。NativeAOT 也能编译 runtime-async 代码了。不过说明写得很清楚 — 此时核心运行时库里没有一个是用 runtime-async 编译的。

预览版 2(2026-03-10)。 前面提到的堆栈跟踪与调试器改进。

预览版 3(2026-04-14)。 门槛降低了一档。API 上原本的 [RequiresPreviewFeatures] 限制被移除,net11.0 项目不再需要为了打开 runtime-async=on 而连带打开 EnablePreviewFeatures(dotnet/runtime #124488)。NativeAOT 和 ReadyToRun 支持也是在这个预览版加入的。

 <PropertyGroup>
   <Features>runtime-async=on</Features>
-  <EnablePreviewFeatures>true</EnablePreviewFeatures>
 </PropertyGroup>

预览版 4(2026-05-12)。 这一轮周期里最大的事件。整个运行时库开始以 runtime-async=on 构建。 用发行说明的说法,运行时库不再包含编译器生成的状态机,而是依赖运行时提供的 async 功能。除此之外,crossgen2 中阻止 runtime-async 方法内联的限制被移除,69 个 async 测试在 crossgen2 和 composite R2R 两侧都通过了(dotnet/runtime #125472)。

预览版 5(2026-06-09)。 挂起/恢复成本的优化。细节放在下一节。

预览版 6(2026-07-14)。 JIT 的变化是,对于同步返回任务的方法,不再靠 thunk 绕过去,而是 单独编译一个专门的 runtime-async 版本(dotnet/runtime #128384)。而且续体现在可以跳过 ExecutionContext 的捕获与恢复(dotnet/runtime #128323) — 以前不管三七二十一,每次恢复都要拍一次上下文快照;现在如果没有用到 AsyncLocal 的值、恢复反正什么都不会做,运行时会检测到这一点并整体跳过。

预览版 6 的第一处改动背后有一段故事,后文会再提到。

老老实实地读数字

预览版 5 的头条数字是这个 — 一个挂起频繁的基准测试从 Took 6357.1 ms 改善到 Took 457.1 ms(dotnet/runtime #127074)。接近 14 倍。这该怎么看待。

先看到底改了什么。OSR(On-Stack Replacement)是一种 JIT 功能,让长时间运行的方法能在执行途中从 tier-0 代码切换到优化后的代码。问题在于,当 async 在一个 OSR 方法内部恢复时,走的是通用的 OSR 转换路径(patchpoint 辅助方法),而 PR 描述里写道那个辅助方法并不便宜,开销大约是 10 到 20 倍。 这次改动让代码从 tier-0 直接跳到 OSR 代码,把辅助方法整个绕过去。

再看这个基准测试到底是什么。看 PR 附带的复现代码就明白了 — 造一个 IsCompleted 永远为 falseNullAwaiter,用一个 1000 万次的预热循环让该方法成为 OSR 候选,然后在里面执行 1000 万次 await na。外层循环则不断调用 na.Continue() 把它唤醒。

换句话说,这是一个 每一次 await 都 100% 挂起的合成微基准测试。真实代码里相当一部分 await 会遇到已经完成的任务,直接通过而不挂起(规范自己也写着「如果所有类 Task 对象都已完成,就不需要挂起」)。这个基准测试刻意把那条快速路径压到 0%,只测量挂起路径。所以这个数字不该被读成「你的应用会快 14 倍」,而应该读成「挂起路径上这一处特定瓶颈缩小了这么多」。这是厂商自测的数字,条件如上所述。

预览版 5 剩下的数字也是同一种性质。挂起代码体积的缩减,在挂起频繁的微基准测试上带来 约 8% 的改善,PR 样例里生成的代码从 766 字节降到 751 字节(#126041)。TLS 操作和写屏障的减少,让 PR 样例从 Took 350.3 ms 变成 Took 291.3 ms(#127336)。这些全都是 PR 作者用自己的样例测出来的数字。

那么「真实应用能快多少」这个问题的答案呢? Microsoft 目前还没给出。预览版 4 发行说明里的一句话最准确地概括了这个状况 — 他们 预计(expect) runtime-async 会带来吞吐量和库体积上的改善,变化幅度会与 async 的使用程度成比例,并且 欢迎反馈,无论结果是好是坏。 一项距离 GA 只有四个月的功能,官方文档明确写着「也请把不好的结果告诉我们」,这并不常见,也很诚实。不是没有头条数字,而是目前还没有能给出的数字 — 这样理解才准确。

就算你不开启,它也已经在你脚下运行

这可能是本文最重要的一节。

runtime-async 就你自己的代码而言 是一个需要显式开启的预览功能。要打开它,得在项目文件里加上这段:

<PropertyGroup>
  <Features>runtime-async=on</Features>
</PropertyGroup>

但从预览版 4 起,运行时库已经以这种方式构建并发布了。也就是说,在 .NET 11 上调用 Task.WhenAllStream.ReadAsync,或者任何 socket 相关代码的那一刻,即便你什么都没开启,BCL 内部的 async 也已经是用 runtime-async 在跑了。 从预览版 1 的「核心库里一个编译好的都没有」到预览版 4 的「全都编译好了」,这就是这次转变。

这不是抽象的说法,有具体证据。dotnet/runtime #126925记录的是这样一个 issue:在 System.Private.CoreLib 里无条件启用 runtime-async 之后,一个以 IIS in-process 方式托管的 ASP.NET Core 应用对所有请求都返回 HTTP 500。 应用正常启动,ANCM 日志也记录成功,但之后的每一个 HTTP 请求都以 500 失败。这个 issue 在 2026 年 4 月 14 日开启,截至 7 月 1 日的更新仍未关闭,是在一个把运行时改动带入 dotnet/aspnetcore 的代码流(codeflow)PR 中被发现的。

这一个 issue 说明的是,这项功能不是「只有想尝鲜的人才会碰的开关」,而是一次已经在平台底层承受着实际负载的结构性变更。所以在 .NET 11 预览版上碰到解释不了的 async 相关异常行为时,「自己代码里没开 runtime-async=on」这件事并不能当作不在场证明。

顺带一提,以前用的 DOTNET_RuntimeAsyncUNSUPPORTED_RuntimeAsync 这两个环境变量已经被移除。官方文档给出的说法是,要按项目关闭它,请使用 <UseRuntimeAsync>false</UseRuntimeAsync>(What's new in .NET 11 runtime,该页面更新到预览版 5 为止)。不过这个属性是在 dotnet/runtime 仓库自身的构建文件里确认到的,在普通用户项目里的实际表现,建议自己动手验证一下。

还做不到的事,以及反而变慢的事

这一节是本文存在的理由。看看那些还开着的 issue,这项功能现在的坐标就非常精确了。

池化构建器(pooling builder)出现了回归。 带有 [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] 的方法,相比 runtime-async 之前,因为分配问题 出现了回归(dotnet/runtime #129004,2026 年 6 月 4 日)。应对方式不是修复而是回避 — 暂时对带有 AsyncMethodBuilder 的方法关闭 runtime-async,这就是PR #128943。预览版 6 的发行说明把这个改动写得比较委婉,说是「已经池化的方法会选择退出 runtime-async 以避免重复工作」,但 issue 那边的措辞更直白。如果你是出于对分配敏感才用池化构建器,那么这些方法并不是这项功能的受益者,而是被绕开的对象。

大结构体可能会在堆上来回搬运。 dotnet/runtime #120855收集了 runtime-async 下性能不佳的一批模式,其中之一就是挂起/恢复频繁时,大结构体可能会被反复复制到堆上。这个 issue 于 2025 年 10 月开启,截至 2026 年 7 月 9 日仍在更新,尚未关闭。

同步返回任务的包装方法长期以来是一种反优化。 为了省下一个状态机,用同步返回 Task 的包装方法把 async 方法包一层,是个非常常见的写法。dotnet/runtime #115771写得很明确 — 在 runtime-async 下,这是一种 相当程度的反优化。 这个 issue 于 2025 年 5 月开启,随着前文预览版 6 部分提到的PR #128384在 2026 年 6 月 22 日合并而关闭。PR 描述的最后一行就是 Fix #115771。一个存活了一年多的反优化,在 GA 前四个月被修复了 — 这是好消息,但同时也说明了这项功能目前的成熟度。

ExecutionContext.SuppressFlow() 可能不被遵守。 dotnet/runtime #122052的标题就是这个意思。issue 正文指出,被抑制的执行上下文却仍然流动,是一种可观察的行为,而抑制它的理由之一可能正是 为了隐藏敏感值。 附带的日志显示,System.Net.SocketsExecutionContextFlowTest 在期望值为 0 的情况下实际得到了 42,测试失败。该 issue 于 2025 年 11 月开启,截至 2026 年 7 月 3 日仍在更新中。这一条和其他条目性质不同,它不是性能问题,而是 语义(semantics) 问题。

自定义 awaiter 在挂起时会被装箱。 dotnet/runtime #119842这个 issue 开着,讨论如何避免这种装箱。如果你用的是自己写的 awaiter,这是个该留意的点。

Multicore JIT 会排除 async 变体。 根据dotnet/runtime #115097,这并不是有什么根本性的原因,只是还没实现/测试(NYI)而已。

性能分析器的 ReJIT 还是个问号。 dotnet/runtime #128944本身就是一个悬而未决的问题 — 「对 runtime-async 方法体做性能分析器 ReJIT,在 .NET 11 里是不是原本就打算支持?」如果你的环境挂着 APM agent,这是个值得关注的项目。

async 迭代器还没到。 Roslyn 的设计文档里,返回 IAsyncEnumerable 的 async 迭代器仍然列在 TODO 里;在 Roslyn 的功能状态表中,「Runtime Async Streams」被标记为在另一个分支上进行中的工作。而「Runtime Async」本身在那张表里的状态是 已作为预览版合并进 main。

史诗级 issue(#109632)于 2024 年 11 月开启,截至 2026 年 7 月 13 日仍然处于打开状态,诊断(StackTrace 的格式化与输出)以及「async 方法的函数指针该怎么处理」之类的项目仍未解决。

规范钉死的限制 — 这些可能不会再改

如果说前面那些 issue 都是「暂时还没做到」的问题,那么规范草案里也明确写着 很可能是永久性的(likely to be permanent)限制。

  • 返回类型只有四种。 只有 System.Threading.Tasks.TaskValueTaskTask<T>ValueTask<T> 能作为 runtime-async 方法的返回类型。
  • byref 局部变量不能跨挂起点被提升。 规范说得更具体:在挂起点之后读取 byref 变量会得到 null,byref-like 结构体同样不会被提升,挂起之后会变成默认值。固定(pinning)的局部变量也是一样。
  • 挂起点不能出现在异常处理块内部。

看到最后一条,你大概会想「那 try 里的 await 怎么办」,答案在 Roslyn 的设计文档里。编译器生成的状态机和运行时生成的 async 共享了一部分构建块,这就是其中之一 — catch/finally 块内的 await 会被重写成:先把异常挂起(pend),在 catch/finally 区域 外部 执行 await,再视需要恢复异常。这种重写一直都是这么做的,如今原样保留。也就是说,即便源码里的 await 位于 EH 块内部,IL 层面的挂起点其实是落在外面的。在开发者眼里,什么都没变。

临时性的限制包括禁止 tail 前缀和禁止 localloc 指令,规范里写着这些以后可能会解除。

还有一条最不起眼但最重要的限制。用 Roslyn 设计文档自己的话说 — 返回其他类 Task 类型的方法不会被转换成 runtime-async 形态,而是继续使用 C# 生成的状态机。 如果你用的是带自定义构建器的类型,那部分代码会一直按老方式运行下去。两套模型在同一个进程里共存,这是设计如此,不是 bug。Mono 运行时同样不在支持范围内。

那么现在该做什么

有理由开启的情况

  • 如果你在运营一个 async 很重、挂起频繁的服务,并且打算等 .NET 11 GA 之后再开启,那 现在就在预览版上用自己的负载测一测是划算的。 Microsoft 明确要求负面结果的反馈,而距离 GA 还有四个月。现在发现的回归还有被修复的机会,GA 之后才发现的回归就得你自己扛了。
  • 如果你经常通过性能分析器或调试器查看 async 的调用栈,13 帧缩到 5 帧是一个能实实在在感受到的改善。

不该开启的情况

  • 生产环境。 这是预览功能,史诗级 issue 还开着,IIS in-process 500 这类 issue 还没解决,ExecutionContext.SuppressFlow 的语义也没敲定。
  • 如果你是为了压缩分配而 在用池化构建器。 回归已经确认,运行时自己的应对方式也是把这些方法整个排除在外。
  • 如果 自定义的类 Task 类型或自定义构建器 是你代码库的核心。反正它们不会被转换。
  • 如果你 面向 Mono。 它不在支持范围内。
  • 如果你的性能分析器或 APM agent 依赖 ReJIT。是否打算支持这件事本身还是个悬而未决的问题。

什么都不开启的人也该知道的事

  • 光是升级到 .NET 11,BCL 的 async 路径就会切换成 runtime-async。这不是可选项。如果在迁移测试中碰到解释不了的 async 行为差异,请把这个可能性列入怀疑对象。

结语

runtime-async 不是「让 async 变快的优化」,而是 改变 async 归谁所有的一次变革。 它把一个做了 14 年编译器源码重写的东西,搬到了运行时的一等概念里,收益的产生方式也因此不同 — 一旦状态机这层中间表示消失,JIT 才第一次知道「这里是个挂起点」,从那之后,OSR 恢复路径的优化、挂起点的 tail-merge 之类的操作才有可能实现。预览版 5 和 6 做的全都是这一类工作,是在状态机模型下压根无从下手的优化。

与此同时,截至 2026 年 7 月,诚实的现状是这样的。运行时库已经迁移过去了,堆栈跟踪确实变短了,一个存活了一年多的反优化上个月被修好了。而池化构建器还在回归中,IIS in-process 的 issue 还开着,SuppressFlow 的语义还没定,async 迭代器还没到。史诗级 issue 从 2024 年 11 月起就一直开着。

GA 是 2026 年 11 月 10 日。到那时这份清单能缩短到什么程度,才是「要不要开启这项功能」的真正答案。现在能做的最有生产力的事,就是在预览版上用自己的负载测一测,然后把结果 — 尤其是不好的结果 — 反馈回去。这也正是文档亲口拜托大家做的事。

参考资料