使用記憶體剖析工具來找出應用程式的記憶體洩漏問題

先前有一篇筆記說明了 .NET 事件訂閱寫法如何導致 Windows 應用程式很容易出現記憶體洩漏的問題。這次要介紹 .NET Memory Profiler 的一點粗淺用法,看看這項工具對於找出記憶體洩漏問題能夠提供什麼幫助。Visual Studio 2012 內建的剖析功能也會順便提一點。(圖多)

先前文章的範例程式裡面,是利用 GC 類別的 GetTotalMemory() 方法來查看應用程式目前的記憶體用量。可是如果程式規模太龐大,或因為某些因素而無法在程式中到處安插這類顯示記憶體用量的 code 呢?我試用了一下 .NET Memory Profiler,發現它有個記憶體用量變化圖還蠻清楚的。在懷疑應用程式有記憶體洩漏問題,卻又毫無頭緒的情況下,這個工具可以提供一些線索,協助縮小問題範圍。

我的 .NET Memmory Profiler 是七天試用版。安裝完成後,可以透過開始>程式集> .NET Memory Profiler 4.5 來單獨執行其主程式。或者,也可以在 Visual Studio 的主選單裡面找到相關功能,如下圖:


如果 Visual Studio 已經有載入專案,只要從主選單點選 PROFILER>Start Memory Profiler,就會直接執行目前 solution 中的預設專案,等程式結束,就會產出記憶體剖析的報告。如果想要剖析的應用程式不在目前的 solution 中,可以用 PROFILER>Profile Application。

我把先前文章中的範例程式稍微修改了一下,在新開啟的子視窗(ListenerForm)中加入一個核取方塊--若有打勾,則當此視窗摧毀時,就會自動移除原先訂閱的事件,以避免產生記憶體洩漏的問題。如下圖:



取消訂閱的程式碼是寫在 ListenerForm 的 Dispose() 方法中。底下是完整程式碼:

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

    public ListenerForm()
    {
        InitializeComponent();
    }

    /// 
    /// Clean up any resources being used.
    /// 
    /// true if managed resources should be disposed; otherwise, false.
    protected override void Dispose(bool disposing)
    {
        if (chkAutoUnsubscribe.Checked)
        {
            UnregisterHandler();
        }            

        if (disposing && (components != null))
        {
            components.Dispose();
        }
        base.Dispose(disposing);
    }

    public void RegisterHandler(Button btn)
    {
        eventSourceButton = btn;
        btn.Click += this.ShowTime;
    }

    private void UnregisterHandler() 
    {
        if (eventSourceButton != null)
        {
            eventSourceButton.Click -= this.ShowTime;
            eventSourceButton = null;
        }
    }

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

利用 .NET Memory Profiler 來執行此範例程式,並依先前示範的步驟,開啟三個 ListenerForm,如下圖:



這次先不要將 "Unsubscribe event when..." 打勾,且每關掉一個 ListenerForm 就點一下 GC.Collect() 方法,最後再將主視窗關閉。

應用程式結束後,.NET Memory Profiler 會顯示其剖析結果。剖析結果分成多個頁籤,其中的 Real time 頁籤有個依照時間順序來呈現的記憶體用量變化圖:


從這張圖可以發現,記憶體呈階梯狀成長,而且一直沒有回收(即使每次關閉 ListenerForm 之後都有呼叫 GC.Collect() 方法),直到應用程式結束。其中的三個階梯正好就是開啟三個 ListenerForm 時的記憶體用量。注意圖中右邊區塊的「Total bytes」必須打勾,左方才會顯示記憶體用量變化。

同樣的測試步驟,這次將 "Unsubscribe event when..." 打勾,然後每關掉一個 ListenerForm 就點一下 GC.Collect() 方法,最後再將主視窗關閉。

這次的剖析結果如下圖所示:


兩相比對之下,差異應該是很明顯了。每關閉一個 ListenerForm,然後在程式中呼叫 GC.Collect() 時,就可以成功回收先前已關閉的 ListenerForm 所占用的記憶體。

可是,如果程式中沒有呼叫 GC.Collect() 呢?

這次我不再強迫 GC 回收記憶體,想觀察 GC 什麼時候會執行回收動作。測試方法是每開啟一個 ListenerForm,就將 "Unsubscribe event when..." 打勾,然後將它關閉。接著再開啟、打勾、關閉,如此反覆執行幾十次。同時,我將 ListenerForm 裡面配置的記憶體從原先的 5MB 增加至 20MB,期望盡早觸發 GC。

測試結果看起來有點糟糕:


其中只有一次,也就是在 60 秒的地方 GC 才有執行回收,而且只回收了一小部分的記憶體,然後又持續上升。在大約 165 秒的地方記憶體用量陡降,是因為我等得不耐煩了,直接按主視窗的 "GC.Collect()" 按鈕來手動觸發資源回收。

好吧!把 ListenerForm 配置的記憶體加大為 100MB,再測試一次看看:



這次的測試結果,.NET Memory Profiler 顯示記憶體用量大約在 500MB 的地方有回收一次,但也僅回收少部分記憶體而已。300MB 處之所以維持好長一條水平線,看起來好像是因為新開啟一個 ListenerForm 時立刻回收上一次的記憶體而彼此打消,但其實不是;那只是因為我在操作時停頓了一分多鐘去做別的事罷了。

最後,當此範例程式的記憶體用量達到約 2000MB 時,GC 出現了一次回收,而且回收了先前配置的大部分記憶體,然後緊接著又繼續回收更多記憶體。

這樣行了,GC 真的有在做事(廢話),實驗到此打住。

但還是要提醒一下:如果沒有用先前文章裡面介紹的 Weak Event Handler 寫法,又沒有像本文中範例那樣取消事件訂閱的話,你的 Windows Forms 或 WPF 應用程式仍然很可能會有 memory leak 問題。也就是說,就算應用程式的記憶體用量衝到 5GB、就算這當中 CLR 有啟動資源回收程序,也沒有辦法回收那些記憶體。結果就像下圖:


你不會希望你的應用程式變成像這樣不斷吃掉系統資源的大怪獸吧?

Visual Studio 2012 內建的剖析工具

我另外也試了一下 Visual Studio 2012 內建的剖析功能(Visual Studio 2010 也有),從主選單點 ANALYZE>Profiler>Start profiling,就會開始執行 solution 中的預設專案。參考下圖:



等到程式結束, Visual Studio 會顯示剖析結果:


從這份剖析報告中可以發現:
  1. 配置了最多記憶體的地方,是 ListenerForm 的建構函式,即圖中由上至下數來第一個紅色框。在此項目上點右鍵,會出現快顯功能表,其中有 View Source 選項可以讓你直接跳到該處原始碼。
  2. 配置了最多記憶體的型別,是 byte[] 陣列,即圖中由上至下數來第二個紅色框。在此項目上點右鍵,也會出現快顯功能表,其中的 Show in Allocation View 功能可以查看該物件的記憶體配置過程(程式的執行路徑)。參考下圖:


圖中上方的 Current View 下拉清單提供了多種檢視選項,這裡就不細說,有興趣的話可以參考 MSDN 文件:Profiling Tools Report Overview

小結

實際的狀況肯定要比這裡的範例程式要複雜得多,通常也得花更多時間才能找到問題所在。這裡僅提供一點入門技巧,使用手邊的工具來協助縮小範圍、定位記憶體洩漏之問題所在。方法雖然還很粗淺,但多少能提供一些有用的線索,便於著手進行調查。 

2 則留言:

  1. 看了您那麼完整的測試,下GC.Collect()可以較快速的回收;而不下GC.Collect()的話,會在不確定的時間做回收。

    那麼下GC.Collect()的瞬間,會不會造成CPU使用率提高的情形呢?

    回覆刪除
  2. 其實這邊的範例程式之所以使用 GC.Collect(),只是單純用來顯現記憶體洩漏的情形。一般而言,最好不要在應用程式中任意呼叫 GC.Collect(),而應該讓 CLR 自己去決定何時該回收資源,除非你真的非常確定在某個時刻最好要呼叫 GC.Collect() ,否則整體系統效能會出現嚴重問題,這種情形才去呼叫它。如果在程式中到處埋 GC.Collect() 呼叫,而且呼叫得很頻繁的話,我想是有可能影響系統整體效能的。

    回覆刪除

技術提供:Blogger.
回頂端⬆️