1. 介紹
寫出永不當機的程式,就像相信 bug 會怕你的 IDE。事實上,程式越複雜(尤其是在多執行緒和非同步環境),bug 越會變得狡猾,錯誤也越複雜。忽視執行緒和任務中的例外,容易導致資源洩漏、死鎖、資料遺失或在啟動數小時後發生意外崩潰。
在這堂講義中我們會彙整處理 C# 多執行緒與非同步程式錯誤的最佳實務。你會學到如何正確捕捉例外、如何拆解多重同時發生的錯誤(像是 AggregateException)、對付那些「悄悄跑掉」的任務,以及為什麼忽略例外既危險又沒意義。
為什麼事情沒那麼簡單
- 發生錯誤的執行緒,可能和包含 try-catch 的執行緒不同。
- 非同步任務不會立刻拋出例外 — 它們會「打包」起來,等待在 await(或同步等待時)處理。
- 同時處理多個任務(比如 Task.WhenAll)時可能會有多個錯誤 — 必須把這些錯誤都考慮進去。
- fire-and-forget 這類操作可能完全「丟失」例外,沒有明確的處理器去接住它們。
這個特性就是 執行緒/上下文分離。把程式想像成有好幾個場地的馬戲團:如果其中一個場地起火,其他場地不會馬上注意到。重要的是能正確追蹤並撲滅這類「火災」。
2. 任務中的例外: Task 與 Task<TResult>
任務如何表示錯誤
當任務內發生未處理的例外時,它不會馬上往外拋。任務會變成 Faulted,例外會保存在內部。可以透過以下方式取得它:
- 等待完成使用 await(或透過 task.Wait()/task.Result — 但最好別這樣做);
- 檢查屬性 task.Exception — 那裡會是 AggregateException。
範例
// 非同步方法內發生錯誤
async Task FailAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("什麼地方出了問題");
}
async Task MainAsync()
{
try
{
await FailAsync();
}
catch (Exception ex)
{
Console.WriteLine($"錯誤: {ex.Message}");
}
}
如果不加 try-catch,程式會崩潰。有了它 — 就算錯誤在另一個任務發生,也能正確捕捉。
AggregateException: 批量錯誤
對於 await Task.WhenAll(tasks),多個任務的錯誤會被收集到一個 AggregateException(在它的 InnerExceptions 中)。
async Task MultiFailAsync()
{
Task t1 = Task.Run(() => throw new InvalidOperationException("錯誤 1"));
Task t2 = Task.Run(() => throw new ArgumentException("錯誤 2"));
try
{
await Task.WhenAll(t1, t2);
}
catch (Exception ex)
{
if (ex is AggregateException agg)
{
foreach (var e in agg.InnerExceptions)
Console.WriteLine($"例外: {e.Message}");
}
else
{
Console.WriteLine($"單一錯誤: {ex.Message}");
}
}
}
注意:使用 await Task.WhenAll(tasks) 時,.NET 會「展開」AggregateException,在 catch 你會拿到「第一個」例外。完整清單可以透過 task.Exception.InnerExceptions 取得(如果該任務以錯誤完成)。
3. 「Fire and Forget」: 為什麼不能就這樣忘了任務
「啟動然後忘記」經常會導致隱藏的崩潰。例子:
Task.Run(() => { throw new Exception("啪!"); }); // 錯誤「飛到」真空中。
現代 runtime 會把錯誤保存在任務中,且可能因未觀察的例外導致進程終止。最好的做法是保留任務的引用和/或訂閱事件 TaskScheduler.UnobservedTaskException。
怎麼做比較好?
- 保留任務引用,以等待它完成並處理錯誤;
- 對於 fire‑and‑forget,在委派內直接放置處理器。
Task.Run(() => {
try
{
// 可能拋出例外的程式碼
}
catch (Exception ex)
{
// 記錄、通知,但不要把錯誤放出來
Console.WriteLine($"fire-and-forget 中的錯誤: {ex.Message}");
}
});
4. 執行緒 (Thread) 中的錯誤:能捕到嗎?
在新的 Thread 中發生的例外,無法在外部用 try-catch 捕捉 — 只能在該執行緒的內部處理。
var thread = new Thread(() =>
{
try
{
throw new Exception("執行緒中的錯誤");
}
catch (Exception ex)
{
Console.WriteLine($"在執行緒內捕獲錯誤: {ex.Message}");
}
});
thread.Start();
如果不放處理器,該例外只會終止該執行緒(若為背景執行緒: thread.IsBackground = true)。對於前景執行緒,未處理的例外可能會終止整個進程。務必在執行緒內部放 try-catch。
如何從執行緒回傳結果與錯誤?
- 使用佇列/集合來傳遞結果與錯誤;
- 事件驅動模型;
- 或更好地轉用任務 — 用任務處理錯誤會比較方便。
5. 平行迴圈: 特殊的錯誤處理方式
在平行迴圈中,來自不同分支的錯誤會被聚合成 AggregateException。
try
{
Parallel.For(0, 5, i =>
{
if (i % 2 == 0)
throw new Exception($"第 {i} 次迭代的錯誤");
});
}
catch (AggregateException ex)
{
foreach (var e in ex.InnerExceptions)
Console.WriteLine($"[平行迴圈] 錯誤: {e.Message}");
}
如果需要記錄局部失敗並繼續其他分支,就在每個分支內放置局部的 try-catch。
6. 使用取消時的錯誤處理
透過 CancellationToken 取消時,約定會拋出 OperationCanceledException — 這不是錯誤,而是正常的停止流程。
async Task DoWorkAsync(CancellationToken token)
{
for (int i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(100);
}
}
// 在某處的程式碼:
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
cts.Cancel(); // 大約 200 ms 後取消
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("操作已被取消!");
}
除了 ThrowIfCancellationRequested(),許多支援 token 的方法(例如 Task.Delay, HttpClient.GetAsync)會自行拋出 OperationCanceledException。務必檢查是否支援取消。
7. 有用的小細節
通用做法
- 在非同步方法的高層放置 try-catch — 這樣能捕到「跑掉」的錯誤。
- 不要忽略任務:保留引用、等待完成、記錄錯誤。
- 對於平行操作(Task.WhenAll / Parallel.ForEach)要考慮 AggregateException。
- 把取消和錯誤分開處理:獨立捕捉 OperationCanceledException。
- 記錄錯誤,尤其是那些不會直接讓應用崩潰的「安靜錯誤」。
- 保留細節:記錄所有內部例外,而不只是第一個。
- 在「不確定怎麼處理」的地方至少記錄錯誤 — 務必在本地處理錯誤。
在多執行緒與非同步程式中應該在哪裡捕捉錯誤
flowchart TD
A[主執行緒 / UI] -->|啟動任務| B[Task/async]
B -->|任務內| C[在非同步方法內的 try/catch]
B -->|在主執行緒 await| D[圍繞 await 的 try/catch]
A -->|啟動 Thread| E[Thread]
E -->|內部| F[在執行緒內的 try/catch]
B -->|多個任務| G[Task.WhenAll / Parallel.ForEach]
G -->|錯誤| H[AggregateException]
8. 常見錯誤和陷阱
錯誤 №1: 用 .Result 或 .Wait() 等待任務。可能造成 deadlock 和/或 意外的 AggregateException。
錯誤 №2: 啟動 fire‑and‑forget 而不在內部放 try-catch — 任務可能靜默失敗,沒有診斷資訊。
錯誤 №3: 忽略平行迴圈中的錯誤 — 一部分工作沒做,你卻不知道。
錯誤 №4: 無法區分取消(OperationCanceledException)和真實錯誤。
錯誤 №5: 只記錄多個任務中的第一個錯誤 — 其他錯誤會被遮蔽。
錯誤 №6: 為所有任務重複使用同一個 Exception 物件 — 每個錯誤應該有自己的實例。
錯誤 №7: 在 UI 執行緒缺少例外處理 — 背景錯誤會被忽視,介面會表現得「怪怪的」。
GO TO FULL VERSION