前言
软件开发领域,异步编程一直是提升程序性能和响应能力的关键技术。随着.NET 平台的不断发展,异步编程模型也在持续演进。从早期的回调地狱到async/await的出现,再到如今Runtime Async的诞生,每一次变革都旨在让异步编程更加高效、易用。本文将深入探讨Runtime Async这一.NET全新的异步方案,剖析其原理、优势以及在实际应用中的表现。
正文
同步代码和异步代码的差异
代码主要分为同步和异步两类。同步代码会阻塞当前线程,直到操作完成才继续执行后续逻辑;而异步代码不阻塞当前线程,发起操作时预先注册完成后的处理逻辑,操作完成后由自身或外部机制触发该逻辑。这就导致同步代码和异步代码写法完全不同。
在async/await出现之前,异步编程通常依赖回调函数。逻辑被拆散到各个回调中,易形成"回调地狱"。而且回调必须由调用方传递给被调用方,迫使调用方提前了解并携带完成后要唤醒的代码,这与自然思维方式相悖。同一项操作的完成可能被多个位置关心,而发起操作的代码不应对等待其完成的代码产生依赖。
async/await的变革
async/await的出现从根本上改变了异步编程的困境。如今,虽然它仍属于stackless coroutine范畴,但早期在递归、错误处理与调用栈追踪上的局限已得到很大程度的克服。
.NET对async/await的支持,本质上是编译器对异步方法进行CPS风格的变换,并将其落地为可恢复的状态机。
以如下代码为例:
async Task Foo()
{
A();
await B();
C();
await E();
F();
}
编译器会以await
为切分点生成若干"续体"(continuation),为每个续体捕获所需的局部变量与执行上下文。被等待的操作完成时,将下一个续体交给调度器,按自定义策略推进后续代码执行。异步方法执行到每一处await
时会被暂停,等待后续逻辑重新调度继续执行,await
也标注了异步方法的潜在暂停点。
在C#的第一版async/await中,这一机制抽象为编译期生成的状态机(实现IAsyncStateMachine),由调度器/同步上下文驱动MoveNext逐步推进,保证每个代码片段在前一个异步操作完成后被正确调度执行。
然而,C#的async/await实现存在边界问题。C#编译器以方法为编译单位,无法跨越方法边界全面洞察被调用方法的实现细节,也不会改变managed ABI去修改当前方法的签名。在形成异步调用链时,每个async方法通常都有自己的状态机。调用方会生成较为通用的路径来覆盖异常与暂停等情形,即便目标方法在多数情况下不会抛出异常,调用点仍会保留异常捕获与恢复路径;目标方法很可能不会暂停,调用点也会保留相应的暂停/恢复分支以保证语义正确;异步调用链中每一处异步调用都通过await对其结果直接进行等待,但由于需要保持managed ABI,编译器仍需将每一步的结果包装进Task里面;对于实际上没有同步上下文的情况,编译器仍需产生备份/恢复同步上下文的代码。
这些问题导致编译后的C#代码难以被JIT优化,同时产生多余的Task对象分配,使得C#中异步代码的性能一直无法与同步代码相匹敌,甚至出现了ValueTask这种专门为了消除分配而诞生的类型。
Runtime Async的诞生
.NET团队自.NET 8开始尝试改进这一现状。先是对Green Thread方案(与goroutine、Java的Virtual Thread方案相同)进行实验,结果不仅性能没有提升,反而在跨runtime边界调用场景存在不可接受的性能回退和调度问题。
结束这一失败的实验后,从.NET 9开始全力向着改进async/await本身的方向探索,于是全新的Runtime Async到来了。
Runtime Async最早的名字叫做Async 2。
Runtime Async的特点
-
源代码兼容性:Runtime Async下,编写的C#代码几乎没有变化,只需用支持Runtime Async的新C#编译器重新编译代码,老Async代码就会被自动升级为新的Async代码,不存在源代码破坏性更改。但未经重新编译的程序集不会自动升级到新的Runtime Async上。
-
实现机制:与依赖C#编译器进行CPS变换的老Async实现相比,新的Runtime Async不需要编译器改写方法体,而是在runtime层面引入全新的async ABI,由运行时直接承载与处理异步控制流。一个方法通过标注特殊的
async
attribute(不是平常使用的attribute,而是直接进入方法签名的特殊attribute)来表示自己遵循异步方法的ABI。
IL代码变化:例如,对于以下代码:
async Task Test()
{
await Test();
}
扔给老的C#编译器编译会得到一个状态机;而扔给新的启用了Runtime Async支持的C#编译器编译,会得到如下IL:
.method public hidebysig
instance class [System.Runtime]System.Threading.Tasks.Task Test() cil managed async
{
ldarg.0
call instance class [System.Runtime]System.Threading.Tasks.Task Program call void [System.Runtime]System.Runtime.CompilerServices.AsyncHelpers::Await(class [System.Runtime]System.Threading.Tasks.Task)
ret
}
状态机完全消失,取而代之的是参考实现里面调用了一些runtime helper函数,以及IL代码方法签名上显著的async
标记。而且方法返回值类型上写的Task类型只是一个参考,运行时并不一定会实际为Task类型产生代码。
C#代码编译到IL后,IL代码也只是参考实现,实际真正被执行的代码没有对应的IL表示形式,C#函数只是要被执行的真实代码的"启动器",在异步调用链中实际上并不存在。
-
执行机制:在新的异步模型中,当在一个异步方法里等待另一个异步方法时,JIT会生成暂停逻辑并把当前状态捕获到一个continuation对象中;当需要"传递"暂停时,则返回一个非空的continuation。调用方收到非空continuation后,会相应地暂停自身、创建自己的continuation并返回,形成一条按照调用层次串接起来的continuation链式结构。恢复执行时,通过参数传入一个非空的continuation,根据其中记录的暂停点(可理解为恢复点标识)跳转到相应位置继续执行;若传入的continuation为空,则表示从方法开头开始执行。这一实现中,额外开销仅仅只有判断continuation对象是否是null的成本,可忽略不计。
-
优化优势:借助这一机制,runtime可以在不受managed ABI限制的前提下跨越方法进行更积极的全局优化。例如,被调用的异步方法不会抛异常时,删除异常处理路径;没使用同步上下文时,删除备份/恢复相关逻辑;实际不发生暂停时,跳过暂停/恢复分支;未在后续使用的局部变量,提前结束变量生命周期释放内存等。同时,在许多异步等待链中,结果不需要显式由Task进行包装,可以在整条链路上彻底消除Task抽象,JIT生成代码时直接传递结果本身而非Task,在热路径上实现零分配或接近零分配的效果。此外,JIT还有能力完全inline掉异步方法,进一步带来大量的性能提升。
染色问题探讨
每当谈起async/await,就会有人提及"染色问题"。
这种问题存在的原因是同一套代码需要同时承载同步与异步两种语义。
若完全采用回调式异步,容易导致逻辑分散、可读性下降、维护成本上升,也不太符合直觉;而全面协程化(如goroutine),在异步runtime内部通常表现良好,但在跨越runtime边界与原生世界交互(如FFI)时,会面临性能与调度上的挑战。
原生库通常默认以系统线程为边界模型,跨边界调用发生阻塞时,runtime往往需要避免在同一线程上继续安排其他任务,导致额外开销;同时,由于调度行为与runtime紧密耦合,开发者较难精确控制代码运行所在的具体系统线程,遇到来自外部的反向回调时也不易回到原先的线程,在客户端和游戏等对线程亲和性敏感的场景中水土不服。
async/await的思路是以"看起来像同步"的方式编写异步,同时让异步走有别于同步的ABI。它既能保留回调式的性能优势,又具备完整的调度灵活性,还有助于降低维护成本。主要代价在于需要将结果包装为Task等异步类型,即异步类型沿调用链传播。从抽象上看,可以视作以Monad的方式对异步进行建模,从而允许同一异步结果被多方同时等待的同时,还能支持在异步操作结束之后随时访问异步操作的结果。
因此,async/await通常能在性能、可维护性与互操作性之间取得较为理想的平衡:书写与调试体验接近同步代码,组合能力(如超时、取消、WhenAll/WhenAny)完善;同时借助Task与同步上下文/调度器,在需要时可以对线程亲和性进行更精细的控制,并为跨FFI的调用保留清晰的边界。也正因此它在工程实践中被C++、C#、F#、Rust、Kotlin、JavaScript、Python等语言广泛采用。
Runtime Async的开启方法
从.NET 10 RC1开始,Runtime Async已经作为实验性预览特性发布。
想要试用Runtime Async的开发者可以抢先体验,但需要注意以下几点:
-
现阶段Runtime Async仍处于实验性预览阶段,存在一些bug,不适合在实际的生产环境中使用。
-
标准库还没有采用Runtime Async重新进行编译,因此Runtime Async只对开发者自己写的异步代码生效,调用进标准库里的异步代码后仍然走的是老的Async实现。
-
不少优化也还没有实装,现阶段的性能表现虽然已经比老的Async好了一大截,但离正式版的Runtime Async还差得很远。
-
计划支持NativeAOT,但因为工期不够目前还没有实装。
开启Runtime Async的方法如下:
首先修改C#项目文件,启用预览功能,并开启C#编译器的Runtime Async特性支持:
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<Features>$(Features);runtime-async=on</Features>
<NoWarn>SYSLIB5007</NoWarn>
<LangVersion>preview</LangVersion>
</PropertyGroup>
然后设置环境变量DOTNET_RuntimeAsync=1
开启runtime层面的支持。
简单测试
编写一个递归计算斐波那契数列的方法(async版本):
class Program
{
static async Task Main()
{
// 把 Fib 和 FibAsync 预热到 tier 1
for (var i = 0; i < 100; i++)
{
Fib(30);
await FibAsync(30);
await Task.Delay(1);
}
// 进行测试
var sw = Stopwatch.StartNew();
var result = Fib(40);
sw.Stop();
Console.WriteLine($"Fib(40) = {result} in {sw.ElapsedMilliseconds}ms");
sw.Restart();
result = await FibAsync(40);
sw.Stop();
Console.WriteLine($"FibAsync(40) = {result} in {sw.ElapsedMilliseconds}ms");
}
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);
}
}
使用dotnet run -c Release
运行后得到结果:
Fib(40) = 102334155 in 250ms
FibAsync(40) = 102334155 in 730ms
而老的Async结果如下:
FibAsync(40) = 102334155 in 1412ms
可以看到新的Runtime Async相比老的Async在这一测试上性能提升了100%。实际上,在.NET 10中一部分针对Runtime Async的优化因为存在bug被临时关闭了。
在优化开启时测试,异步代码成功做到了和同步代码同样的性能,甚至在多层递归且未使用ValueTask的情况下,相比老的Async提升了接近500%。当然,在真实世界的重I/O应用场景里,大量时间消耗在真实的I/O操作本身上,总体上不会有这么夸张的提升。但对于想要使用async/await来做并行计算的同学来说,Runtime Async铺平了道路。
总结
Runtime Async作为.NET全新的异步方案,在保留源代码兼容性的同时,通过将async的实现从编译器搬到runtime,展示出了可观的性能改善。
对于大规模异步I/O、链式调用、微服务/云原生等场景,预计将带来更好的延迟与吞吐表现,并减少内存分配与GC压力。在高性能并行计算场景,async/await也能拥有一席之地。总体而言,开发者熟悉的async/await使用方式基本不变,在此基础上,Runtime Async把同样的开发体验推向了更高的性能与工程效率。
关键词
Runtime Async、异步编程、.NET、async/await、性能优化、源代码兼容性\Runtime Async、.NET 10、async/await、性能优化、JIT、continuation、状态机、IL、Task、零分配
最后
如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。
也可以加入微信公众号 [DotNet技术匠] 社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!
作者:hez2010
出处:cnblogs.com/hez2010/p/19097937/runtime-async
声明:网络内容,仅供学习,尊重版权,侵权速删,歉意致谢!