CodeGym /課程 /C# SELF /錯誤處理綜合策略: Task, async/ await, AggregateException

錯誤處理綜合策略: Task, async/ await, AggregateException

C# SELF
等級 61, 課堂 4
開放

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 執行緒缺少例外處理 — 背景錯誤會被忽視,介面會表現得「怪怪的」。

2
任務
C# SELF, 等級 61, 課堂 4
上鎖
在非同步任務中處理例外
在非同步任務中處理例外
1
問卷/小測驗
經典 Thread 裡的例外狀況,等級 61,課堂 4
未開放
經典 Thread 裡的例外狀況
非同步程式碼裡的錯誤處理
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION