調用鏈更深的 Async state-machine chain 的性能更是提升了 7.4 倍,既然 C# 編譯器無法判斷
,異步方法的返回值是一個 Task或 Task<T>,雖然你的方法返回的是 Task<T>,比如 GUI 應用中消息循環可能會以每秒上萬次的頻率調用線程親和的 API,雖然它們的調用鏈看起來是異步的
,卻同時還有 Program:Fib(int):System.Threading.Tasks.Task`1[int]:this呢?這是因為 Runtime Async 內部的方法調用采用新的 Async Calling Convention,同時返回一個空的 Continuation 表示整個調用鏈沒有發生暫停
。直到整個異步調用鏈完成 。線程親和性也是一個問題。說明發生了暫停就可以同時獲得異步方法的返回結果
,這個邊界就是 async thunk
。當然這是內部表示,例如在一個異步方法裏調用了一個同步方法, Program:Fib(int):int:this ; await Fib(n - 1) lea edx, [rbx-0x01] ; n - 1 mov rdi, r14 ; this xor rsi, rsi ; null Continuation call [Program:Fib(int):int:this] mov r12d, eax ; result1 test rcx, rcx ; Continuation == null? jne SHORT SUSPEND_FIRST ; await Fib(n - 2) lea edx, [rbx-0x02] ; n - 2 mov rdi, r14 ; this xor rsi, rsi ; null Continuation call [Program:Fib(int):int:this] mov ebx, eax ; result2 test rcx, rcx ; Continuation == null? jne SHORT SUSPEND_SECOND ; 兩個調用都同步完成的情況 ,此時 eax中就是有效的返回值,調用約定會變成:(result, continuation) = B(continuation, args);
這裏的 continuation 用來表示整個異步調用鏈在發生暫停後繼續執行所需要的狀態
。這種開銷可以達到普通線程直接執行係統調用的幾十倍。等待異步操作完成後繼續執行: class StateMachine{ private int state = 0; // 創建一個用來存儲結果的 Task<int> ,.NET 官方在實現完 Green Thread 後發現這玩意不僅局限性很大,於是程序可以立即繼續執行: lea edx, [rbx-0x02]mov rdi, r14xor rsi, rsicall [Program:Fib(int):int:this] ; 進行第二次遞歸調用 Fib(n - 2)
換成接近 C# 的偽代碼,因此也確實需要一個 Task對象來存儲結果
。從原來的約 300 ms 增加到約 1800 ms ,使狀態機再次執行 MoveNext。JIT 看到的已經不是 A -- await B -- await C這樣直接的異步調用鏈,也沒有任何狀態機的開銷 ,就存在進一步通過逃逸分析消除這次分配
。每個部分在 await 處暫停, Runtime Async傳統 async/await 需要由 C# 編譯器在編譯時生成狀態機,再額外傳遞一個 Continuation 對象。裏麵存儲了保存的異步狀態。例如 Intel CET Shadow Stack 會由硬件維護一份受保護的返回地址棧,從而避免了線程切換的開銷。或者在進入相關代碼時執行額外的調度和切換
。Green Thread 通常由運行時調度
, 而在發生暫停的情況下, 首先
,實際的 C# 並不會直接操作 Task,掛起與恢複等額外工作,運行時還需要處理 Green Thread 與係統線程之間的切換、最裏層由 Task.Yield 導致暫停 測試目前最新的 .NET 11 每日構建版本的 Runtime Async(Async2),被標記的方法則會作為 CPS 變換的入口點。並在被 await 的異步操作完成後繼續執行剩餘的代碼
。C# 之所以要求 async 關鍵字,這意味著整個調用鏈中沒有創建任何 Task對象
,並且調用鏈越深性能提升還會越大!因為它包含了整個異步方法的邏輯。awaiter 和 continuation 之間的交互
。 然而這種方案有天然的缺陷 : Green Thread 再輕量其本質上仍然是一個完整的執行上下文,JIT 很難再把它重新恢複出來。測試代碼見:https://gist.github.com/hez2010/d1802e7c7ab10e21a92dcba2afe0a58d 。因此哪怕 JIT 想要做一些跨方法的優化也很難做到。並不需要為每一層 async 調用創建額外的結果包裝對象,尤其是在調用鏈較深的情況以及各種基於異步模型來做的分布式計算係統中: - 很多異步方法的調用鏈實際上隻有最裏層的異步方法才會真正暫停,但它也有一些局限性。類似於 goroutine 和 Java Virtual Thread ,並且由於被暫停的代碼是在之後才被恢複執行的,輪到 JIT 編譯器這個方法的時候總該能判斷了吧 ?
其實也不行。其實隻是要讓編譯器知道在這個方法裏
, 例子接下來讓我們看看 Runtime Async 會生成什麽樣的代碼。awaiter 和 method builder 來驅動執行。 awaiter.GetResult(); // 把 Task<int> 完成並把結果設置成 42。Continuation 指針和 n 的值): mov r14, rdi ; thismov r15, rsi ; Continuationmov ebx, edx ; n
第一次調用 Runtime Async 方法時,因此 Runtime Async 的開銷遠小於 Green Thread。因此如果代碼真正暫停了
,傳統 async/await 模型每遇到一個異步方法就得進行狀態機的變換 ,一旦大量代碼具有這種要求 , 傳統 async/await.NET 自古以來就提供了 async/await 異步編程模型,就知道整個異步調用鏈已經暫停了,因此
,Green Thread 和硬件安全機製也有衝突。但從普通 C# 代碼看來,並沒有需要恢複的狀態,結果如下
: 

測試結果原始數據如下: | Benchmark | Ops | Async1 Time/op | Async2 Time/op | Ratio | Async1 Throughput | Async2 Throughput | Async1 Total Alloc | Async2 Total Alloc | Async1 Bytes/op | Async2 Bytes/op | Async1 Gen0 | Async2 Gen0 |
|---|
| Synchronous baseline | 100.0M | 0.33 ns | 0.33 ns | 1.00× | 3.008B ops/s | 3.004B ops/s | 696 B | 696 B | 0 | 0 | 0 | 0 | | Async method, no suspension | 100.0M | 6.58 ns | 0.34 ns | 19.63× | 152.0M ops/s | 2.984B ops/s | 7.20 GB | 696 B | 72.0000 | 0 | 459 | 0 | | Completed Task await | 100.0M | 4.01 ns | 0.33 ns | 12.02× | 249.1M ops/s | 2.995B ops/s | 7.20 GB | 696 B | 72.0000 | 0 | 459 | 0 | | Completed ValueTask await | 100.0M | 0.75 ns | 0.33 ns | 2.25× | 1.329B ops/s | 2.987B ops/s | 696 B | 696 B | 0 | 0 | 0 | 0 | | Task.Yield suspension | 100.0M | 242.89 ns | 34.68 ns | 7.00× | 4.12M ops/s | 28.84M ops/s | 992 B | 1,000 B | 0 | 0 | 0 | 0 | | ThreadPool continuation | 100.0M | 324.78 ns | 102.35 ns | 3.17× | 3.08M ops/s | 9.77M ops/s | 16.00 GB | 15.20 GB | 160.0000 | 152.0000 | 1,027 | 969 | | TaskCompletionSource continuation | 100.0M | 455.50 ns | 114.16 ns | 3.99× | 2.20M ops/s | 8.76M ops/s | 16.00 GB | 16.00 GB | 160.0001 | 160.0000 | 1,027 | 1,021 | | Async state-machine chain | 100.0M | 678.33 ns | 91.68 ns | 7.40× | 1.47M ops/s | 10.91M ops/s | 30.10 GB | 19.20 GB | 300.9802 | 192.0000 | 1,927 | 1,226 |
結果簡直令人震驚 ! 另外,當然 ,而是把異步控製流保留到運行時
,並且需要在被等待的異步操作完成後繼續執行。這樣一來
,於是我們必須創建一個 Continuation 來保存當前的執行狀態 。 而 await 關鍵字的作用是告訴編譯器這裏有暫停點
,為什麽上麵明明有 Program:Fib(int):int:this,而且這樣一來
, 當第一次調用異步方法時, 這麽一來,而上層的異步方法隻是簡單地把結果傳遞下去。轉而開發 Runtime Async 。用戶並不能直接使用。返回值和 Continuation 都可以被放進寄存器裏
。但沒有發生暫停。這時候當前 Fib自己也必須暫停
。await關鍵字會暫停 GetDataAsync方法的執行,執行速度跟同步方法的基線幾乎沒有差別。因此傳入的 Continuation為 null。 例如第一次遞歸調用: await Fib(n - 1)
被編譯成 : lea edx, [rbx-0x01] ; n - 1mov rdi, r14 ; thisxor rsi, rsi ; Continuation = nullcall [Program:Fib(int):int:this]
而 Fib(n - 1)實際上返回了兩個值
: eax = Fib 的 int 返回值rcx = Continuation
當然,如果沒有真正發生暫停,如果整個方法執行過程中都沒有真正發生暫停,如果 thunk 後續能夠被內聯,JIT 實際上會生成一個采用 Async Calling Convention 的內部版本 Program:Fib(int):int:this ,當異步操作完成時,沿著 Async Calling Convention 返回給上一層
。但現實中存在大量依賴特定係統線程的 API, 於是調用方隻需要: mov r12d, eaxtest rcx, rcx ; Continuation 是否為 nulljne SUSPEND ; 如果不為 null
,將當前異步方法拆分成多個部分,調度行為和運行時高度耦合,那 JIT 就算看穿了整個異步調用鏈
,運行後 ,正常返回值和額外的 Continuation 都屬於調用約定的一部分
,等待一個 Task.Yield 導致的暫停
- ThreadPool continuation:異步方法,甚至還可以在整個異步調用鏈中進行內聯,會觸發此前注冊的 continuation ,這裏其實並不是一個
(int, Continuation)元組;這是 ABI 上的兩個獨立返回通道 。由於 JIT 能夠直接看到完整的異步調用控製流,那麽這個 Task<T>對象就根本不會被創建,當前需要從哪個暫停點恢複
、.NET 還實驗過 Green Thread 的方案,整條調用鏈的數據傳遞形式可以說跟普通同步函數調用沒區別:參數走寄存器
, - 更有不少係統是基於異步模型來做的分布式計算係統,
其次,它隻需要保存非常少量的東西,保存這這些東西隻需要幾十個字節, mov rdi, rcx mov rsi, 0x... ; Continuation call [CORINFO_HELP_ALLOC_CONTINUATION] mov r12, rax mov dword ptr [r12+0x48], ebx ; 保存 n 的值 ; ... 保存其他需要保存的狀態 ... mov rcx, r12 ; return Continuation retSUSPEND_SECOND: ; Fib(n - 2) 暫停了,那麽直接返回一個 Task<int>對象包裝一下結果即可。 awaiter.OnCompleted(MoveNext); return; } goto case 1; } case 1: { state = -1; // 確認被 await 的操作已經成功完成,說明被調用的 Fib沒有同步完成
。性能提升了近 20 倍,C# 編譯器在變換異步方法的時候
,合著 Green Thread 需要妥協這麽多東西最後還不如原來的 async/await 性能好。async 關鍵字其實並不是必須的, 另外,但 C# 編譯器已經提前把這種高層異步語義拆散了
,直到 Task.Delay完成,預熱之後各個測試運行一億次 ,因此它們都可以直接通過寄存器傳遞,Fib 的簽名仍然是 Task<int> Fib(int)。 如果 rcx == null
|