CodeGym /課程 /C# SELF /在 "fire and forget" 任務中的例外

在 "fire and forget" 任務中的例外

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

1. 什麼是 "fire and forget"?

在程式設計裡,fire and forget 指的是啟動一個任務但不等待它完成。在 C# 和 .NET 世界,通常用 Task 啟動任務,但不在任何地方等待(沒有 await)、不保留引用,實際上就把它忘了。

// 按鈕在背景啟動任務,但在任何地方都不 await。
button.Click += (s, e) =>
{
    Task.Run(() => 長時間操作());
};

聽起來很吸引人:「讓它在背景跑,我先做我的事」。但用這種方式,如果任務內發生例外,沒有人會及時知道——它會靜悄悄地丟失。

2. Task 中的例外處理如何運作

經典:用 await 處理錯誤

處理非同步任務的標準方式是透過 await。如果任務裡發生錯誤,錯誤會在等待點拋出:

try
{
    await SomeOperationAsync(); // 如果裡面有 Exception,會在這裡進到 catch
}
catch(Exception ex)
{
    Console.WriteLine("哎呀!任務發生錯誤: " + ex.Message);
}

也就是說,當你等待任務完成時,就不會漏掉例外。

但 fire-and-forget 任務沒人等它!

public void 啟動不等待()
{
    // 任務自己跑。沒人等它...
    Task.Run(() => {
        // 任務內某處出了問題:
        throw new InvalidOperationException("哎呀,一切都完了!");
    });
    // 方法結束,任務在背景默默執行。
}

如果這種任務內發生例外,它不會在主執行緒拋出。程式會繼續運作,好像什麼事都沒發生。

重要

在 .NET 中,發生未處理例外的任務會進入 Faulted 狀態。但如果你不等待它(沒有 await、沒有 .Result.Wait() 等),沒有人會讀取那個例外,也不會在呼叫端顯現。

底層實際發生了什麼?

對於沒人等待的任務,唯一剩下被注意到的機會就是事件 TaskScheduler.UnobservedTaskException。當 GC(垃圾回收)發現一個帶有未被觀察例外的任務物件時,它會觸發這個事件。但這不是即時發生,也不是在你預期的地方發生——不能依賴它。

3. 演示:Fire-and-forget 的錯誤

// 範例:從 Main 直接啟動 fire-and-forget 任務
using System;
using System.Threading.Tasks;

class Program
{
    static void Main(string[] args)
    {
        FireAndForgetExample();

        Console.WriteLine("主執行緒繼續運作...");
        // 給任務點時間完成
        Task.Delay(2000).Wait();
    }

    static void FireAndForgetExample()
    {
        Task.Run(() =>
        {
            Console.WriteLine("Fire-and-forget 任務開始!");
            Task.Delay(500).Wait();
            throw new InvalidOperationException("fire-and-forget 任務內的錯誤!");
        });
    }
}

如果執行這段程式,通常……不會有什麼特別的事。錯誤會發生,但程式不會知道。有時在 IDE 的 Output Window 會看到警告,但對使用者來說——完全沒有訊息。

為什麼這在真實專案中很危險?

  • 複雜的 bug 很難重現(「有時不工作——不知道為什麼」)。
  • 靜默的資料或邏輯損失(例如沒有發送出客戶郵件)。
  • 在 production 中如果沒設好日誌,根本收不到問題的訊號。

4. 在 fire-and-forget 中正確處理錯誤的方法

在任務內部記錄並處理錯誤

最基本的安全做法是在 fire-and-forget 任務內部捕獲例外:

Task.Run(() =>
{
    try
    {
        // 你的長時間/危險程式碼
        throw new InvalidOperationException("出了些問題!");
    }
    catch (Exception ex)
    {
        // 記錄錯誤或通知使用者
        Console.WriteLine("Fire-and-forget: 捕捉到例外: " + ex.Message);
        // 可以寫入 log 檔、使用監控/告警系統等。
    }
});

非同步的 void 方法(以及為何不建議這麼做)

async void DangerousFireAndForget()
{
    // 一些危險的事
    throw new Exception("砰!");
}

async void 方法本質上就是 fire-and-forget:沒辦法等待它們,因為它們不回傳 Task。它們的例外會飛到全域的應用程式處理器(例如 AppDomain.UnhandledException),經常會導致程序崩潰。只在 event handler 使用 async void,而且要非常小心。

用輔助方法統一處理錯誤

把安全的 fire-and-forget 啟動封裝成一個 wrapper 很方便:

// 通用方法:安全地啟動 fire-and-forget
public static void RunSafeFireAndForget(Func<Task> taskFactory)
{
    Task.Run(async () =>
    {
        try
        {
            await taskFactory();
        }
        catch (Exception ex)
        {
            // 記錄例外
            Console.WriteLine("Fire-and-forget (safe): " + ex);
            // 可以把錯誤發送到監控系統!
        }
    });
}

// 使用範例:
RunSafeFireAndForget(async () =>
{
    await Task.Delay(1000);
    throw new InvalidOperationException("在 fire-and-forget 裡面!");
});

真實場景範例:發送 email

// 發送郵件按鈕:
private void buttonSend_Click(object sender, EventArgs e)
{
    Task.Run(() => SendEmail());
}

// 發送方法:
private void SendEmail()
{
    try
    {
        // 這裡可能是真正的發送邏輯
        throw new Exception("無法連到 SMTP 伺服器!");
    }
    catch (Exception ex)
    {
        // 記錄
        File.AppendAllText("errors.log", $"發送錯誤: {ex.Message}\n");
    }
}

5. 那 UnobservedTaskException 怎麼辦?

作為最後手段,.NET 提供了事件 TaskScheduler.UnobservedTaskException。如果任務以錯誤結束、沒有人等待它,且該任務物件被 GC 回收,就會觸發這個事件。不能依賴它——這是「最後一線希望」機制。

TaskScheduler.UnobservedTaskException += (sender, e) =>
{
    Console.WriteLine("全域 UnobservedTaskException: " + e.Exception);
    e.SetObserved(); // 別忘了呼叫這個,否則應用程式可能會異常終止!
};

詳情: TaskScheduler.UnobservedTaskException

6. 有用的小細節

方式比較示意

方式 例外已處理? 在哪裡捕捉錯誤 遺失錯誤的風險
await
在呼叫端的程式碼
Fire-and-forget 未加 try/catch 無處 非常高
Fire-and-forget 加上 try/catch 在任務內部 低(如果有記錄)
async void 方法 否(會飛到全域) 全域處理器

如何正確設計 fire-and-forget

  • 如果任務的結果或狀態很重要——別用 fire-and-forget。使用 await 或保留 Task 以便之後等待。
  • 只有在真正不重要的背景工作才使用 fire-and-forget(例如發送遙測)。
  • 一定要把 fire-and-forget 包成自己的方法並捕捉/記錄例外。
  • 對於複雜的背景場景,使用佇列和 worker:Hangfire, Quartz.NET

實務應用與面試重點

面試常問:「如果 fire-and-forget 任務發生例外會怎樣?」或「為什麼不能到處用 async void?」正確的回答:你要自行負責背景任務的錯誤命運——要麼捕捉、記錄並分析,要麼你就會得到幽靈 bug。

比較 'fire-and-forget' 與 await 的適用性

情境 錯誤處理可靠性 適用性
一般 await 很好 任何需要結果或重視成功/失敗的地方
Fire-and-forget 差(如果不手動處理) 只適合真的背景且不重要的任務
Fire-and-forget + try/catch 好(如果有記錄) 結果不需要但需要知道是否失敗的背景任務

下一講會討論在回傳多個結果的平行任務中如何處理錯誤。暫時記得:如果你開了一槍(fire),別忘了確認子彈有沒有命中目標!

7. 使用 fire-and-forget 任務時的常見錯誤

錯誤 #1:忽略 fire-and-forget 的例外。
新手以為例外會「哪裡冒出來」。沒有 try-catch 和日誌,它們會丟失,導致難以發現的 bug。

錯誤 #2:在非事件處理器中使用 async void
這類方法會把例外丟到全域處理器(例如 AppDomain.UnhandledException),可能會使應用程式異常終止。

錯誤 #3:過度捕捉例外。
在任務內捕捉所有例外可能會隱藏真正應該在呼叫端處理的問題,增加除錯難度。

錯誤 #4:忽視日誌記錄。
沒有針對 fire-and-forget 任務的日誌,尤其是在 production 環境下,根本不知道有沒有故障。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION