1. 介紹
從某種意義上說,在 C# 裡訂閱一個事件就像訂閱朋友發的迷因郵件:消息會一直來,直到你說「夠了」並退訂。在程式設計中這很重要,因為忘記退訂可不只是「多了一則迷因」,而是會造成記憶體洩漏!
想像你有一個應用程式中的某個表單(例如一個額外的設定視窗)。它訂閱了主視窗的事件,以便對變更作出回應。使用者關閉表單,以為它已被銷毀,但處理器仍然被訂閱著!表單仍然留在記憶體中,因為發佈者透過事件保存了對它的參考。
結論:如果物件(訂閱者)訂閱了發佈者的事件卻「忘記」退訂,只要發佈者仍然存活,垃圾回收器就不會把該訂閱者從記憶體中移除。
回顧操作符 += 並展示 -=
- += — 訂閱:把處理器加入到事件的調用清單中。
- -= — 退訂:從調用清單中移除處理器。
看起來大概是這樣:
worker.WorkCompleted += handler; // 訂閱
worker.WorkCompleted -= handler; // 退訂
如果處理器被加入了兩次,就需要刪除同樣次數,才能確保它從調用清單中消失。
一些內部運作
在背後,C# 的事件本質上是一個 delegate 欄位(或 delegate 的清單),操作符 += 事實上會呼叫 Delegate.Combine,而 -= 會呼叫 Delegate.Remove。訂閱事件的物件會成為引用圖的一部分。這就是為什麼忘記退訂會導致記憶體洩漏的原因。
2. 透過事件造成記憶體洩漏:工作原理
經典情況
class Window
{
public event EventHandler Updated;
public void SimulateUpdate()
{
// 模擬:向所有訂閱者發送通知
Updated?.Invoke(this, EventArgs.Empty);
}
}
class SettingsForm
{
public void OnWindowUpdated(object sender, EventArgs e)
{
Console.WriteLine("SettingsForm 對視窗更新做出反應");
}
}
我們一步步來看:
var window = new Window();
var settingsForm = new SettingsForm();
window.Updated += settingsForm.OnWindowUpdated;
window.SimulateUpdate(); // SettingsForm 有反應
// 使用者關閉了表單。我們丟掉了對它的所有引用:
settingsForm = null;
// 但 SettingsForm 物件不會被 GC 回收,只要 window 還活著,
// 因為 window.Updated 仍然保有對 OnWindowUpdated 方法的引用,
// 也就間接保有對 SettingsForm 實例的引用。
該怎麼做?
退訂:
// 為此我們需要保留對處理器或物件的引用:
window.Updated -= settingsForm.OnWindowUpdated;
settingsForm = null; // 現在物件可以被回收
表格:誰持有誰的引用
| 操作 | 誰持有引用 | 能否釋放記憶體? |
|---|---|---|
| 訂閱事件(+=) | 發佈者指向訂閱者 | 不行,除非發佈者被回收 |
| 退訂(-=) | 沒有額外引用 | 可以,在移除所有外部引用後 |
| 未訂閱 | 沒有引用 | 可以 |
3. 如何正確安排退訂
明確刪除處理器
這可以在視窗或表單關閉時執行,例如:
class SettingsForm
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void Close()
{
_window.Updated -= OnWindowUpdated; // 退訂!
// 在這裡放關閉相關的程式(例如 Dispose, GC.SuppressFinalize 等)
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// 事件處理邏輯
}
}
如果 SettingsForm 是透過「關閉」按鈕被銷毀,重要的是不要忘記呼叫執行退訂的方法(例如 Close())。
使用 IDisposable 介面
對於那些會訂閱事件並且管理自己生命週期的複雜物件,實作 IDisposable 很方便。在 Dispose() 方法中做所有必要的退訂。
class SettingsForm : IDisposable
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// ...
}
public void Dispose()
{
_window.Updated -= OnWindowUpdated;
// 在這裡釋放其他資源
}
}
現在可以在 using 區塊中使用 SettingsForm、明確呼叫 Dispose(),或在需要的情況下在有 finalizer 的型別中搭配 GC.SuppressFinalize 自動化資源釋放。
4. 與 lambda 表達式互動:風險與技巧
如果你用 lambda 表達式訂閱事件,但沒有把那個 lambda 存起來成為變數,你就無法退訂它!
// 訂閱 — 匿名 lambda
window.Updated += (s, e) => Console.WriteLine("Lambda 被呼叫!");
// 現在要怎麼退訂?— 沒辦法!
window.Updated -= (s, e) => Console.WriteLine("Lambda 被呼叫!"); // 這是另一個 delegate!
怎麼辦?
把 lambda 存到 delegate 變數裡:
EventHandler handler = (s, e) => Console.WriteLine("Lambda 被呼叫!");
window.Updated += handler;
// ... 現在可以退訂了!
window.Updated -= handler;
5. 有用的細節
物件生命週期與事件的特殊情況
另一個常見問題是兩個「長生命週期」物件之間透過事件產生的交叉引用。例如,一個視窗訂閱了另一個視窗的事件,兩者都還在使用中、都沒被移除——記憶體就會一直被消耗。
建議:養成習慣去記住誰訂閱了誰,什麼時候需要退訂。如果訂閱者與發佈者的生命週期一致,那問題不大。如果訂閱者可能比發佈者短命,就要實作明確退訂。
通用準則:「訂閱了就要退訂!」
- 對於長期存活的發佈者(例如全域、singleton、主要視窗)— 訂閱者一定要實作退訂。
- 對於短期物件(例如一次性的通知或發佈者生命週期短於訂閱者)— 情況可以放鬆,但仍要小心上下文。
- 如果你不想手動管理退訂,可以考慮像 WeakEvent(弱事件)這類的方法或使用支援的框架。
6. 處理退訂時的常見錯誤
失敗的退訂:處理器方法必須相同
非常重要的一點是,退訂時你必須指定與訂閱時完全相同的處理器。否則退訂不會生效。
錯誤:
window.Updated += settingsForm.OnWindowUpdated;
// ...
window.Updated -= new SettingsForm().OnWindowUpdated; // 不會生效!這是另一個實例和另一個 delegate!
正確:
window.Updated -= settingsForm.OnWindowUpdated;
如果訂閱時使用了未保存引用的匿名 lambda,退訂就不可能,因為那會是另一個 delegate 實例:
// 訂閱
window.Updated += (s, e) => Console.WriteLine("Lambda!");
// 嘗試退訂 — 不會生效!
window.Updated -= (s, e) => Console.WriteLine("Lambda!");
「忘記」退訂
退訂常常會被忘記,特別是當訂閱者的生命週期比發佈者長,或者開發者不完全理解事件的運作機制時。結果是訂閱者物件比預期停留在記憶體中更久,導致記憶體洩漏和效能問題。
GO TO FULL VERSION