CodeGym /課程 /C# SELF /訂閱者與安全觸發事件

訂閱者與安全觸發事件

C# SELF
等級 53 , 課堂 2
開放

1. 簡介

當我們寫下像這樣的程式碼:

worker.WorkCompleted += listener.OnWorkCompleted;

實際上我們是在事件的 "multicast delegate"(多播委派)中加入一個方法的指標。這個「清單」就是事件內部的多個方法,當事件被觸發時,這些方法會依序被呼叫。在 C# 中,事件是建立在支持多個訂閱者的 delegate 之上的。

想像一下郵件群發:你有一份訂閱者清單(email 地址)。當你發送郵件(呼叫事件)時,所有訂閱者都會收到信。如果有人取消訂閱,他就會從清單中移除,不再收到郵件。

如何加入或移除訂閱者

訂閱(+=)與取消訂閱(-=)是對事件內 delegate 的操作。以下範例示範用 lambda 表達式來訂閱與取消訂閱:

EventHandler<WorkCompletedEventArgs> handler = (sender, e) =>
{
    Console.WriteLine($"[Lambda] 工作完成:{e.Message}");
};

worker.WorkCompleted += handler; // 訂閱
worker.WorkCompleted -= handler; // 取消訂閱

對於一般的方法,取消訂閱的方式相同:

worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted -= listener.OnWorkCompleted;

注意:如果你多次訂閱同一個方法,呼叫多次,該方法會被多次呼叫。取消訂閱時,也要呼叫相同次數的-=,才能完全移除。

2. 為什麼要手動管理?

管理訂閱者的重要性

在實務應用中,尤其是長生命週期的(例如桌面或伺服器端應用),不當管理訂閱可能導致「記憶體洩漏」。例如,訂閱者物件已不再需要,但仍在事件訂閱清單中,導致垃圾回收器無法釋放,造成資源浪費。

示意圖

動作 對訂閱者的影響
+=(訂閱) 加入清單
-=(取消訂閱) 從清單中移除
訂閱者物件被刪除 未取消訂閱! — 仍在清單中,導致記憶體未釋放
訂閱者物件被刪除 已取消訂閱 — 會正常被垃圾回收

如何知道誰訂閱了事件?

C# 允許「透明」存取事件:在類別外部,你只能加入或移除事件處理器,無法直接存取訂閱者清單。但在事件的類別內部,如果事件是建立在 delegate(例如 EventHandler)上,可以用 GetInvocationList() 取得目前所有訂閱者:

// 在發佈者類別內
if (WorkCompleted != null)
{
    foreach (Delegate subscriber in WorkCompleted.GetInvocationList())
    {
        Console.WriteLine($"處理器:{subscriber.Method.Name}, 物件:{subscriber.Target}");
    }
}

這雖然較少用,但在除錯或自訂批次取消訂閱時很有幫助。

3. 安全呼叫事件:「陷阱」與解決方案

呼叫事件時可能出現的問題?

看起來很簡單:你寫

WorkCompleted?.Invoke(this, args);

大多數情況都沒問題!但有一些細節需要注意。

1. 多執行緒的危險

假設:在多執行緒應用中,你讀取事件變數並準備呼叫,但此時另一個執行緒可能已經取消訂閱(或新增訂閱)。這可能導致例外或未呼叫到處理器。

經典問題:
1. 在執行緒A中,你檢查 WorkCompleted != null,確定有訂閱者。
2. 在此時,執行緒B中有人呼叫 -=,導致訂閱者清單變空。
3. 執行緒A呼叫 WorkCompleted.Invoke(...),結果得到 NullReferenceException

2. 註意處理器拋出例外

如果其中一個處理器拋出例外,後續的處理器就不會被呼叫!事件會在第一個例外處停止,其他訂閱者不會收到通知。

3. 不希望的記憶體洩漏

有時候,處理器是實例方法(會捕獲 this),如果沒有取消訂閱,會一直持有該物件,直到事件發生的發佈者被釋放。

如何安全呼叫事件?

1. 複製 delegate 到本地變數

這樣可以確保在呼叫期間,訂閱者清單不會變動。範例:

// 傳統做法
var handler = WorkCompleted;
if (handler != null)
{
    handler(this, args);
}

或用較新的 null-conditional 運算子 ?.

WorkCompleted?.Invoke(this, args);

大多數情況下這樣就夠了,因為 C# 編譯器會幫你做內部的複製(參考:官方文件)。

2. 捕捉處理器拋出的例外

如果你希望所有處理器都被呼叫,即使其中一個拋出例外,可以用迴圈逐一呼叫,並用 try/catch 包裹:

var handler = WorkCompleted;
if (handler != null)
{
    foreach (EventHandler<WorkCompletedEventArgs> subscriber in handler.GetInvocationList())
    {
        try
        {
            subscriber(this, args);
        }
        catch (Exception ex)
        {
            // 記錄錯誤,但不中斷事件傳遞
            Console.WriteLine($"處理器錯誤:{ex.Message}");
        }
    }
}

這在需要確保所有處理器都執行的情境(例如 logging 系統)很有用。

3. 避免記憶體洩漏

如果訂閱者的生命週期比發佈者短(例如,視窗訂閱了應用程式事件),一定要在適當時候取消訂閱:

worker.WorkCompleted -= listener.OnWorkCompleted;

否則,垃圾回收器無法釋放該訂閱者,造成記憶體持續佔用。

4. 實務範例:批次訂閱與取消訂閱管理器

讓我們擴充範例,假設有多個 listener,我們希望動態訂閱與取消訂閱:

public class WorkListener
{
    private readonly string _name;

    public WorkListener(string name)
    {
        _name = name;
    }

    public void OnWorkCompleted(object sender, WorkCompletedEventArgs e)
    {
        Console.WriteLine($"Listener {_name}: {e.Message}");
    }
}

在主程式中:

var worker = new Worker();

var listeners = new List<WorkListener>
{
    new WorkListener("Ivan"),
    new WorkListener("Maria"),
    new WorkListener("Dobrynya")
};

// 訂閱所有 listener
foreach (var listener in listeners)
    worker.WorkCompleted += listener.OnWorkCompleted;

// 執行事件
worker.DoWork();

// 批次取消訂閱
foreach (var listener in listeners)
    worker.WorkCompleted -= listener.OnWorkCompleted;

// 確認不再有反應
worker.DoWork();

第一次呼叫後,控制台會顯示3則訊息;第二次則沒有。

5. 安全操作事件的建議

  • 及時取消訂閱,避免記憶體洩漏,尤其是訂閱者生命週期較短時。
  • 實作長生命週期發佈者與短生命週期訂閱者(例如全域服務與臨時視窗)時,建議在結束時呼叫 Dispose() 或類似方法取消訂閱。
  • 對於只用一次的事件,可以用匿名 lambda 來訂閱,並在內部立即取消:
  • EventHandler<WorkCompletedEventArgs> handler = null;
    handler = (s, e) => 
    {
        Console.WriteLine("事件只處理一次!");
        worker.WorkCompleted -= handler;
    };
    worker.WorkCompleted += handler;
    
  • 不要長期保存訂閱者或處理器的引用,除非確定需要(例如除錯用),正常業務邏輯中不建議這樣做。

6. 常見錯誤與避免方法

  • 記憶體洩漏:未取消訂閱,導致訂閱者物件無法被垃圾回收,尤其在大量事件與訂閱者的應用中。
  • 未檢查 null:呼叫事件前忘了檢查是否為 null。用 ?. 運算子可以避免此問題(C# 6.0 以上)。
  • 處理器拋出例外:若某個處理器拋出例外,後續的處理器就不會被呼叫。用 try/catch 包裹每個處理器可以解決此問題。
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION