談到 .NET 的非同步處理,長期以來都是以 async/await 為核心。它的外觀接近同步程式碼,能夠很自然地撰寫原本容易變得複雜的非同步流程。
這個 async/await,底層是透過 CPS(Continuation-Passing Style,延續傳遞風格)轉換實作的。
首先,透過 async 關鍵字讓編譯器知道這是一個非同步方法。這裡是 CPS 轉換的起點。不過,async/await 的機制本身並不是非得要 async 不可。在 C# 裡之所以需要 async,是為了讓編譯器知道,方法內的 await 不是單純的識別字,而是表示處理中斷位置的關鍵字。採用相同 async/await 模型的 C++,就不需要 async 關鍵字。
await 是告訴編譯器「這裡可能會暫停處理」的標記,編譯器會以 await 為分界,將方法切成幾段程式碼,並在等待的非同步處理完成後執行後續部分。
先看一個簡單範例。
public async Task<int> GetDataAsync()
{
// 預期是非同步處理,因此等待 1 秒
await Task.Delay(1000);
return 42;
}
在這個範例中,如果 Task.Delay(1000) 還沒完成,await 就會先暫停 GetDataAsync 的執行。1 秒後再繼續處理,然後回傳 42。簡單來說,就是把非同步方法按 await 拆開,等候中的處理完成後再呼叫後續部分的機制。
class StateMachine
{
private int state = 0;
// 建立一個用來保存結果的 Task<int>。
// 目前的非同步方法完成時,這個 Task<int> 也會進入完成狀態。
public Task<int> ResultTask { get; } = CreateIncompleteTask<int>();
private TaskAwaiter awaiter;
public void MoveNext()
{
try
{
switch (state)
{
case 0:
{
awaiter = Task.Delay(1000).GetAwaiter();
if (!awaiter.IsCompleted)
{
// 記錄重新開始的位置。
state = 1;
// 註冊 continuation。
// 當 Task.Delay 完成時,已註冊的 continuation 會被呼叫,
// 狀態機的 MoveNext 會再次執行。
// continuation 最終會在哪裡執行,除了 awaiter 之外,
// 也取決於目前的 SynchronizationContext、TaskScheduler 等因素。
awaiter.OnCompleted(MoveNext);
return;
}
goto case 1;
}
case 1:
{
state = -1;
// 確認 await 的操作已正常完成。
// 如果失敗,會在這裡拋出例外。
awaiter.GetResult();
// 讓 Task<int> 完成,並將結果設定為 42。
CompleteTask(ResultTask, 42);
return;
}
}
}
catch (Exception ex)
{
// 如果 MoveNext 發生例外,就讓 Task<int> 進入失敗狀態。
// 如此一來,呼叫端在 await GetDataAsync() 時就能接收到例外並處理。
FailTask(ResultTask, ex);
}
}
}
如上所示,非同步方法會被轉換成狀態機,並在每個 await 位置分成不同狀態。等待中的處理完成後,狀態機會執行後續程式碼。GetDataAsync 方法在概念上會被編譯成如下形式。
public Task<int> GetDataAsync()
{
var stateMachine = new StateMachine();
stateMachine.MoveNext();
return stateMachine.ResultTask;
}
這裡使用的 CreateIncompleteTask 與 CompleteTask,只是為了說明機制的偽程式碼。實際上 C# 編譯器產生的程式碼不會直接操作 Task,而是使用 AsyncTaskMethodBuilder<int> 來建立並完成代表整個非同步方法的 Task<int>。
async/await 非常方便,但這份便利不是沒有代價的。
首先,C# 編譯器在把非同步方法轉換時,無法知道這次呼叫到底會不會真的發生中斷。實際上,很多非同步方法根本不會中斷,而是直接一路完成。
public async Task<int> GetDataAsync()
{
return await GetValueAsync();
}
public async Task<int> GetValueAsync()
{
return 42;
}
C# 編譯器會替這兩個方法都生成狀態機與 Task<int>。由於轉換是以方法為單位進行,所以在編譯 GetDataAsync 時,無法事先看透 GetValueAsync 的內容。
這時你可能會想:「就算 C# 編譯器不行,執行時的 JIT 應該可以想辦法吧?」
很可惜,事情沒那麼簡單。C# 編譯器會把非同步方法改寫成狀態機,並透過 MoveNext、awaiter、method builder 來推進執行。等到程式碼送到 JIT 手上時,原本的呼叫關係 A -> await B -> await C 已經被拆散了。JIT 看到的是狀態機、Task、awaiter、continuation 之間複雜協作的程式碼。這樣一來,原本能跨方法進行的最佳化也變得很困難。
如果整條呼叫鏈一次都沒有中斷,理論上應該可以像一般同步方法那樣執行。然而 C# 編譯器先把原本的非同步流程拆開了,JIT 事後要再把它還原成原始樣貌就很難。
而且,MoveNext 會包進整個非同步方法的邏輯,因此很容易變成大方法。JIT 常常會因為程式碼大小而避免將 MoveNext 展開,導致無法看穿整條呼叫鏈。同樣的理由,也讓多個非同步呼叫一起展開變得困難。
另一個問題是記憶體配置。async/await 中,非同步方法會回傳 Task 或 Task<T>。通常每次呼叫都會建立新的 Task 物件,因此在追求效能的場景中,這個成本不容忽視。於是就有了 ValueTask。它透過值型別回傳與 IValueTaskSource 重用機制,減少多餘配置。
當然,如果真的會中斷,那前面說的問題就沒那麼嚴重。就算 JIT 能看穿整條呼叫鏈,中斷本身也不可能消失。既然之後還要重新繼續執行,就仍然需要保留結果與完成狀態的 Task 物件。
問題在於,一個殘酷的事實是:不會中斷卻會完成的非同步方法其實很多。對於呼叫鏈很深的程式碼,以及採用非同步模型的分散式運算系統,這種傾向尤其明顯。
即便如此,傳統 async/await 還是會在每次看到非同步方法時建立狀態機,所以連不會中斷的方法也得老老實實付出這筆額外成本。
在進入 Runtime Async 之前,先稍微岔開一下。.NET 團隊以前也試過 Green Thread。這是一種像 goroutine 或 Java Virtual Thread 那樣,在使用者模式實作的輕量執行緒,目的是降低執行緒切換成本。
這個構想很吸引人,但真正做起來之後,出現了不少很難迴避的問題。
Green Thread 雖然輕量,但本質上仍是一個完整的執行上下文。至少必須保存暫存器狀態、呼叫堆疊,以及執行階段排程所需的中繼資料。例如 goroutine 的使用者堆疊,初始大小約 2 KB,並會依需要動態擴充。2 KB 已經足夠建立數百個 async 狀態機,所以只用「輕量」來形容,似乎還是有點重。
在 Green Thread 架構下,執行階段會在使用者模式下連執行緒排程一起接手。換句話說,排程的主控權掌握在 runtime 手上,開發者很難做細部控制。
到了系統呼叫,情況就更不妙了。Green Thread 本身不是 OS 執行緒,因此真正的系統呼叫是由支撐它的 OS 執行緒來執行。每次都會增加 Green Thread 與 OS 執行緒之間的切換、中斷、恢復等處理,成本有時甚至會變成一般情況的數十倍。.NET 的實驗中,執行 1 億次系統呼叫的時間從約 300 ms 增加到約 1,800 ms,速度慢了 5 倍以上。
它和硬體安全機制的相容性也不佳。以 Intel CET 的 Shadow Stack 為例,硬體會管理受保護的返回位址堆疊,並在函式返回時檢查它是否與一般呼叫堆疊上的返回位址一致。Green Thread 會在使用者模式切換呼叫堆疊,因此 runtime 不只要管理一般堆疊指標,還必須正確管理實際運作中的 OS 執行緒的 Shadow Stack。要和硬體的控制流保護良好整合並不容易,有時甚至需要作業系統額外支援。
執行緒親和性也很麻煩。因為 Green Thread 是由 runtime 排程,所以恢復後不保證仍在原本的 OS 執行緒上執行。然而 GUI、部分 OS API、以及依賴 thread-local 狀態的程式碼等,有很多處理都只能在特定 OS 執行緒上執行。這時要嘛把 Green Thread 綁定到特定 OS 執行緒,要嘛每次執行這類程式碼時都得額外做排程與切換。GUI 應用程式的訊息迴圈中,每秒呼叫數萬次具執行緒親和性的 API 也不稀奇。頻率高到這種程度時,Green Thread 的排程成本會膨脹,甚至可能比直接使用 OS 執行緒還慢。
最後一擊則是 ASP.NET Core 的結果。RPS(每秒請求數)不但沒有提升,反而下降了。既然得吞下這麼多限制,結果還比傳統 async/await 慢,那就不划算了。.NET 團隊於是停止了 Green Thread 的實驗,轉而開發 Runtime Async。
那麼,該怎麼做才好?答案其實出乎意料地簡單:不要讓 C# 編譯器先建立狀態機,而是把原始的非同步控制流程直接交給 JIT。Runtime Async 就是從這個想法誕生的。
Runtime Async 在 .NET runtime 中導入了一種新的 Async Calling Convention(非同步呼叫慣例)。內部會以 MethodImplOptions.Async 標記,但這不是使用者會直接指定的東西。我們撰寫的程式碼,仍然維持原本的 async/await 寫法。
async Task<int> A()
{
return await B();
}
在傳統 async 中,JIT 看到的是 C# 編譯器產生的 MoveNext 狀態機。到了 Runtime Async,JIT 可以直接看到方法原本的非同步控制流程。接著,它會生成符合特殊 Async Calling Convention 的程式碼。這個呼叫慣例除了通常的引數之外,還會額外傳遞 Continuation 物件。
假設一般的方法呼叫是這種形式。
result = B(args);
在 Runtime Async 中,就會多一個 continuation。
(result, continuation) = B(continuation, args);
這個 continuation 裡包含的是:呼叫鏈中斷之後,恢復執行所需的狀態。
第一次呼叫非同步方法時,會把 Continuation 傳成 null。因為此時還沒有要復原的狀態,所以方法會像一般同步方法一樣,從開頭開始執行。若一路執行到結束都沒有中斷,則會回傳一般結果與 null 的 Continuation。呼叫端只要看到 Continuation 是 null,就能判定這次是同步完成。
相反地,如果執行到 await 時等待的處理還沒完成,就會中斷整條呼叫鏈。runtime 會把恢復所需的狀態儲存到 Continuation 物件中,並回傳給呼叫端。呼叫端只要看到 Continuation 不是 null,就知道這次發生了中斷。等到等待中的處理完成後,再利用這個 Continuation 繼續執行。
當等待的處理完成後,runtime 會再次呼叫 Runtime Async 方法,並將剛才保存的 Continuation 作為額外引數傳入。方法會從前一次停下的位置接續執行,直到整條呼叫鏈結束。
這就是 Runtime Async 的核心。一般回傳值與額外的 Continuation 都包含在呼叫慣例之中。不需要特地包成物件,可以直接透過暫存器傳遞。一般回傳值的 ABI 不變,只是把 Continuation 當成另一個回傳值。如果目標架構的呼叫慣例允許,兩者都可以放在暫存器裡。
在完全沒有中斷的呼叫鏈中,資料傳遞方式幾乎和一般同步方法沒有差別。引數、回傳值與 Continuation 都經由暫存器傳遞。每次 async 呼叫都不必建立包裝結果的物件,因此那部分額外成本就消失了。
也就是說,雖然宣告寫的是 Task<T>,但只要沒有中斷,實際上根本不會出現 Task<T> 物件。在呼叫鏈內部,T 的值會直接被回傳。
JIT 能直接看到整體非同步控制流程,這也很重要。它甚至能做跨方法最佳化,以及將非同步呼叫展開內聯。
我們來看看實際產生的程式碼。假設有一個遞迴計算費波那契數的非同步方法。
class Program
{
async Task<int> Fib(int n)
{
if (n <= 1)
return n;
return await Fib(n - 1) + await Fib(n - 2);
}
}
先用 ILSpy 看看編譯後的 IL 轉成 C# 後會長什麼樣子。
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;
}
}
除了原本的 C# 程式碼之外,完全看不到狀態機或 Task 操作!
執行時 JIT 生成的程式碼,大致上會像下面這樣。雖然很長,但重點之後會逐步提取,所以現在不用緊張。
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
; 兩次呼叫都同步完成,因此直接回傳結果。
add ebx, r12d
mov eax, ebx ; 回傳值
xor ecx, ecx ; null Continuation
ret
SUSPEND_FIRST:
; 因為 Fib(n - 1) 中斷了,所以建立一個用來保存目前執行狀態的 Continuation。
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 ; 回傳 Continuation
ret
SUSPEND_SECOND:
; 因為 Fib(n - 2) 中斷了,所以建立一個用來保存目前執行狀態的 Continuation。
mov rdi, rcx
mov rsi, 0x... ; Continuation 的型別
call [CORINFO_HELP_ALLOC_CONTINUATION]
mov r15, rax
mov dword ptr [r15+0x4C], r12d ; 儲存 Fib(n - 1) 的結果
; ... 以及其他必要狀態 ...
mov rcx, r15 ; 回傳 Continuation
ret
; --------------------------------------------
Program:Fib(int):Task<int>:this
mov rdi, rbx ; this
mov edx, r15d ; n
xor rsi, rsi ; null Continuation
call [Program:Fib(int):int:this] ; 實際呼叫 Runtime Async 方法
mov ebx, eax ; 結果
test rcx, rcx ; Continuation == null?
jne THUNK_SUSPENDED
; 回傳 Task.FromResult(ebx)
mov rax, <Task<int>>
ret
THUNK_SUSPENDED:
; var task = new RuntimeAsyncTask<int>();
; 將 continuation 綁定到 task。
; 回傳 task。
首先最醒目的是,內部使用 Async Calling Convention 的 Program:Fib(int):int:this。回傳型別已經不是原本的 Task<int>,而是 int。
在 x64 上,this 指標、Continuation 指標與 n 的值,分別透過暫存器傳遞。
mov r14, rdi ; this
mov r15, rsi ; Continuation
mov ebx, edx ; n
第一次呼叫 Runtime Async 方法時,因為還沒有要復原的狀態,所以 Continuation 會傳入 null。
例如,第一次遞迴呼叫是這一段。
await Fib(n - 1)
JIT 會將其編譯成如下。
lea edx, [rbx-0x01] ; n - 1
mov rdi, r14 ; this
xor rsi, rsi ; Continuation = null
call [Program:Fib(int):int:this]
這個 Fib(n - 1) 實際上回傳了兩個值。
eax = Fib 的 int 回傳值
rcx = Continuation
這裡回傳的不是 (int, Continuation) 這種 tuple,而是依照 x64 ABI,把 int 和 Continuation 分別放在不同暫存器中回傳。
呼叫端只需用幾條指令,就能接住回傳值,並判斷是否發生中斷。
mov r12d, eax
test rcx, rcx ; Continuation 是否為 null
jne SUSPEND ; 若不是 null,表示發生中斷
如果 rcx == null,表示呼叫是同步完成的。eax 裡已經有有效回傳值,因此直接繼續下一步即可。
lea edx, [rbx-0x02]
mov rdi, r14
xor rsi, rsi
call [Program:Fib(int):int:this] ; 第二次遞迴呼叫 Fib(n - 2)
把它寫成 C# 風格的偽程式碼,大致上會是這樣。
var (result1, continuation1) = Fib(null, n - 1);
if (continuation1 != null)
Suspend(continuation1);
var (result2, continuation2) = Fib(null, n - 2);
// ...
那麼,如果 Continuation 不是 null 呢?這也直接反映在產生的程式碼中。
在第一次遞迴呼叫之後,會有以下判斷。
call [Program:Fib(int):int:this]
mov r12d, eax
test rcx, rcx
jne SHORT SUSPEND
如果 rcx != null,表示剛才呼叫的 Fib 並沒有同步完成。這時目前執行中的 Fib 也要一起中斷。
只有到了這一步,才會建立用來保存目前執行狀態的 Continuation。不會因為「也許會中斷」就先預先建立。
mov rdi, rcx
mov rsi, 0x... ; Continuation 的型別
call [CORINFO_HELP_ALLOC_CONTINUATION]
mov r12, rax
接著,把恢復後仍需要的區域變數等資訊儲存到 Continuation 中。
mov dword ptr [r12+0x48], ebx
最後,把建立好的 Continuation 放進 rcx,依照 Async Calling Convention 回傳給上一層呼叫端。
mov rcx, r12
ret
和 Green Thread 不同,Runtime Async 的 Continuation 很小。它只保存 await 前後必須維持的區域變數、恢復位置、等待中處理的回傳值或例外等少量資訊。所需大小大約只有數十位元組。和初始堆疊就約 2 KB 的 Green Thread 相比,輕巧許多。
把前面的流程整理成 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;
不過,在這個費波那契範例中,所有呼叫其實都同步完成。最後只是反覆做遞迴呼叫,因此實際行為就和以下同步程式碼一樣。
var result1 = Fib(n - 1);
var result2 = Fib(n - 2);
return result1 + result2;
整條呼叫鏈上完全不會建立 Task 物件,也沒有狀態機的成本。async/await 的額外負擔乾乾淨淨地消失了,行為就像一般同步方法一樣。這和傳統 async 是根本不同的。
這時你可能會疑惑:「既然有 Program:Fib(int):int:this,為什麼還會保留 Program:Fib(int):System.Threading.Tasks.Task1[int]:this` 呢?」
Runtime Async 內部雖然使用新的 Async Calling Convention,但從一般 C# 程式看來,Fib 的簽章仍然是 Task<int> Fib(int)。為了連接這兩者,中間會插入一層稱為 async thunk 的薄包裝器。在這個例子中,它會把 Runtime Async 內部回傳的一般回傳值與 Continuation,轉換成外層呼叫端所期待的 Task<int>。
thunk 內部也有和剛才很像的程式碼。
xor rsi, rsi
call [Program:Fib(int):int:this]
mov ebx, eax
test rcx, rcx ; Continuation 是否為 null
它會先呼叫真正的 Runtime Async 方法,並確認回傳的 Continuation 是否為 null。如果是 null,表示已同步完成,那就把結果包成 Task<int> 回傳即可。將來如果這個 thunk 被內聯,而 JIT 也能判定 Task 不會逃逸,理論上還可以透過逃逸分析把這次配置也消掉。
接著實際做了基準測試。測試程式碼公開在 GitHub Gist。
測試項目如下。
Synchronous baseline:直接呼叫一般方法的同步處理基準值。Async method, no suspension:不發生中斷就完成的非同步處理。Completed Task await:await 已完成的 Task 的非同步處理。Completed ValueTask await:await 已完成的 ValueTask 的非同步處理。Task.Yield suspension:透過 Task.Yield 發生中斷的非同步處理。ThreadPool continuation:回到執行緒池的非同步處理。TaskCompletionSource continuation:由 TaskCompletionSource 造成中斷的非同步處理。Async state-machine chain:多層非同步呼叫巢狀串接,並在最內層的 Task.Yield 發生中斷的處理。以撰寫當下最新的 .NET 11 daily build 中的 Runtime Async(Async2)與 .NET 10 的傳統 async(Async1)分別在暖機後,各測試執行 1 億次進行比較。
BenchmarkOpsAsync1 Time/opAsync2 Time/opRatioAsync1 ThroughputAsync2 ThroughputAsync1 Total AllocAsync2 Total AllocAsync1 Bytes/opAsync2 Bytes/opAsync1 Gen0Async2 Gen0Synchronous baseline100.0M0.33 ns0.33 ns1.00×3.008B ops/s3.004B ops/s696 B696 B0000Async method, no suspension100.0M6.58 ns0.34 ns19.63×152.0M ops/s2.984B ops/s7.20 GB696 B72.000004590Completed Task await100.0M4.01 ns0.33 ns12.02×249.1M ops/s2.995B ops/s7.20 GB696 B72.000004590Completed ValueTask await100.0M0.75 ns0.33 ns2.25×1.329B ops/s2.987B ops/s696 B696 B0000Task.Yield suspension100.0M242.89 ns34.68 ns7.00×4.12M ops/s28.84M ops/s992 B1,000 B0000ThreadPool continuation100.0M324.78 ns102.35 ns3.17×3.08M ops/s9.77M ops/s16.00 GB15.20 GB160.0000152.00001,027969TaskCompletionSource continuation100.0M455.50 ns114.16 ns3.99×2.20M ops/s8.76M ops/s16.00 GB16.00 GB160.0001160.00001,0271,021Async state-machine chain100.0M678.33 ns91.68 ns7.40×1.47M ops/s10.91M ops/s30.10 GB19.20 GB300.9802192.00001,9271,226結果相當驚人。對於不會中斷的情況,Runtime Async 完全消除了傳統 async 的額外成本。執行速度約快 20 倍,幾乎和同步方法的基準值沒有差別。
而且,變快的不只是未中斷的情況。ThreadPool continuation 與 TaskCompletionSource continuation 快了 3 到 4 倍,呼叫鏈較深的 Async state-machine chain 甚至快了 7.4 倍。呼叫鏈越深,Runtime Async 的優勢就越明顯。
記憶體配置也在所有測試中都比傳統 async 少。尤其是在不會中斷的測試裡,每次配置變成 0 位元組,也沒有發生 Gen0 GC。這顯示整條呼叫鏈中完全沒有建立 Task 物件,也沒有支付狀態機成本。
Runtime Async 是將於 .NET 11 導入的新非同步執行機制。C# 編譯器不再搶先把 async 方法轉成狀態機,而是把原始的非同步控制流程保留到執行時,讓 JIT 直接處理並進行最佳化。
這樣一來,所謂 pay for play(只為實際使用的功能付出成本)的設計,才算真正實現。若沒有中斷,非同步處理的額外成本幾乎為零;即使中斷,也只需為實際保存的狀態付出代價。
不會停下來的 async 像同步程式碼一樣奔跑,真的需要停下來時才付出必要成本。Runtime Async 的目標,說到底就是這樣而已。