一:背景

1. 講故事

事件泄露導致的內存暴漲,説實話我以前是不敢相信的,因為我認為沒人會寫這樣的代碼,但現實往往都會打臉,還是太年輕了,今年年中的時候還真給遇到了,也算是無語啦,這一篇我們就來聊一聊如何通過 DotMemory 來一探究竟。

二:內存暴漲分析

1. 問題代碼

為了方便講述,先來一段測試代碼,代碼非常簡單,也就調用 1kw 次 SomeOperation 方法,調用完之後使用 GC.Collect() 強行回收,參考代碼如下:

internal class Program
    {
        static void Main(string[] args)
        {
            WiFiManager wifiManager = new WiFiManager();

            for (int i = 0; i < 10000000; i++)
            {
                SomeOperation(wifiManager);
            }

            GC.Collect();
            Console.WriteLine("全部執行完成,GC也觸發完畢!!!");
            Console.ReadKey();
        }

        static void SomeOperation(WiFiManager wifiManager)
        {
            var room = new Room(wifiManager);

            var wifiStatus = room.GetWifiStatus();
        }
    }

    public class WiFiManager
    {
        public event EventHandler<WifiEventArgs> WiFiSignalChanged;
    }

    public class Room
    {
        public Room(WiFiManager wiFiManager)
        {
            wiFiManager.WiFiSignalChanged += OnWiFiChanged;
        }

        private void OnWiFiChanged(object sender, WifiEventArgs e)
        {
        }

        public string GetWifiStatus()
        {
            return "wifi 狀態良好...";
        }
    }

    public class WifiEventArgs : EventArgs { }

接下來使用 DotMemory 的默認配置(採樣模式)跟蹤程序,會發現即使觸發了 FullGC ,內存還維持1.15G左右,很明顯存在內存泄露,截圖如下:

DotMemory系列:2. 事件泄露引發的內存暴漲分析_sed

接下來就是找原因了,為什麼會這樣?

2. 問題分析

要想找原因,必須用 Get Snapshot 採一個快照下來,採集完成之後打開 Snapshot #1 快照,可以看到如下的 檢測台。

DotMemory系列:2. 事件泄露引發的內存暴漲分析_問題分析_02

從檢測台上可以看到如下三點信息:

  1. Largest Size 區域

前面的文章跟大家説過,這個區域是每個Type的淺層大小,可以看到 EventHandler<WifiEventArgs>Room 聯合吃了 940M 左右,和內存總量 1.15G 比較接近了,説明這兩塊是禍根,先重點備註一下。

  1. Largest Retained Size 區域

這個區域是以root根為出發點,幷包含所有孩子節點的size,從圖中可以看到 WifiManager 就屬於其中的一個 root 根,有些人可能好奇它是什麼 root 根? 可以單擊 item 選擇 Key Retention Path 選項,截圖如下:

DotMemory系列:2. 事件泄露引發的內存暴漲分析_sed_03

上面的 Regular local variable 表示局部變量,也就是説這個變量是棧引用根。

還有一點就是 EventHandler<WifiEventArgs> + Room 剛好接近 WifiManager 的總大小,説明前者應該都是它的孩子節點。

  1. Event handlers leak

從英文解釋上就能知道,這個列表中的類實例是被訂閲到別人的事件上,並且還沒有 解訂閲,那這樣的對象有多少呢? 從列表中就可以看到有 1000w 的 Room,這個在數據上是一個異常信號。雖然 Retained Size=228.88M,但這個只算了淺層大小,深層大小不得而知。

有了上面三點信息之後,我們就從 Room 這個點出來,觀察它的 root 鏈,單擊 Room 類型之後再次選擇 Similar Retention 選項,截圖如下:

DotMemory系列:2. 事件泄露引發的內存暴漲分析_System_04

還有一點如果你想可視化觀察,可以點擊 檢測台 上的 Dominators 選項卡觀察 旭日圖,這也是 DotMemory 快速可視化的一個亮點,截圖如下:

DotMemory系列:2. 事件泄露引發的內存暴漲分析_System_05

如果想要觀察 WifiManager 類實例的內容也比較簡單,這個也是 DotMemory 非常好的一個亮點,比如下圖的 _invocationList[],這也是 多播調用 的底層核心,截圖如下:

DotMemory系列:2. 事件泄露引發的內存暴漲分析_System_06

到這裏就已經豁然開朗了,接下來就是去看 Room 是怎麼掛接到 WiFiManager.WiFiSignalChanged 上,翻看源碼很快就找到了問題,參考如下:

public Room(WiFiManager wiFiManager)
        {
            wiFiManager.WiFiSignalChanged += OnWiFiChanged;
        }

可能有些人比較懵逼,我明明是把 OnWiFiChanged 方法注進去的,為什麼當前的 this (room) 對象也進去了呢?

3. 為什麼會註冊 this

要想找到這個答案,直接觀察彙編即可,參考如下:

// wiFiManager.WiFiSignalChanged += OnWiFiChanged;
00007FFAAD7B16F2  mov         rcx,7FFAADAE8BF0h  
00007FFAAD7B16FC  call        CORINFO_HELP_NEWSFAST (07FFB0D30FA50h)  
00007FFAAD7B1701  mov         qword ptr [rbp+28h],rax  
00007FFAAD7B1705  mov         rcx,qword ptr [rbp+28h]  
00007FFAAD7B1709  mov         rdx,qword ptr [rbp+50h]  
00007FFAAD7B170D  mov         r8,offset Example_9_9_2.Room.OnWiFiChanged(System.Object, Example_9_9_2.WifiEventArgs) (07FFAADB022B0h)  
00007FFAAD7B1717  call        qword ptr [Pointer to stub for: System.MulticastDelegate.CtorClosed(System.Object, IntPtr) (07FFAAD794210h)]  
00007FFAAD7B171D  mov         rcx,qword ptr [rbp+58h]  
00007FFAAD7B1721  mov         rdx,qword ptr [rbp+28h]  
00007FFAAD7B1725  cmp         dword ptr [rcx],ecx  
00007FFAAD7B1727  call        Example_9_9_2.WiFiManager.add_WiFiSignalChanged(System.EventHandler`1<Example_9_9_2.WifiEventArgs>) (07FFAADB01A40h)  
00007FFAAD7B172C  nop

從卦中看上面的 rdx,qword ptr [rbp+50h] 就是我們的 Room 實例,然後通過 OnWiFiChanged 方法傳遞下去,即下面的 target 字段。

private void CtorClosed(object target, nint methodPtr)
{
	if (target == null)
	{
		ThrowNullThisInDelegateToInstance();
	}
	_target = target;
	_methodPtr = methodPtr;
}

三:總結

是不是挺有意思的, DotMemory 這些界面真的是太有愛了。

DotMemory系列:2. 事件泄露引發的內存暴漲分析_問題分析_07