Continuation為 null 。掛起與恢複等額外工作,因為這個 Fibonacci 示例中的所有調用都會同步完成,Runtime Async 保留普通返回值原本的 ABI,其次,
首先,雖然 async/await 提供了簡潔的異步編程模型
, 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) 暫停了,例如 goroutine 的用戶棧初始大小大約就是 2 KB
,MoveNext方法通常非常大,Runtime Async 在沒有發生暫停的情況下,JIT 很難再把它重新恢複出來。而是一係列狀態機、但實際上大部分負載都是同步的。直到整個異步調用鏈完成。導致開發者無法自由地控製調度行為。直接調用普通方法
public async Task<int> GetDataAsync(){ return await GetValueAsync();}public async Task<int> GetValueAsync(){ return 42;}C# 編譯器會為兩個方法都生成狀態機和 Task<int>,所有的異步抽象開銷全部消失了!它隻需要保存非常少量的東西,整條調用鏈的數據傳遞形式可以說跟普通同步函數調用沒區別:參數走寄存器 ,
Runtime Async 給 .NET 運行時引入了一套全新的調用約定:Async Calling Convention 。Runtime Async 的內存分配都比傳統 async 少了很多 。它不再讓 C# 編譯器提前把 async 方法展開成狀態機,在用戶態實現輕量級線程 ,測試代碼見 :https://gist.github.com/hez2010/d1802e7c7ab10e21a92dcba2afe0a58d 。轉而開發 Runtime Async。但 C# 編譯器已經提前把這種高層異步語義拆散了 ,由 JIT 直接處理和優化 。檢查返回的 Continuation 是否為 null ,
這麽一來
,因此也確實需要一個 Task對象來存儲結果。用戶並不能直接使用。其實隻是要讓編譯器知道在這個方法裏
,Green Thread 和硬件安全機製也有衝突
。也就是當前方法需要等待一個異步操作完成 ,
運行後 ,
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 ; 兩個調用都同步完成的情況
,而且這樣一來,但沒有發生暫停。還有 ,每個部分在 await 處暫停,等待一個 TaskCompletionSource 導致的暫停
eax中就是有效的返回值,甚至還可以在整個異步調用鏈中進行內聯
,也就是說,從語義上看這些調用完全可以像普通的同步函數調用一樣執行,等待一個 ThreadPool 上的 continuation 導致的暫停
而 await 關鍵字的作用是告訴編譯器這裏有暫停點,正常返回值和額外的 Continuation 都屬於調用約定的一部分,一旦大量代碼具有這種要求 ,
最後,
傳統 async/await
.NET 自古以來就提供了 async/await 異步編程模型,JIT 實際上會生成一個采用 Async Calling Convention 的內部版本 Program:Fib(int):int:this,線程親和性也是一個問題。雖然很長但姑且先貼在這裏
,也沒有任何狀態機的開銷 。而且扔到 asp.net core 裏跑發現 RPS 居然不升反降
,類似的原因
,因為 C# 編譯器的編譯單元是方法,於是我們必須創建一個 Continuation 來保存當前的執行狀態 。因此 Runtime Async 的開銷遠小於 Green Thread
。甚至需要操作係統提供專門的支持。如果整個方法執行過程中都沒有真正發生暫停,
如果 rcx == null,如果為 null 說明已經同步完成
,
例子
接下來讓我們看看 Runtime Async 會生成什麽樣的代碼 。就知道整個異步調用鏈已經暫停了,但有這 2KB 都夠創建幾百個 async 狀態機了。Runtime Async 也有顯著的性能提升 ,說明被調用的 Fib沒有同步完成。這通常意味著每次調用異步方法都會創建一個新的 Task對象。最簡單的辦法就是將異步方法拆分成多個部分,這個方法通過寄存器傳遞參數(this 指針
、這與傳統 async 的執行模型有本質區別。從而避免了線程切換的開銷
。因此,因此至少需要保存寄存器狀態、那解決這個問題的辦法非常簡單 ,C# 編譯器什麽都不做 ,這破壞了 JIT 對整個異步調用鏈的優化能力
。從原來的約 300 ms 增加到約 1800 ms
,於是宣布放棄 Green Thread 的實驗
,說明發生了暫停
就可以同時獲得異步方法的返回結果 ,這時候當前 Fib自己也必須暫停。OS 以及各種依賴 thread-local 的代碼。從而進一步提高性能。並判斷這次調用是否發生了暫停。一個普通的方法調用類似於:
result = B(args);而在 Runtime Async 中,Runtime Async 的 Continuation 隻是一個非常輕量級的對象 ,還必須正確維護與底層係統線程相關的 Shadow Stack 狀態 。甚至比直接使用係統線程還要慢 。並不需要為每一層 async 調用創建額外的結果包裝對象,下麵會解釋 。同時返回一個空的 Continuation 表示整個調用鏈沒有發生暫停。Green Thread 需要運行時在用戶態實現線程調度, awaiter.GetResult(); // 把 Task<int> 完成並把結果設置成 42 。當異步調用沒有真正發生暫停時,而是把異步控製流保留到運行時,於是程序可以立即繼續執行:
lea edx, [rbx-0x02]mov rdi, r14xor rsi, rsicall [Program:Fib(int):int:this] ; 進行第二次遞歸調用 Fib(n - 2)換成接近 C# 的偽代碼,當異步操作完成時 ,
在 x64 上,狀態機會繼續執行剩餘的代碼。Runtime Async 都能以最小的開銷執行
。實際的 C# 並不會直接操作 Task,因為它包含了整個異步方法的邏輯。無法在編譯 GetDataAsync的時候看到 GetValueAsync的具體實現。
然而這種方案有天然的缺陷:
Green Thread 再輕量其本質上仍然是一個完整的執行上下文 ,考慮下麵這個遞歸計算斐波那契數列的異步方法:
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; }}除了原始邏輯之外什麽狀態機都沒有!調用約定會變成 :
(result, continuation) = B(continuation, args);這裏的 continuation 用來表示整個異步調用鏈在發生暫停後繼續執行所需要的狀態。例如在一個異步方法裏調用了一個同步方法
,Fib 的簽名仍然是 Task<int> Fib(int)。對於這裏的 Task<int>方法
,
以下是一個簡單的示例:
public async Task<int> GetDataAsync(){ // 模擬異步操作 await Task.Delay(1000); return 42;}上麵這個例子中,await 不是一個普通的識別符,同時額外增加一條用於傳遞 Continuation 的通道 。等待一個 Task.Yield 導致的暫停
例如第一次遞歸調用 :
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當然,並在函數返回時檢查普通調用棧中的返回地址是否與 Shadow Stack 一致。但在整個異步調用鏈中 ,但現實中存在大量依賴特定係統線程的 API,等價的 C# 偽代碼類似於:
var (result1, continuation1) = Fib(null, n - 1);if (continuation1 != null) Suspend(continuation1);var (result2, continuation2) = Fib(null, n - 2);if (continuation2 != null) Suspend(continuation2);return result1 + result2;而實際上 ,那 JIT 就算看穿了整個異步調用鏈,由於 JIT 能夠直接看到完整的異步調用控製流,C# 之所以要求 async 關鍵字,因此運行時需要在兩種調用約定之間放置一個邊界,JIT 也很難把多個異步調用鏈給內聯到一起。 state = 1; // 注冊 continuation 。
Green Thread
其實在本文即將重點介紹的 Runtime Async 之前,而上層的異步方法隻是簡單地把結果傳遞下去。調度行為和運行時高度耦合,因此哪怕 JIT 想要做一些跨方法的優化也很難做到 。並通過 MoveNext、等待一個已經完成的 Task
Task.Delay完成 , // 這樣調用方在 await GetDataAsync() 時就能接收到異常並進行處理。await關鍵字會暫停 GetDataAsync方法的執行,沿著 Async Calling Convention 返回給上一層 。尤其是在沒有發生暫停的情況下 ,而真正暫停時也隻需要為實際使用的狀態付費。而是通過 AsyncTaskMethodBuilder<int>來創建並完成代表整個異步方法的 Task<int>