我们是否已经步入Runtime Async,迈向高性能异步时代?

摘要:同步代码和异步代码 一般而言,代码可分为同步与异步两类。两者同样需要等待操作完成:同步会阻塞当前线程,直至操作结束后再继续执行后续逻辑;异步则不阻塞当前线程,而是在发起操作时预先注册完成后的处理逻辑,待操作完成时由操作本身或外部机制触发该逻
同步代码和异步代码 一般而言,代码可分为同步与异步两类。两者同样需要等待操作完成:同步会阻塞当前线程,直至操作结束后再继续执行后续逻辑;异步则不阻塞当前线程,而是在发起操作时预先注册完成后的处理逻辑,待操作完成时由操作本身或外部机制触发该逻辑。 于是这就带来一个问题,那就是同步代码和异步代码的写法是完全不同的! 在 async/await 之前,异步编程通常将回调函数交给异步操作,以便在完成时触发预先编写的逻辑。其后果是:逻辑被拆散到各个回调中,或层层嵌套成“回调地狱”。此外,回调必须由调用方向被调用方传递,迫使调用方提前了解并携带完成后要唤醒的代码,这与自然的思维方式相悖——同一项操作的完成可能会被多个位置同时关心,而发起该操作的代码不应对等待其完成的代码产生任何形式的依赖。 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 时会被暂停,等待后续逻辑被重新调度继续执行。因此,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 代码,因此并不存在任何的源代码破坏性更改。
阅读全文