記一次 .NET 某外貿Web站 記憶體洩漏分析

funnyok發表於2021-09-09

一:背景

1. 講故事

上週四有位朋友加wx諮詢他的程式記憶體存在一定程度的洩漏,並且無法被GC回收,最終機器記憶體耗盡,很尷尬。

溝通下來,這位朋友能力還是很不錯的,也已經做了初步的dump分析,發現了託管堆上有 10w+ 的 byte[] 陣列,並佔用了大概 1.1G 的記憶體,在抽取幾個 byte[] 的 gcroot 後發現沒有引用,接下來就排查不下去了,雖然知道問題可能在 byte[],但苦於找不到證據。???

那既然這麼信任的找到我,我得要做一個相對全面的輸出報告,不能辜負大家的信任哈,還是老規矩,上 windbg 說話。

二: windbg 分析

1. 排查洩漏源

看過我文章的老讀者應該知道,排查這種記憶體洩露的問題,首先要二分法找出到底是託管還是非託管出的問題,方便後續採取相應的應對措施。

接下來使用 !address -summary 看一下程式的提交記憶體。


||2:2:080> !address -summary

--- Type Summary (for busy) ------ RgnCount ----------- Total Size -------- %ofBusy %ofTotal
MEM_PRIVATE                             573        1`5c191000 (   5.439 GB)  95.19%    0.00%
MEM_IMAGE                              1115        0`0becf000 ( 190.809 MB)   3.26%    0.00%
MEM_MAPPED                               44        0`05a62000 (  90.383 MB)   1.54%    0.00%

--- State Summary ---------------- RgnCount ----------- Total Size -------- %ofBusy %ofTotal
MEM_FREE                                201     7ffe`9252e000 ( 127.994 TB)          100.00%
MEM_COMMIT                             1477        0`d439f000 (   3.316 GB)  58.04%    0.00%
MEM_RESERVE                             255        0`99723000 (   2.398 GB)  41.96%    0.00%

從卦象的 MEM_COMMIT 指標看:當前只有 3.3G 的記憶體佔用,說實話,我一般都建議 5G+ 是做記憶體洩漏分析的最低門檻,畢竟記憶體越大,越容易分析,接下來看一下託管堆的記憶體佔用。


||2:2:080> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x00000002b37c0c48
generation 1 starts at 0x00000002b3781000
generation 2 starts at 0x0000000000cc1000

------------------------------
GC Heap Size:            Size: 0xbd322bb0 (3174181808) bytes.

可以看到,當前託管堆佔用 3174181808/1024/1024/1024= 2.95G,哈哈,看到這個數,心裡一陣狂喜,託管堆上的問題,對我來說差不多就十拿九穩了。。。畢竟還沒有失手過,接下來趕緊排查一下託管堆,看下是哪裡出的問題。

2. 檢視託管堆

要想檢視託管堆,可以使用 !dumpheap -stat 命令,下面我把 Top10 Size 給顯示出來。


||2:2:080> !dumpheap -stat
Statistics:
              MT    Count    TotalSize Class Name
00007ffd7e130ab8   116201     13014512 Newtonsoft.Json.Linq.JProperty
00007ffdd775e560    66176     16411648 System.Data.SqlClient._SqlMetaData
00007ffddbcc9da8    68808     17814644 System.Int32[]
00007ffddbcaf788    14140     21568488 System.String[]
00007ffddac72958    50256     22916736 System.Net.Sockets.SocketAsyncEventArgs
00007ffd7deb64b0      369     62115984 System.Collections.Generic.Dictionary`2+Entry[[System.Reflection.ICustomAttributeProvider, mscorlib],[System.Type, mscorlib]][]
00007ffddbcc8610     8348    298313756 System.Char[]
00007ffddbcc74c0  1799807    489361500 System.String
000000000022e250   312151    855949918      Free
00007ffddbccc768   109156   1135674368 System.Byte[]

從上面的輸出中可以看到,當前狀元是 Byte[],榜眼是 Free,探花是 String,這裡還是有一些經驗之談的,深究 Byte[]String 這種基礎型別,投入產出比是不高的,畢竟大量的複雜型別,它的內部結構都含有 String 和 Byte[],比如我相信 MemoryStream 內部肯定有 Byte[],對吧,所以暫且放下狀元和探花,看一下榜眼或者其他的複雜型別。

如果你的眼睛犀利,你會發現 Free 的個數有 31W+,你肯定想問這是什麼意思?對,這表明當前託管堆上有 31W+ 的空閒塊,它的專業術語叫 碎片化,所以這條資訊透露出了當前託管堆有相對嚴重的碎片化現象,接下來的問題就是為什麼會這樣? 大多數情況出現這種碎片化的原因在於託管堆上有很多的 pinned 物件,這種物件可以阻止 GC 在回收時對它的移動,長此以往就會造成託管堆的支離破碎,所以找出這種現象對解決洩漏問題有很大的幫助。

補充一下,這裡可以藉助 dotmemory ,紅色表示 pinned 物件,肉眼可見的大量的紅色間隔分佈,最後的碎片率為 85% 。

接下來的問題是如何找到這些 pinned 物件,其實在 CLR 中有一張 GCHandles 表,裡面就記錄了這些玩意。

3. 檢視 GCHandles

要想找到所有的 pinned 物件,可以使用 !gchandles -stat 命令,簡化輸出如下:


||2:2:080> !gchandles -stat
Statistics:
              MT    Count    TotalSize Class Name
00007ffddbcc88a0      278        26688 System.Threading.Thread
00007ffddbcb47a8     1309       209440 System.RuntimeType+RuntimeTypeCache
00007ffddbcc7b38      100       348384 System.Object[]
00007ffddbc94b60     9359       673848 System.Reflection.Emit.DynamicResolver
00007ffddb5b7b98    25369      2841328 System.Threading.OverlappedData
Total 36566 objects

Handles:
    Strong Handles:       174
    Pinned Handles:       15
    Async Pinned Handles: 25369
    Ref Count Handles:    1
    Weak Long Handles:    10681
    Weak Short Handles:   326

從卦象中可以看出,當前有一欄為: Async Pinned Handles: 25369 ,這表示當前有 2.5w 的非同步操作過程中被pinned住的物件,這個指標就相當不正常了,而且可以看出與 2.5W 的System.Threading.OverlappedData 遙相呼應,有了這個思路,可以回過頭來看一下託管堆,是否有相對應的 2.5w 個類似封裝過非同步操作的複雜型別物件? 這裡我再把 top10 Size 的託管堆列出來。


||2:2:080> !dumpheap -stat
Statistics:
              MT    Count    TotalSize Class Name
00007ffd7e130ab8   116201     13014512 Newtonsoft.Json.Linq.JProperty
00007ffdd775e560    66176     16411648 System.Data.SqlClient._SqlMetaData
00007ffddbcc9da8    68808     17814644 System.Int32[]
00007ffddbcaf788    14140     21568488 System.String[]
00007ffddac72958    50256     22916736 System.Net.Sockets.SocketAsyncEventArgs
00007ffd7deb64b0      369     62115984 System.Collections.Generic.Dictionary`2+Entry[[System.Reflection.ICustomAttributeProvider, mscorlib],[System.Type, mscorlib]][]
00007ffddbcc8610     8348    298313756 System.Char[]
00007ffddbcc74c0  1799807    489361500 System.String
000000000022e250   312151    855949918      Free
00007ffddbccc768   109156   1135674368 System.Byte[]

有了這種先入為主的思想,我想你肯定發現了託管堆上的這個 50256 的 System.Net.Sockets.SocketAsyncEventArgs,看樣子這回洩漏和 Socket 脫不了干係了,接下來可以查下這些 SocketAsyncEventArgs 到底被誰引用著?

4. 檢視 SocketAsyncEventArgs 引用根

要想檢視引用根,先從 SocketAsyncEventArgs 中導幾個 address 出來。


||2:2:080> !dumpheap -mt 00007ffddac72958 0 0000000001000000
         Address               MT     Size
0000000000cc9dc0 00007ffddac72958      456     
0000000000ccc0d8 00007ffddac72958      456     
0000000000ccc358 00007ffddac72958      456     
0000000000cce670 00007ffddac72958      456     
0000000000cce8f0 00007ffddac72958      456     
0000000000cd0c08 00007ffddac72958      456     
0000000000cd0e88 00007ffddac72958      456     
0000000000cd31a0 00007ffddac72958      456     
0000000000cd3420 00007ffddac72958      456     
0000000000cd5738 00007ffddac72958      456     
0000000000cd59b8 00007ffddac72958      456     
0000000000cd7cd0 00007ffddac72958      456     

然後檢視第一個和第二個address的引用根。


||2:2:080> !gcroot 0000000000cc9dc0
Thread 86e4:
    0000000018ecec20 00007ffd7dff06b4 xxxHttpServer.DaemonThread`2[[System.__Canon, mscorlib],[System.__Canon, mscorlib]].DaemonThreadStart()
        rbp+10: 0000000018ececb0
            ->  000000000102e8c8 xxxHttpServer.DaemonThread`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]]
            ->  00000000010313a8 xxxHttpServer.xxxHttpRequestServer`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]]
            ->  000000000105b330 xxxHttpServer.HttpSocketTokenPool`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]]
            ->  000000000105b348 System.Collections.Generic.Stack`1[[xxxHttpServer.HttpSocketToken`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]], xxxHttpServer]]
            ->  0000000010d36178 xxxHttpServer.HttpSocketToken`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]][]
            ->  0000000008c93588 xxxHttpServer.HttpSocketToken`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]]
            ->  0000000000cc9dc0 System.Net.Sockets.SocketAsyncEventArgs
||2:2:080> !gcroot 0000000000ccc0d8
Thread 86e4:
    0000000018ecec20 00007ffd7dff06b4 xxxHttpServer.DaemonThread`2[[System.__Canon, mscorlib],[System.__Canon, mscorlib]].DaemonThreadStart()
        rbp+10: 0000000018ececb0
            ->  000000000102e8c8 xxxHttpServer.DaemonThread`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]]
            ->  00000000010313a8 xxxHttpServer.xxxHttpRequestServer`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]]
            ->  000000000105b330 xxxHttpServer.HttpSocketTokenPool`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]]
            ->  000000000105b348 System.Collections.Generic.Stack`1[[xxxHttpServer.HttpSocketToken`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]], xxxHttpServer]]
            ->  0000000010d36178 xxxHttpServer.HttpSocketToken`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]][]
            ->  0000000000ccc080 xxxHttpServer.HttpSocketToken`2[[xxx.xxx, xxx],[xxx.RequestInfo, xxx]]
            ->  0000000000ccc0d8 System.Net.Sockets.SocketAsyncEventArgs

從輸出資訊看,貌似程式自己搭了一個 HttpServer,還搞了一個 HttpSocketTokenPool 池,好奇心來了,把這個類匯出來看看怎麼寫的?

5. 尋找問題程式碼

還是老辦法,使用 !savemodule 匯出問題程式碼,然後使用 ILSpy 進行反編譯。

說實話,這個 pool 封裝的挺簡陋的,既然 SocketAsyncEventArgs 有 5W+,我猜測這個 m_pool 池中估計也得好幾萬,為了驗證思路,可以用 windbg 把它挖出來。

從圖中的size可以看出,這個 pool 有大概 2.5w 的 HttpSocket,這就說明這個所謂的 Socket Pool 其實並沒有封裝好。

三:總結

想自己封裝一個Pool,得要實現一些複雜的邏輯,而不能僅僅是一個 PUSH 和 POP 就完事了。。。 所以優化方向也很明確,想辦法控制住這個 Pool,實現 Pool 該實現的效果。

更多高質量乾貨:參見我的 GitHub: dotnetfly

圖片名稱

相關文章