一:背景
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
