同步代码和异步代码
一般而言,代码可分为同步与异步两类。两者同样需要等待操作完成:同步会阻塞当前线程,直至操作结束后再继续执行后续逻辑;异步则不阻塞当前线程,而是在发起操作时预先注册完成后的处理逻辑,待操作完成时由操作本身或外部机制触发该逻辑。
于是这就带来一个问题,那就是同步代码和异步代码的写法是完全不同的!
在 async/await 之前,异步编程通常将回调函数交给异步操作,以便在完成时触发预先编写的逻辑。其后果是:逻辑被拆散到各个回调中,或层层嵌套成"回调地狱"。此外,回调必须由调用方向被调用方传递,迫使调用方提前了解并携带完成后要唤醒的代码,这与自然的思维方式相悖------同一项操作的完成可能会被多个位置同时关心,而发起该操作的代码不应对等待其完成的代码产生任何形式的依赖。
async/await 的出现则从根本上改变了这一点。
async/await
现如今我们提到 async/await,尽管它仍归入 stackless coroutine 范畴,但已不同于早期那种在递归、错误处理与调用栈追踪上局限颇多的形态;这些局限在很大程度上已经被克服。
.NET 对 async/await 的支持,本质上是编译器对异步方法进行一种 CPS 风格的变换,并将其落地为可恢复的状态机。
举一个具体的例子,当遇到如下代码时:
csharp
async Task Foo()
{
A();
await B();
C();
await E();
F();
}
编译器会以 await 为切分点生成若干"续体"(continuation),并为每个续体捕获所需的局部变量与执行上下文,使其既可被独立调度执行,同时仍能访问 await 之前的状态。这样一来,只需在被等待的操作完成时将下一个续体交给调度器,就可以按自定义策略自由地推进后续代码的执行。异步方法在执行到每一处 await 时会被暂停,等待后续逻辑被重新调度继续执行。因此,await 实际上也标注了异步方法的潜在暂停点。
在 C# 的第一版 async/await 中,这一机制具体抽象为编译期生成的状态机(实现 IAsyncStateMachine),由调度器/同步上下文驱动 MoveNext 逐步推进,从而保证每个代码片段在前一个异步操作完成后被正确调度执行。
然而一直以来 C# 的 async/await 实现都存在一个边界上的问题:C# 编译器以方法为编译单位,既无法跨越方法边界全面洞察被调用方法的实现细节,也不会改变 managed ABI 去擅自修改当前方法的签名。因此,在形成异步调用链时,通常每个 async 方法都会拥有自己的状态机;而在缺乏跨边界全量信息的情况下,调用方会生成较为通用的路径来覆盖异常与暂停等情形。举例来说,即便目标方法在多数情况下并不会抛出异常,调用点仍会保留异常捕获与恢复路径;又或者目标方法很可能不会暂停,调用点也会保留相应的暂停/恢复分支以保证语义正确;又或者比如异步调用链中每一处异步调用都通过 await 对其结果直接进行等待,这种情况下实际上并不需要将异步操作的结果包装进 Task 之类的类型,然而由于需要保持 managed ABI,编译器仍然需要将每一步的结果包装进 Task 里面去;再比如对于实际上没有同步上下文的情况,编译器仍然需要产生备份/恢复同步上下文的代码。
上面的问题使得编译后的 C# 代码难以被 JIT 优化,同时还会产生多余的 Task 对象分配,从而导致 C# 中异步代码的性能一直无法与同步代码相匹敌,甚至出现 ValueTask 这种专门为了消除分配而诞生的类型。
.NET 团队自从 .NET 8 开始尝试对这一现状进行改进。先是对 Green Thread 方案(与 goroutine、Java 的 Virtual Thread 方案相同)进行实验,结果相比目前的 async/await 不仅性能没有提升,反而在跨 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,由运行时直接承载与处理异步控制流。
在 Runtime Async 中,一个方法通过标注 async
这一 attribute(注意不是我们平常使用的 attribute,而是一种直接进入方法签名的特殊 attribute)来表示自己遵循异步方法的 ABI。
比如,假设我们有以下代码:
csharp
async Task Test()
{
await Test();
}
扔给老的 C# 编译器编译则会得到一个状态机;而扔给新的启用了 Runtime Async 支持的 C# 编译器编译,则会得到如下 IL:
msil
.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::Test()
call void [System.Runtime]System.Runtime.CompilerServices.AsyncHelpers::Await(class [System.Runtime]System.Threading.Tasks.Task)
ret
}
状态机完全消失了,取而代之的只剩下一个参考实现里面调用了一些 runtime helper 函数,以及我们 IL 代码的方法签名上那一个显著的 async 标记。
以及,我们给方法返回值类型上写的 Task 类型只不过是一个参考,运行的时候 runtime 并不一定会实际为 Task 类型产生代码。并且我们的 C# 代码被编译到 IL 后,IL 代码也只不过是一个参考实现而已,并不是会被真正执行的代码。实际真正被执行的代码则并没有对应的 IL 表示形式,而我们写的这个 C# 函数只不过是要被执行的真实代码的 trunk,或者叫它"启动器",在异步调用链中实际上并不存在。
在新的异步模型中,当在一个异步方法里等待另一个异步方法时,JIT 会生成暂停逻辑并把当前状态捕获到一个 continuation 对象中;当需要"传递"暂停时,则返回一个非空的 continuation。调用方收到非空 continuation 后,会相应地暂停自身、创建自己的 continuation 并返回。由此形成一条按照调用层次串接起来的 continuation 链式结构。
恢复执行时,通过参数传入一个非空的 continuation,根据其中记录的暂停点(可理解为恢复点标识)跳转到相应位置继续执行;若传入的 continuation 为空,则表示从方法开头开始执行。
你会发现这一实现中,我们付出的额外开销仅仅只有判断 continuation 对象是否是 null 的成本,这简直可以忽略不计!
借助这一机制,runtime 可以在不受 managed ABI 限制的前提下跨越方法进行更积极的全局优化:
- 被调用的异步方法不会抛异常?异常处理路径删了!
- 没使用同步上下文?备份/恢复相关逻辑删了!
- 实际不发生暂停?暂停/恢复分支跳了!
- 未在后续使用的局部变量?提前结束变量生命周期释放内存!
- ...
同时,在许多异步等待链中,结果并不需要显式由 Task 进行包装,因此可以在整条链路上彻底消除 Task 抽象:JIT 生成代码时可以直接传递结果本身而非 Task,从而在热路径上实现零分配或接近零分配的效果。除此之外,这还使得 JIT 有能力完全 inline 掉异步方法,从而进一步带来大量的性能提升。
Runtime Async 在大量场景中显著提升了异步代码的性能,使其逼近甚至达到同步代码的性能,并有效降低了分配和内存占用,减少了 GC 压力;同时 Runtime Async 还不会对跨 runtime 边界的互操作与任务调度带来负面影响,可以说成功做到了既要还要。
染色问题?
当然,每当谈起 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 等语言广泛采用。
开启方法
从 .NET 10 RC1 开始,Runtime Async 已经作为实验性预览特性发布了出来,因此想要试用 Runtime Async 的开发者可以抢先体验。
不过需要提前说明的是,现阶段 Runtime Async 仍然处于实验性预览阶段,存在一些 bug,还不适合在实际的生产环境中使用。另外,标准库也还没有采用 Runtime Async 重新进行编译,因此 Runtime Async 只对你自己写的异步代码生效,而调用进标准库里的异步代码后仍然走的是老的 Async 实现。此外,不少优化也还没有实装,因此现阶段的性能表现虽然已经比老的 Async 好了一大截,但离正式版的 Runtime Async 还差了很远。另外虽然计划支持 NativeAOT 但是因为工期不够目前还没有实装。
那么说了这么多,到底如何在 .NET 10 中提前体验 Runtime Async 呢?
首先我们需要修改我们的 C# 项目文件,启用预览功能,并开启 C# 编译器的 Runtime Async 特性支持:
xml
<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 层面的支持。
这样我们就可以体验 Runtime Async 带来的提升了!
简单测试
这里我们编写一个递归计算斐波那契数列的方法,但是 async 版本:
csharp
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 被临时关闭了。我在这些优化被关闭之前的时候自己编译源码测试过一次 Runtime Async 性能,得到的测试结果如下:
FibAsync(40) = 102334155 in 255ms
是的你没有看错,在这个测试中异步代码成功做到了和同步代码同样的性能,甚至还是在有这么多层递归的情况之下,以及我们连 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 把同样的开发体验,推向更高的性能与工程效率。