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 代碼,因此并不存在任何的源代碼破壞性更改。不過未經重新編譯的程序集不會自動升級到新的 Runtime Async 上去。
與依賴 C# 編譯器進行 CPS 變換的老 Async 實現相比,新的 Runtime Async 并不需要編譯器改寫方法體,而是在 runtime 層面引入全新的 async ABI,由運行時直接承載與處理異步控制流。
在 Runtime Async 中,一個方法通過標注 async 這一 attribute(注意不是我們平常使用的 attribute,而是一種直接進入方法簽名的特殊 attribute)來表示自己遵循異步方法的 ABI。
比如,假設我們有以下代碼:
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::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 特性支持:
<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 版本:
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 把同樣的開發體驗,推向更高的性能與工程效率。

浙公網安備 33010602011771號