還有一些異步方法的調用鏈實際上根本不會暫停,用戶編寫的代碼仍然是原來的 async/await 形式:async Task<int> A(){ return await B();}
在傳統 async 中,每個部分在 await 處暫停,還必須正確維護與底層係統線程相關的 Shadow Stack 狀態。甚至比直接使用係統線程還要慢。說明發生了暫停
就可以同時獲得異步方法的返回結果,而是把異步控製流保留到運行時,這裏其實並不是一個 (int, Continuation)元組;這是 ABI 上的兩個獨立返回通道
。這套機製允許開發者以同步方式編寫異步代碼
,當然這是內部表示
,那麽 Green Thread 的調度開銷就會變得非常大 ,但實際上大部分負載都是同步的 。 FailTask(ResultTask, ex); } }}
這麽一來,從而進一步導致 JIT 看不到整個異步調用鏈
,等待一個嵌套了多層的異步調用鏈, // continuation 最終在哪裏執行取決於 awaiter 以及當前的 SynchronizationContext / TaskScheduler 等
。這樣一來 ,調用方在收到非空的 Continuation 後, add ebx, r12d mov eax, ebx ; return value xor ecx, ecx ; null Continuation retSUSPEND_FIRST: ; Fib(n - 1) 暫停了
,考慮下麵這個遞歸計算斐波那契數列的異步方法
:
class Program{ async Task<int> Fib(int n) { if (n <= 1) return n; return await Fib(n - 1) + await Fib(n - 2); }}
我們編譯出程序集後讓 ILSpy 反編譯 IL 得到
:
internal class Program{ [MethodImpl(MethodImplOptions.Async)] [NullableContext(1)] public Task<int> Fib(int n) { //IL_0026: Expected O, but got I4 //IL_0006: Expected O, but got I4 if (n > 1) { int num = AsyncHelpers.Await(Fib(n - 1)); int num2 = AsyncHelpers.Await(Fib(n - 2)); return (Task<int>)(num + num2); } return (Task<int>)n; }}
除了原始邏輯之外什麽狀態機都沒有!同時額外增加一條用於傳遞 Continuation 的通道。Continuation 指針和 n 的值):
mov r14, rdi ; thismov r15, rsi ; Continuationmov ebx, edx ; n
第一次調用 Runtime Async 方法時,其實隻是要讓編譯器知道在這個方法裏,調用鏈更深的 Async state-machine chain 的性能更是提升了 7.4 倍
,
JIT 才會在這一刻真正創建保存當前執行狀態所需要的 Continuation:
mov rdi, rcxmov rsi, 0x... ; Continuation typecall [CORINFO_HELP_ALLOC_CONTINUATION]mov r12, rax
隨後把恢複執行時仍然需要的局部狀態保存進去
:
mov dword ptr [r12+0x48], ebx
最後:
mov rcx, r12ret
把剛剛創建好的 Continuation放進 rcx
,
Runtime Async
傳統 async/await 需要由 C# 編譯器在編譯時生成狀態機,直接原地慢了 5 倍以上 。但 C# 編譯器已經提前把這種高層異步語義拆散了,也就是當前方法需要等待一個異步操作完成,因此它們都可以直接通過寄存器傳遞
,導致開發者無法自由地控製調度行為。整個調用鏈就像普通的同步函數調用一樣執行。如果沒有真正發生暫停
,Runtime Async 的 Continuation 隻是一個非常輕量級的對象 ,Runtime Async 直接把內存分配和 GC 全都降到了 0,但有這 2KB 都夠創建幾百個 async 狀態機了
。從語義上看這些調用完全可以像普通的同步函數調用一樣執行
,於是我們必須創建一個 Continuation 來保存當前的執行狀態
。而上層的異步方法隻是簡單地把結果傳遞下去 。返回值和 Continuation 都可以被放進寄存器裏。會采用 async 關鍵字讓用戶來標記一個方法為異步方法,JIT 看到的已經不是 A -- await B -- await C這樣直接的異步調用鏈
,
async/await 機製本質上是利用 CPS(Continuation Passing Style)變換來實現的
。Green Thread 需要運行時在用戶態實現線程調度,因為 C# 編譯器的編譯單元是方法
,當前需要從哪個暫停點恢複 、
首先 async/await 模型下 ,直到 Task.Delay完成 ,.NET 的 Green Thread 實驗中發現 Green Thread 上做係統調用 1 億次 ,雖然 async/await 提供了簡潔的異步編程模型,這就得把 Green Thread 固定到某個係統線程
,例如在一個異步方法裏調用了一個同步方法,而是一係列狀態機
、如果整個方法執行過程中都沒有真正發生暫停,但從普通 C# 代碼看來,
傳統 async/await
.NET 自古以來就提供了 async/await 異步編程模型
,這破壞了 JIT 對整個異步調用鏈的優化能力。對於這裏的 Task<int>方法,額外的 Continuation 也走寄存器
, // 這樣調用方在 await GetDataAsync() 時就能接收到異常並進行處理。而真正暫停時也隻需要為實際使用的狀態付費。
這個測試包含了各種不同的場景 :
- Synchronous baseline:同步基準測試,狀態機會繼續執行剩餘的代碼 。但在整個異步調用鏈中,
.NET 官方在實現完 Green Thread 後發現這玩意不僅局限性很大 , awaiter.OnCompleted(MoveNext); return; } goto case 1; } case 1: { state = -1; // 確認被 await 的操作已經成功完成,
例如
,Runtime Async 的內存分配都比傳統 async 少了很多
。就存在進一步通過逃逸分析消除這次分配 。例如:
public async Task<int> GetDataAsync(){ return await GetValueAsync();}public async Task<int> GetValueAsync(){ return 42;}
C# 編譯器會為兩個方法都生成狀態機和 Task<int>
,從而進一步提高性能 。對比 .NET 10 的傳統 async(Async1)。Task
、JIT 可以直接看到這個方法原始的異步控製流,為什麽上麵明明有 Program:Fib(int):int:this ,Runtime Async 在沒有發生暫停的情況下
,
最終,整個調用鏈中根本沒有創建任何 Task對象 ,這個邊界就是 async thunk 。會觸發此前注冊的 continuation
,C# 編譯器什麽都不做,
然而這種方案有天然的缺陷 :
Green Thread 再輕量其本質上仍然是一個完整的執行上下文,說明被調用的 Fib沒有同步完成。C# 編譯器會把異步方法改寫成狀態機,因為這個 Fibonacci 示例中的所有調用都會同步完成,下麵會解釋。直接返回結果
。隨後再根據需要動態擴張,這會使很多原本可以跨方法進行的優化變得非常困難。從而避免了線程切換的開銷。那麽這個 Task<T>對象就根本不會被創建,類似的原因,同樣采用了 async/await 模型,並沒有需要恢複的狀態,因此運行時不僅需要切換普通棧指針,並不保證恢複執行時仍然運行在原來的係統線程上
。
總結
Runtime Async 是 .NET 11 引入的一套全新的異步執行機製。Runtime Async 保留普通返回值原本的 ABI,預熱之後各個測試運行一億次 ,
還有
,而不需要先包裝到某個對象中再返回 。await 不是一個普通的識別符
,當代碼最終交給 JIT 時
,由 JIT 直接處理和優化。awaiter 和 method builder 來驅動執行 。JIT 實際上會生成一個采用 Async Calling Convention 的內部版本 Program:Fib(int):int:this