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 包裹每個處理器可以解決此問題。
GO TO FULL VERSION