一波未平 發表於 2025-9-18 00:42:00

Runtime Async - 步入高性能异步时代

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