使用 Weak Events 來避免記憶體洩漏問題

.NET 事件訂閱的語法很簡單,C# 是用 += 運算子來訂閱事件,用 -= 運算子來取消訂閱。這些我們都已經很熟悉了。可是,在某些情況下,這種事件訂閱的寫法會導致記憶體洩漏(memory leak),亦即應該由 GC 回收的記憶體,卻遲遲沒有回收,導致記憶體用量不斷升高,甚至造成應用程式不穩定。這裡用幾個範例程式來呈現問題和解法,包括 WPF 4.5 新增加的 WeakEventManager<TEventSource, TEventArgs>。

2012-09-19 更新:文後附上範例程式的下載連結。

問題

直接引用 Jeffrey Richter 的經典《CLR via C# 3rd Edition》中的段落:
Occasionally, developers ask me if there is a way to create a weak delegate where one object will register a callback delegate with some other object’s event but the developer doesn't want the registering of the event to forcibly keep the object alive. For example, let's say that we have a class called DoNotLiveJustForTheEvent . We want to create an instance of this class and have it register a callback method with a Button object's Click event . However, we don’t want the Button object's event to keep the DoNotLiveJustForTheEvent object alive. If the DoNotLiveJustForTheEvent object has no other reason to live, then we want it to get garbage collected, and it will just not receive a notification the next time the Button object raises its Click event .
簡單地說:如果事件來源的壽命比訂閱者的壽命還要長,我們如何讓 CLR 盡早回收那些已經不再活著的訂閱者呢?

如果覺得上面的解釋過於簡化,這裡是囉嗦的版本:如果有一個事件來源(event source,或者說 publisher)提供一個事件供外界訂閱,例如有個 Button 物件提供了一個 Click 事件,那麼一旦其他物件訂閱了這個事件,事件來源(Button 物件)就會延長那些訂閱者(subscriber,或者說 listener)的壽命。就算訂閱者已經不再運作、或消失了、結束了,由於事件來源還持有對那些訂閱者的物件參考,故 CLR 在進行資源回收時,並不會回收那些訂閱者。

那,實際上有哪些情況屬於「事件來源比訂閱者的壽命還要長」呢?

比如說,在進行資料繫結的時候,當資料來源有狀態改變,就會通知各個訂閱者。那些訂閱者可能只是另外一個視窗裡面的 UI 控制項,當視窗關閉時,控制項也就應該要隨之消失,並由 CLR 在進行資源回收時一併清掉(但其實沒有)。

這裡用一個 Windows Forms 範例程式來呈現「事件來源比訂閱者的壽命還要長」,以及事件訂閱導致記憶體洩漏(該回收而無法回收)的問題。此範例應用程式的專案名稱是  EventMemoryLeakDemoWin,原始碼下載連結附在文後。

先看主視窗:


我在 Form1 這個主視窗裡面放了三個按鈕:

  • btnRegisterEvent - 用來開啟一個新的 modeless 視窗(此為訂閱者視窗,稍後會看到),並且為該視窗訂閱按鈕 btnTest 的 Click 事件。
  • btnTest - 單純觸發事件。
  • btnGC - 呼叫 GC.Collect() 來強迫 CLR 立即進行資源回收。

下方的文字標籤會顯示此應用程式目前使用了多少 MB 的記憶體。

再加入一個新視窗:ListenerForm,扮演訂閱者的角色。此視窗上面只有一個 Label,用來顯示目前的系統時間。如下圖:


ListerForm 裡面會提供一個方法叫做 ShowTime(),作為訂閱按鈕事件時的處理程式。此外, 為了凸顯記憶體洩漏的問題,我在 ListenerForm 裡面配置了 5MB 的記憶體。程式碼如下:

public partial class ListenerForm : Form
{
    private byte[] buf = new byte[5 * 1024 * 1024];

    public ListenerForm()
    {
        InitializeComponent();
    }

    public void ShowTime(object sender, EventArgs e)
    {
        label1.Text = DateTime.Now.ToString();
    }
}

當主視窗 Form1 的 「Register event」按鈕(btnRegisterEvent)按下時,就會建立並顯示一個新的 ListenerForm 視窗,同時為此視窗物件訂閱 Form1 的 btnTest 按鈕的 Click 事件:

private void btnRegisterEvent_Click(object sender, EventArgs e)
{
    ListenerForm aForm = new ListenerForm();
    btnTest.Click += aForm.ShowTime;
    aForm.Show();
    ShowMemoryUsed();
}

這裡是用 += 運算子來為新建立的 ListenerForm 訂閱 btnTest 的 Click 事件。所以當「Test」按鈕按下時,按鈕就會去呼叫該視窗物件的 ShowTime 方法。最後一行呼叫的 ShowMemoryUsed() 方法只是單純秀出此應用程式目前使用了多少記憶體。程式碼如下:

private void ShowMemoryUsed()
{
    lblMemUsed.Text = (GC.GetTotalMemory(false) / (1024*1024)).ToString() + "MB";
}

當「GC.Collect()」按鈕(btnGC)按下時,只是令 CLR 回收記憶體,並更新顯示目前使用的記憶體大小:

private void btnGC_Click(object sender, EventArgs e)
{
    GC.Collect();
    ShowMemoryUsed();
}

範例程式到此寫完,執行起來測試看看:


我連續按了三次「Register event」按鈕,所以產生了三個 ListenerForm。如果你有實際動手做這個範例,就可以看到 Form1 下方的記憶體使用量的變化--每當一個新的 ListenerForm 產生時,記憶體用量就會增加 5MB。這是因為我們在 ListenerForm 裡面故意配置了 5MB 大小的 byte 陣列的緣故。三個視窗自然就是 15MB。

接著按一下「Test」按鈕,你會看到三個 ListenerForm 上面的 label1 都會顯示目前的系統時間:



這個動作只是要確認事件觸發的機制沒有問題。

好,接著把其中一個 ListenerForm 關閉(一個訂閱者消失了),然後點 Form1 上面的「GC.Collect()」按鈕來強迫 CLR 回收記憶體,看看會發生什麼事。

記憶體用量有減少嗎?並沒有。下圖中,我關了兩個 ListenerForm,然後強迫 CLR 回收記憶體,結果也是一樣:


原因就如前面所說的,.NET 事件訂閱的機制,在「事件來源比訂閱者的壽命還要長」的情況下,會造成記憶體洩漏的問題。

我們總希望資源能夠充分利用,而且沒用的物件要盡快釋放,以免不斷增加記憶體的負載,或呼叫了不必要的事件處理常式而引發其他問題。因此,我們有必要了解此問題的成因和解決方法。

這裡示範兩種解決方法。一種是自己利用 WeakReference 類別寫個 event wrapper,另一種是使用 WPF 4.5 新增加的泛型 WeakEventManager(僅適用於 WPF 應用程式)。

在說明解法之前,先簡單提一下弱參考(weak reference),因為這兩種解決方法基本上都是利用弱參考來解決記憶體回收的問題。
2012-09-28 更新:如果你的專案受限於某些原因而難以採用 WeakReference 和 WeakEventManager 的解法,還有一個簡易的方法是在訂閱者(此例的 ListenerForm)的 Dispose() 方法中移除所有先前訂閱的事件。這樣也可以避免前面提到的 memory leak 狀況。

Weak Reference

一般情況下,CLR 在進行資源回收時,只要還有被(GC roots,例如靜態變數、區域變數)參考到的物件(及其相連參考的物件),CLR 就不會回收它們--因為這表示還有「人」要用這些物件。

.NET Framework 從 1.0 開始便提供了 WeakReference 類別。你可以建立一個 WeakReference 類別的 instance 來指向另一個你希望 CLR 盡快回收的物件。例如:

WeakReference weakRef = new WeakReference(targetObject);

當 CLR 在回收資源時,如果發現某個物件只有被 WeakReference 物件所參考--亦即它是個弱參考物件--那麼 CLR 就會將此物件回收(不是將 WeakReference 物件回收,而是它所指向的目標物件)。

應用程式可以透過 WeakReference 物件的 Target 屬性來取得它所指向的目標物件。一旦目標物件被回收,此 Target 屬性就會變成 null。應用程式可利用此屬性來判斷物件是否已被回收,並進行相應的適當處理。

Richter 在《CLR via C# 3rd Edition》中特別提醒:不要利用 WeakReference 的這項特性來實作應用程式的快取機制。比如說,判斷 Target 屬性若為 null 就再從資料庫載入快取資料。這反而會造成應用程式的效能問題。原因在於,CLR 並不是在記憶體爆滿或接近爆滿時才回收記憶體;回收動作是只要 generation 0 爆滿的時候就會發生,這意味著幾乎每當應用程式配置 256KB 記憶體的時候就會發生。如果用 WeakReference 來實作快取,你的快取資料會很頻繁的被丟棄、載入、丟棄、載入....。

解法一:手工打造 event wrapper

直接修改先前的範例,先加入一個類別:

public sealed class ButtonEventWrapper
{
    private Button _eventSource;
    WeakReference _weakRefToListener;

    public ButtonEventWrapper(Button eventSrc, ListenerForm listenerForm)
    {
        _eventSource = eventSrc;
        _weakRefToListener = new WeakReference(listenerForm);
        _eventSource.Click += ButtonClicked;
    }

    void ButtonClicked(object sender, EventArgs e)
    {
        ListenerForm listenerForm = (ListenerForm)_weakRefToListener.Target;
        if (listenerForm != null)
        {
            listenerForm.ShowTime(sender, e);
        }
        else
        {
            Unregister();
        }
    }

    public void Unregister()
    {
        _eventSource.Click -= ButtonClicked;
    }
}

為了沿用前面的範例,並簡化程式碼,這裡的 ButtonEventWrapper 是專門為 Button 的 Click 事件所設計,弱參考的目標物件的型別也是限定使用先前範例中的 ListenerForm。所以此類別是無法直接用在其他場合的。

你可以看到,這個 event wrapper 類別就像是個事件訂閱的仲介,它的建構函式需要傳入兩個參數,一個是事件來源物件,一個是訂閱者物件。ButtonEventWrapper 就在建構函式裡面完成 WeakRefernce 物件的建立,以及事件訂閱的動作。這裡的事件來源是個 Button,其 Click 事件的處理常式指向 ButtonClicked 方法。

在 ButtonClicked 方法中,首先會透過弱參考的 Target 屬性來取得目標物件。若目標物件為 null,表示 CLR 已經回收該物件,此時就進行註銷事件的動作(因為處理事件的物件根本不存在了,所以當事件發生時,當然就不用再通知我了)。若目標物件不是 null,就再轉而呼叫目標物件的事件處理常式(ShowTime 方法)。

在先前的範例程式專案中加入此 ButtonEventWrapper 類別之後,其餘的部分幾乎不變,只要稍微修改一下「Register event」按鈕按下的事件處理常式:

private void btnRegisterEvent_Click(object sender, EventArgs e)
{
    ListenerForm aForm = new ListenerForm();
    ButtonEventWrapper ew = new ButtonEventWrapper(btnTest, aForm);
    aForm.Show();
    ShowMemoryUsed();
}

差別就只是事件訂閱的動作,由原先的 += 寫法改成用我們自己寫的 ButtonEventWrapper 代勞了。

OK! 執行看看,結果的確如我們的預期,如下圖所示。


Code Project 網站上的文章 Weak Events in C# 裡面說,這種方法有個缺點:如果事件從未觸發過,就還是一樣會 memory leak。但我試的結果是不會;若不點擊「Test」按鈕,直接把新開的  ListenerForm 視窗關閉,再去按「GC.Collect()」按鈕,記憶體是有回收的。

解法二:使用 WPF 4.5 的 WeakEventManager<TEventSource, TEventArgs>

照前面的 Windows Forms 範例程式依樣畫葫蘆建立一個類似的 WPF 應用程式專案,命名為 WeakEventDemoWpf。程式邏輯和寫法幾乎一樣,主要的差別只在「Register event」按鈕的事件處理常式。如下:

private void btnRegisterEvent_Click(object sender, RoutedEventArgs e)
{
    ListenerWindow aWindow = new ListenerWindow();

    //btnTest.Click += aWindow.ShowTime;
    //上一行用來訂閱按鈕事件的程式碼改成底下兩行:
    EventHandler<RoutedEventArgs> handler = new EventHandler<RoutedEventArgs>(aWindow.ShowTime);
    WeakEventManager<Button, RoutedEventArgs>.AddHandler(btnTest, "Click", handler);

    aWindow.Show();
    ShowMemoryUsed();
}

泛型版本的 WeakEventManager 比先前範例中的那個 ButtonEventWrapper 來得通用多了。實際應用時也很簡單,不需要寫太多複雜的 code。

此 WPF 範例的執行結果跟前一張圖片相同,就不再貼圖了。

如果你的專案只能用比較舊的 WPF 版本,沒辦法使用 WPF 4.5 的話,事情就複雜多了--除了要寫一個類別繼承自 WeakEventManager 來處理事件訂閱的動作,還得為你的訂閱者(event listener)類別實作 IWeakEventListener 介面。這部分就不細說,有興趣的話可參考 .NET 4.0 版的 Weak Event Patterns

即使你覺得泛型有損程式效率,WPF 4.5 還是可以讓你自己打造 WeakEventManager 的衍生類別,但是又不用像先前版本那樣非得實作 IWeakEventListener 介面不可。這是因為 WPF 4.5 的 WeakEventManager 類別增加了 ProtectedAddHandler 和 ProtectedRemoveHandler 方法的緣故。詳情請看 .NET 4.5 版的 Weak Event Patterns

小結

「解法一」只是單純示範 WeakReference 類別的用法,實際上通常不會這樣寫,不然我們可能得寫好多個 event wrapper 類別。一種作法是改寫成泛型的版本,例如這篇文章提供的解法:WeakReference Event Handlers。或者,也可以參考 Jeffrey Richter 的 Weak Event Handler

「解法二」的部分,我偷了個懶,從 .NET 1.x 的 WeakReference 直接跳到 WPF 4.5 的 WeakEventManager<TEventSource, TEventArgs>,因為這個類別實在太方便了,只要一兩行程式碼就搞定。雖然在 WPF 4.5 之前也有非泛型的 WeakEventManager 類別可以用,但是得寫不少程式碼--包括一個自訂的 WeakEventManager 類別以及為事件訂閱者類別實作 IWeakEventListener 介面。

延伸閱讀

下載範例程式:WeakEventDemo.7z

Post Comments

技術提供:Blogger.