← 文章列表

C# async/await 正確性指南:避免 async void、假等待與取消誤判

C# async/await 正確性指南:避免 async void、假等待與取消誤判

C# async/await 正確性不是替方法加上 async 就完成,而是要讓工作生命週期、例外、完成訊號與取消意圖沿著呼叫鏈保持可觀測。本文把常見陷阱整理成可審查的設計規則,並以固定版本的 .NET 8 測試專案重現三個核心行為。

async/await 正確性的核心判準

判斷非同步程式是否正確,先檢查呼叫端能否等待完成、接收例外、辨識取消,並確認背景工作不會脫離原本責任邊界;這比只看方法是否標記 async 更可靠。

一段非同步程式至少同時處理四件事。第一,呼叫端必須拿到代表工作的 Task,才能知道何時完成。第二,失敗必須進入同一個可等待物件,使上層可以捕捉、記錄或轉換錯誤。第三,並行或循序必須是明確選擇,而不是委派型別意外造成的結果。第四,取消只能被視為合作協定:要求停止的一方發出訊號,執行工作的一方仍要主動觀測並在安全點結束。

await 的價值因此不是「讓程式變快」。它建立可組合的完成關係:目前方法先把控制權交還呼叫端,等受等待的 Task 完成後再延續;如果該 Task 失敗,例外會在 await 點重新出現。這種結構讓測試可以等待結果,讓 API 可以在回應前確認必要工作完成,也讓錯誤處理保持在正常的呼叫鏈上。

程式碼審查時,先找出每一個非同步邊界,再問三個問題:誰擁有這個工作的生命週期?誰觀測它的結果或錯誤?應用程式結束、要求取消或逾時後,工作會怎麼收尾?只要其中一題沒有明確答案,就不應把該段程式視為已完成設計。

為什麼 List.ForEach 搭配 async lambda 會形成假等待

List.ForEach 接受的是同步 Action,傳入 async lambda 時會被轉成 async void;外層呼叫無法取得每次工作的 Task,所以方法可能在項目尚未完成前就返回。

這個問題的危險不在語法,而在型別。開發者看到 lambda 裡有 await,容易直覺認為外層也會等待;實際上,Action 沒有回傳值,編譯器只能建立 async void 回呼。結果是所有回呼啟動後,ForEach 立刻結束。呼叫端無法用 Task.WhenAll 聚合它們,也無法透過一般 try/catch 穩定接住稍後發生的錯誤。

需要循序處理時,直接使用 foreach 並在迴圈內 await。這個寫法保留順序,而且下一個項目要等前一個完成。需要並行處理時,先用 Select 建立 IEnumerable,實體化成陣列,再 await Task.WhenAll(tasks)。如此並行是可見的設計決策,所有子工作也有共同的完成點。

不要把「全部啟動」誤當成「全部完成」。如果後續步驟要依賴前面的寫入、計算或驗證,只有已等待的 Task 才能形成可靠的 happens-before 關係。沒有這個關係,即使在開發機上常常看起來正常,也可能因排程順序改變而提早讀取不完整狀態。

審查清單應包含委派簽章。看到 Action、事件回呼或不回傳 Task 的擴充方法時,要確認 async lambda 是否被迫成為 async void。看到 Select(async ...) 時,則要確認產生的 Tasks 最後真的被實體化並等待,而不是只建立延遲列舉後就離開方法。

async void、fire-and-forget 與例外可觀測性

除事件處理器之外,公開非同步方法應回傳 Task;刻意丟棄 Task 會同時丟失完成、失敗與取消訊號,使呼叫端無法建立可靠的後續流程或一致錯誤處理。

async void 的主要限制是呼叫端沒有可等待物件。方法中的例外不會像 Task 例外一樣回到一般 await 點,測試也無法直接等待工作完成。UI 或框架事件因簽章限制可能需要 void,但事件處理器應保持薄層:立即呼叫回傳 Task 的核心方法,在事件邊界明確處理例外,避免把商業流程直接塞進不可組合的回呼。

_ = SomeAsync() 雖然明確表示刻意忽略傳回值,卻不會自動提供可靠性。若工作是回應不可或缺的一部分,就應直接 await。若工作真的可以脫離要求生命週期,則應交給具備佇列、錯誤記錄、重試政策、關閉排空與健康監測的背景元件,而不是在任意方法內悄悄啟動。這裡的重點是責任移交,不是用特殊語法壓掉編譯器警告。

例外處理也必須靠近責任擁有者。底層方法只捕捉它能修復或增加有效脈絡的錯誤;否則保留 Task 的失敗狀態讓上層決策。空白 catch 會讓工作看似成功,還會消除可診斷證據。使用 Task.WhenAll 時,應保留所有子 Task 的識別資訊,使失敗能對應到輸入,而不是只留一段缺少上下文的訊息。

對外回應必須在必要工作完成後才建立。如果先啟動工作,接著立刻序列化可變結果,背景回呼稍後再修改該物件,呼叫端收到的內容取決於競速時序。修正方式不是延長等待時間,而是建立不可模糊的完成點:先 await,取得穩定結果,再產生回應。

Wait、Result、Task.Status 與取消語意

同步 Wait 或 Result 會占住目前執行緒,Task.Status 則只反映讀取當下的快照;兩者都不能取代 await 後依完成、失敗或取消分支進行的明確控制流程。

使用 .Wait().Result 會把原本可非封鎖等待的路徑變成同步阻塞。在具有同步內容的環境可能造成互相等待,在伺服器負載下也會讓執行緒無法處理其他工作。正確原則是 async all the way:從入口一路回傳並 await Task,只有真正的同步邊界才考慮隔離轉接,而且不能假設阻塞等同安全完成。

Task.Status 也不是成功預言器。剛建立的 Task 常處於尚未完成狀態;「目前不是 Faulted」只代表此刻未觀察到 fault,不代表稍後一定成功,也沒有涵蓋取消。需要結果時直接 await,成功就繼續,失敗由 catch 處理,取消則依需求分流。若只處理已確定完成的 Task,可在明確前置條件下使用 IsCompletedSuccessfully,但不能用它取代等待。

逾時與取消尤其容易被混為一談。WaitAsync 可以讓等待者在時間到或取消訊號出現時停止等待,然而原始工作若沒有觀測同一個取消訊號,仍可能繼續執行。這是 API 契約,而不是執行階段漏洞。因此呼叫端不能在逾時後立刻假設舊工作已停止,也不應無條件啟動另一份相同工作。

可取消方法應接收 CancellationToken,將它傳遞給支援取消的下游 API,並在迴圈或階段邊界定期檢查。取消後的狀態變更要保持一致;如果某個舊元件無法合作取消,則要把逾時視為「等待已放棄但工作狀態未知」,透過工作識別、冪等設計與明確監測避免重複執行。

驗證與安全重現:固定 .NET 8 行為

以下測試在本機暫存專案使用 .NET SDK 8.0.100、目標框架 net8.0,僅操作記憶體中的 TaskCompletionSource;排程環境已實際執行並取得七行 PASS,沒有外部帳號、網路寫入或不可逆資料操作。

前置條件是已安裝 .NET SDK 8.0.100,空白測試資料夾可寫入,且 global.json 固定 SDK 版本。重現流程如下:

  1. 建立 net8.0 Console 專案,加入 global.json,將 SDK 精確固定為 8.0.100。
  2. 以閘門控制三個非同步案例:List.ForEach 回呼、Task.WhenAll 子工作,以及不觀測取消的原始 Task。
  3. 執行 dotnet run --configuration Release,逐行檢查 PASS;任何 assertion 擲出例外或程序非零結束都算失敗。
using System.Collections.Concurrent;

static void Assert(bool ok, string message)
{
    if (!ok) throw new InvalidOperationException("FAIL: " + message);
    Console.WriteLine("PASS: " + message);
}

var items = new[] { 1, 2, 3 };
var gates = items.ToDictionary(x => x, _ => new TaskCompletionSource());
var completed = new ConcurrentBag();
items.ToList().ForEach(async x => { await gates[x].Task; completed.Add(x); });
Assert(completed.IsEmpty, "ForEach returned before callbacks completed");
foreach (var gate in gates.Values) gate.SetResult();
await Task.Delay(50);

var tasks = items.Select(async x => { await Task.Yield(); return x; }).ToArray();
var results = await Task.WhenAll(tasks);
Assert(results.SequenceEqual(items), "WhenAll observed every result");

var stop = new TaskCompletionSource();
var original = Task.Run(async () => await stop.Task);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
try { await original.WaitAsync(cts.Token); } catch (OperationCanceledException) { }
Assert(!original.IsCompleted, "original task continued after waiter cancellation");
stop.SetResult();
await original;

第一次檢查證明 List.ForEach 已返回時,受閘門控制的回呼仍未完成;這直接顯示外層沒有可等待的聚合 Task。第二次檢查證明 Task.WhenAll 可提供共同完成點並傳回全部結果。第三次檢查證明等待者收到取消後,不合作的原始工作仍保持未完成,直到測試主動開啟閘門。

排程實際輸出包含以下通過條件:ForEach 在工作完成前返回、所有 async-void 回呼在釋放後完成、WhenAll 在子工作受阻時保持未完成、WhenAll 觀測全部結果、WaitAsync 觀測取消、原工作不因等待者取消而停止,以及原工作在釋放後成功完成。這些是功能斷言,不是 benchmark,也不代表任何吞吐量或成功率。

程式碼審查與改寫決策表

落地時先依工作是否影響目前結果決定 await 或移交背景處理,再依循序、有限並行與取消合作選擇結構;每條路徑都必須保留可測試的完成點與可觀測錯誤。

第一類是要求內必要工作,例如驗證、資料轉換與回應內容計算。這些工作必須在建立回應前 await。第二類是可以並行但仍屬於目前要求的工作,應建立明確 Task 集合,以 Task.WhenAll 等待,並把輸入識別與結果對應保存。第三類是生命週期確實獨立的工作,必須交給受管理的背景服務,而不是臨時 fire-and-forget。

對集合處理,先選語意再選語法。要求順序與逐項失敗停止,就用 foreach 加 await。各項互相獨立且允許同時執行,就建立 Task 集合;若資源有上限,使用固定容量的節流機制,並以測試確認上限。不要因為 Task.WhenAll 寫法短,就在未評估資源壓力時無限制建立工作。

對取消處理,文件要說清楚「取消等待」還是「取消工作」。前者可以由 WaitAsync 提供呼叫端界線;後者需要工作本身觀測 CancellationToken。若無法保證合作取消,逾時結果必須標示為未知,不能回報工作已停止。重試前要先確認冪等性與舊工作的狀態處理策略。

最後,把這些規則轉成可執行測試:受控閘門確認方法不會過早返回,故障子工作確認例外可在 await 點被捕捉,取消測試確認等待者與原始工作的狀態差異。如此 async/await 就不只是風格規範,而是能在變更後持續驗證的正確性契約。

結論

可靠的 async/await 設計要讓每個工作都有擁有者、完成點、錯誤觀測者與明確取消契約;避免 async void 與未管理背景工作,才能消除假完成與競速造成的不確定性。

實務修正順序很明確:先把非事件的 async void 改為 Task,再找出被丟棄或未等待的 Task,接著把集合操作改成循序 foreach 或可聚合的 WhenAll,最後補上取消合作、逾時後狀態與可重現測試。不要用延遲、Status 快照或空白 catch 掩蓋生命週期問題。

本文的核心結論經 Microsoft 官方 C# 非同步文件、Task 例外處理文件與 .NET 8 WaitAsync API 文件交叉查核,並由本機 net8.0 測試驗證。來源筆記只提供可公開抽象的技術概念,環境特定名稱與敏感值均未帶入文章。

廣告