CodeGym /課程 /C# SELF /從事件退訂( -=)與記憶體洩漏

從事件退訂( -=)與記憶體洩漏

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

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!");

「忘記」退訂

退訂常常會被忘記,特別是當訂閱者的生命週期比發佈者長,或者開發者不完全理解事件的運作機制時。結果是訂閱者物件比預期停留在記憶體中更久,導致記憶體洩漏和效能問題。

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