CodeGym /課程 /C# SELF /在非同步程式碼中處理例外

在非同步程式碼中處理例外

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

1. 非同步方法與例外

我們習慣於如果程式碼出了問題(例如除以零或嘗試存取不存在的檔案),就會丟出例外,然後可以用 try-catch 捕捉。這在程式順序執行且在同一個執行緒時很簡單。但一旦出現非同步,情況就像外太空一樣:例外可能在你預期位置的很遠處「出現」,或甚至完全被忽略。

原因在於非同步方法通常會回傳一個任務(Task),該任務的執行會在方法返回之後繼續。例外可能發生在主要執行緒「放手」之後,所以圍繞呼叫的傳統 try-catch 並不總是像在同步程式碼中那樣運作。

我們用一個簡單範例來說明。假設在我們的小應用程式裡有這樣的非同步方法:

// 我們應用的一段:非同步計算「發送報告」
public async Task SendReportAsync()
{
    // 這裡可能有網路呼叫或檔案存取
    await Task.Delay(100);
    throw new InvalidOperationException("發送報告時發生錯誤!");
}

下面是我們如何呼叫它:

SendReportAsync();
Console.WriteLine("繼續工作...");

視覺化

flowchart TD
    Start["Main 執行緒"]
    Call[/"呼叫 SendReportAsync()"/]
    Continue["工作繼續..."]
    Exception["例外在 Task 中發生"]
    Unhandled["錯誤未被處理!"]
    Start --> Call --> Continue
    Call -.- Exception --> Unhandled

結論:如果非同步方法回傳 Task,而你沒有等待該任務完成(用 await.Wait()),例外會被「忽略」。在最好的情況下,執行環境會在日誌寫上「Task 中未處理的例外」之類的東西;在最糟的情況下,你完全丟失錯誤,會花很多時間找「神祕的 bug」。

2. 如何在非同步程式碼中正確捕捉例外?

使用 await + try-catch

看一個正確的做法:

try
{
    await SendReportAsync(); // 等待 Task 結束
    Console.WriteLine("報告已成功發送!");
}
catch (Exception ex)
{
    Console.WriteLine($"哎呀! 出了點問題: {ex.Message}");
}

這是怎麼運作的? 當你在非同步方法前加上 await,C# 會把你的方法分成兩段:await 之前和之後。如果非同步那一段發生例外,例外會在放有 await 的地方「冒出來」,於是可以用傳統的 try-catch 捕獲。

範例(應用程式)

在我們的示範中加上報告發送的錯誤處理:

public async Task StartReportProcessAsync()
{
    try
    {
        await SendReportAsync();
        Console.WriteLine("報告已成功發送!");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"發送報告時出錯: {ex.Message}");
    }
}

然後呼叫:

await StartReportProcessAsync();

.Wait(), .Result — 對於主控台程式不是最佳但可用

有時候,特別是在舊版 C# 的主控台應用中,頂層不能使用 await(舊版 Main),這時只能用同步等候任務完成,透過 .Wait().Result

try
{
    SendReportAsync().Wait();
}
catch (AggregateException aggEx)
{
    foreach (var ex in aggEx.InnerExceptions)
        Console.WriteLine($"錯誤: {ex.Message}");
}

為什麼? 呼叫 .Wait().Result 時,原始例外會被包進 AggregateException。這個容器可能包含一個或多個例外。所以你得用迴圈拆解內部例外。關於 AggregateException 的更多資訊可見 官方文件

注意!

在較新的 .NET 版本(從 C# 7.1 開始),你可以把 Main 宣告為非同步,並在進入點直接使用 await

static async Task Main(string[] args)
{
    await StartReportProcessAsync();
}

3. 在 "fire-and-forget" 任務中的例外

如果你啟動了一個非同步方法,既不等待它完成也不保留對該任務的引用,會發生什麼?

SendReportAsync(); // 「忘記」了這個任務

在這種情況下會有問題:任務中發生的例外不會被任何人處理。有時(視環境與設定而定)應用程序可能會異常終止;有時只是記錄個警告。這不是 C# 的 bug,而是 Task 工作邏輯的結果。

正確做法?

  • 理想情況下:如果你不確定任務是否可能異常終止,盡量不要使用 "fire-and-forget"。
  • 如果非同步方法確實應該以 "fire-and-forget" 方式運行,請在方法內明確處理錯誤。
public async Task SendReportSafeAsync()
{
    try
    {
        await Task.Delay(100);
        throw new InvalidOperationException("發送時發生錯誤!");
    }
    catch (Exception ex)
    {
        // 記錄或處理錯誤
        Console.WriteLine($"[日誌] 例外: {ex.Message}");
    }
}

// 呼叫
SendReportSafeAsync();

通用建議: 如果任務沒有人追蹤,而且你不使用 await,務必要在非同步方法內包一層 try-catch。這樣至少能把錯誤記錄下來,不會悄悄丟失。

4. 例外與平行任務:Task.WhenAll 與同伴

在真實應用中常常需要同時啟動多個獨立的非同步任務並等待它們完成。例如,向多個收件人平行發送報告:

var tasks = new List<Task>
{
    SendReportAsync(),
    SendReportAsync(),
    SendReportAsync()
};

await Task.WhenAll(tasks);

如果其中一個(或多個)任務丟出例外會怎樣?

如何捕捉這些錯誤?

當使用 await Task.WhenAll(tasks) —— 如果至少有一個任務以錯誤結束,await 會拋出第一個完成且發生錯誤的任務的例外(這個例外通常不會被包在 AggregateException 中)。
但有個重點:如果有多個任務失敗,那可能會拋出包含多個內部例外的 AggregateException

try
{
    await Task.WhenAll(tasks);
}
catch (Exception ex)
{
    // 如果是 AggregateException — 拆開處理
    if (ex is AggregateException agg)
    {
        foreach (var inner in agg.InnerExceptions)
            Console.WriteLine($"任務錯誤: {inner.Message}");
    }
    else
    {
        Console.WriteLine($"錯誤: {ex.Message}");
    }
}

對於使用 await 的單一任務,例外通常不會被包成 AggregateException。但對於 WhenAll —— 有多個錯誤時就很有可能出現!

5. 非同步委派與錯誤處理

在有 UI 的應用(WPF、WinForms、ASP.NET)中,事件處理器常常會寫成非同步 lambda。如果這類處理器拋出例外,結果會依 UI 框架而異:應用可能異常終止,或框架會吞掉錯誤。

建議

在非同步委派內部一定要使用 try-catch

button.Click += async (sender, args) =>
{
    try
    {
        await SendReportAsync();
    }
    catch (Exception ex)
    {
        MessageBox.Show($"錯誤: {ex.Message}");
    }
};
1
問卷/小測驗
非同步程式設計,等級 59,課堂 4
未開放
非同步程式設計
非同步 vs. 多執行緒
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION