記憶體洩漏問題分析之非託管資源洩漏

hkant發表於2020-12-31

在某次巡查生產環境監控資料的時候,發現某個程式的記憶體佔用偏高(大於500M)。對於這個程式的作用需要簡單交代一下,這個程式是用做通訊服務程式,通過Socket與IOT裝置進行通訊。因為了解這個程式的使用場景,所以對於該程式的記憶體佔用偏高產生了懷疑。該程式服務的裝置並不多,但是佔用了幾百兆的記憶體,很明顯是存在問題的。

對於該程式隨後進行的分析也驗證了這個想法,由於這個問題相對來說比較典型,因此比較具有分享價值,通過對於該案例的分享希望可以讓更多人瞭解和掌握記憶體洩漏問題分析的一般方法。

記憶體洩漏問題分析的基本步驟

記憶體洩漏問題的分析可以分為三大部分:

  1. 確認問題
  2. 定位問題
  3. 解決問題

確認問題即確認記憶體確實存在洩漏問題,這個步驟不是光看看就可以,還需要儘量的保留問題發生的現場。不管是什麼樣的記憶體洩漏問題,最好能夠保留記憶體映象用於分析(dump檔案),因為記憶體洩漏問題有時候是瞬間的,如果不及時保留現場,等到有時間看的時候,可能程式已經恢復正常。儲存記憶體映象檔案的時候最好可以間隔一段時間保留多個映象檔案用於對比分析,可以更好的定位問題。

從windbg的角度分析問題

通過windbg擴充套件項sos,分析dump檔案中的控制程式碼和記憶體裡面的物件型別。sos隨著.net framework一起安裝,可以適用於大多數情況下的除錯。

首先檢查記憶體中的物件統計資訊,輸入!dumpheap -stat

0:000> !dumpheap -stat 
Statistics:
      MT    Count    TotalSize Class Name
……
6c3ab8d4      806        38688 System.RuntimeMethodInfoStub
6c363e90     2592        39424 System.RuntimeType[]
6b68105c     2265        45300 System.Net.SafeCloseSocket+InnerSafeCloseSocket
6b680f2c     2265        45300 System.Net.SafeNativeOverlapped
6c36d120      476        45696 System.Reflection.Emit.DynamicILGenerator
08eb8b40      334        49432 Newtonsoft.Json.Serialization.JsonProperty
6b671564     2264        54336 System.Net.Sockets.OverlappedCache
6c3a1dd8     1284        87312 System.Reflection.RuntimeParameterInfo
6c3a1d90     2092        92048 System.Signature
……
6c3a17a8     7179       114864 System.Int64
00d8a37c    10717       900228 ********.NetCommunicator.SocketConnectionInfo
6b674f28    10741       988172 System.Net.Sockets.Socket
6c35da78    88000      1056000 System.Object
08eb08c0    17403      1113792 Newtonsoft.Json.Linq.JProperty
082188ac    10717      1457512 System.Collections.Concurrent.ConcurrentDictionary`2+Node[[System.String, mscorlib],[*******.RoadGate.API.Entity.MessagePacketModel, ***.RoadGate.API.Entity]][]
6c35e0e4      472     68400932 System.Char[]
6c35d6d8   126896    198551106 System.String
00af6ca0    34283    464007622      Free
6c361d04    25662   1037503288 System.Byte[]

獲取到記憶體中物件的統計資訊後重點關注堆疊中數量較多的型別,通過分析發現記憶體中有一萬多個socket物件,還有一萬多個放在ConcurrentDictionary中的業務自定義的實體類物件。由於當前分析的程式是通訊伺服器,socket的合理值很容易通過分析dump時刻的業務量得到結果(在本案例中肯定是不合理的)。

經過諮詢得知當前通訊服務的通訊物件遠遠達不到上萬客戶端的水平,因此很明顯是socket相關的物件的處理出現了問題,出現了洩漏問題。對於.net程式來說,socket相關物件屬於非託管資源,非託管資源的使用原則上必須顯式地進行釋放或關閉操作。

對於應用建立的大多數物件,可以依賴 .NET 垃圾回收器來進行記憶體管理。 但是,如果建立包含非託管資源的物件,則當你使用完非託管資源後,必須顯式釋放這些資源。 最常用的非託管資源型別是包裝作業系統資源的物件,如檔案、視窗、網路連線或資料庫連線。 雖然垃圾回收器可以跟蹤封裝非託管資源的物件的生存期,但無法瞭解如何釋出並清理這些非託管資源。

雖然已經定位到通訊服務對於socket的處理不當,但是非託管資源到底是因為未能顯示執行dispose方法導致的問題,還是說這些物件一直被引用而無法被回收?想要對於非託管資源的問題進行詳細分析,可以使用!finalizequeue命令進行分析。該命令有三個可選引數:

  • -detail:顯示需要清理的任何 SyncBlocks 的額外資訊,以及有關等待清理的任何 RuntimeCallableWrappers (RCW) 的額外資訊,這個選項也是預設值。
  • -allReady:選項顯示所有準備終止的物件,無論它們已被垃圾回收標記成這樣,還是將被下一個垃圾回收標記。 “準備終止”列表中的物件為不再為根的可終止物件。
  • -short:將輸出限制為每個物件的地址,可以跟-allReady或者-detail一起使用。

首先輸入!finalizequeue -allready檢查有多少可以回收的物件:

0:000> !finalizequeue -allready
SyncBlocks to be cleaned up: 0
Free-Threaded Interfaces to be released: 0
MTA Interfaces to be released: 0
STA Interfaces to be released: 0
----------------------------------
generation 0 has 71 finalizable objects (190ce568->190ce684)
generation 1 has 32 finalizable objects (190ce4e8->190ce568)
generation 2 has 56598 finalizable objects (19097090->190ce4e8)
Finalizable but not rooted:  976186dc 97619774 9761981c 97619844 9da920c8 9da920e0 9da9213c 9da92150 
……
Ready for finalization 0 objects (190ce684->190ce684)
Statistics for all finalizable objects that are no longer rooted:
      MT    Count    TotalSize Class Name
6c3711b8        1           16 System.Threading.Gen2GcCallback
6c36b328        2           24 System.Threading.TimerHolder
6b68c0fc        1           24 System.Net.Sockets.TcpClient
6b671fec        1           40 System.Net.Sockets.NetworkStream
6b68105c        8          160 System.Net.SafeCloseSocket+InnerSafeCloseSocket
6b6811a8        8          192 System.Net.SafeCloseSocket
6c35e2cc        4          208 System.Threading.Thread
67b788b8        4          208 System.Windows.Forms.Control+ThreadMethodEntry
00d8a37c        4          336 **************.NetCommunicator.SocketConnectionInfo
6b690e80        4          400 System.Net.Sockets.AcceptOverlappedAsyncResult
6b680f2c       20          400 System.Net.SafeNativeOverlapped
6c362a18       21          420 Microsoft.Win32.SafeHandles.SafeWaitHandle
6b671564       20          480 System.Net.Sockets.OverlappedCache
6b674f28        9          828 System.Net.Sockets.Socket
6c36207c      107         1284 System.WeakReference
6b671800       99         9900 System.Net.Sockets.OverlappedAsyncResult
Total 313 objects

從這個結果可以看到只有9個物件是沒有根引用可以直接回收的,這說明其他的一萬多個socket都是有root引用而造成記憶體無法釋放。根引用是什麼?在垃圾回收過程中起到什麼作用?

應用程式的根包含執行緒堆疊上的靜態欄位、區域性變數、CPU 暫存器、GC 控制程式碼和終結佇列。 每個根或者引用託管堆中的物件,或者設定為空。

換言之,記憶體中眾多的Socket物件就是被其他的變數引用了而無法釋放。如何進一步查詢這些物件的根引用呢?這需要藉助!gcroot指令。GCRoot 命令將檢查整個託管堆和控制程式碼表以查詢其他物件內的控制程式碼和堆疊上的控制程式碼。 然後,在每個堆疊中搜尋物件的指標,同時還搜尋終結器佇列。記憶體中有一萬多個socket的物件,不需要全部去檢查gcroot,只要看過一部分就會發現規律,在這些物件的gcroot的結果中有很多是類似的,最底層的引用關係是這樣的:

->  029197f0 *******.RoadGate.TcpCommunicator.CameraTcpCommunictor
->  029199e4 *******.NetCommunicator.SocketConnectionInfoFactory
->  029199f0 System.Collections.Concurrent.ConcurrentDictionary`2[[System.Net.Sockets.Socket, System],[*******.NetCommunicator.SocketConnectionInfo, *******.RoadGate.Communicator]]
->  5e7c58f4 System.Collections.Concurrent.ConcurrentDictionary`2+Tables[[System.Net.Sockets.Socket, System],[*******.NetCommunicator.SocketConnectionInfo, *******.RoadGate.Communicator]]
->  80f55d48 System.Collections.Concurrent.ConcurrentDictionary`2+Node[[System.Net.Sockets.Socket, System],[*******.NetCommunicator.SocketConnectionInfo, *******.RoadGate.Communicator]][]
->  5e7afc40 System.Collections.Concurrent.ConcurrentDictionary`2+Node[[System.Net.Sockets.Socket, System],[*******.NetCommunicator.SocketConnectionInfo, *******.RoadGate.Communicator]]
->  02925630 System.Net.Sockets.Socket

如何解讀這個引用關係呢?可以從下往上看,下層的物件是被上層的物件使用的。就當前這個物件來說,首先被ConcurrentDictionary的Node內部型別包裝,並且是ConcurrentDictionary內部的Node陣列中的一員,該Node陣列又被ConcurrentDictionary中的Tables內部型別再次封裝,Tables內部類的物件直屬於ConcurrentDictionary物件。該物件又是被SocketConnectionInfoFactory類物件使用。SocketConnectionInfoFactory是業務上定義的型別,如果要檢查原始碼,這個位置就是檢查的入口。

既然ConcurrentDictionary中存放了大量的該釋放而未被是否的物件,那麼這個物件有多大呢?用!objsize來檢查一下。

0:000> !objsize 029199f0
sizeof(029199f0) = 1141780680 (0x440e30c8) bytes (System.Collections.Concurrent.ConcurrentDictionary`2[[System.Net.Sockets.Socket, System],[IOT.NetCommunicator.SocketConnectionInfo, IOT.RoadGate.Communicator]])

通過windbg的統計,這一個物件存放的內容就佔用了1G+的記憶體,跟抓取dump時的監控資料比較吻合。至此,記憶體洩漏的元凶就已經水落石出。

從程式碼角度分析問題

有了上一個章節內容的基礎,再從程式碼角度分析出問題就比較容易了,程式碼中確實使用了多個ConcurrentDictionary儲存了socket物件和一些業務物件的對映關係,但是對於裝置斷線重連的情況處理並不完善,導致重連後部分ConcurrentDictionary的內容得到了更新,而部分字典的內容並未被更新,並進而導致了記憶體洩漏的問題。

用虛擬碼描述裝置上線和離網過程中的相關邏輯:

//裝置上線
if(不允許上線) return;
else
      建立Socket物件socket1;
      if(字典1中存在裝置特徵碼ID)
            字典1[ID]=socket1;
      else
            字典1.Add(ID,socket1);
      字典2.Add(socket1,業務物件);

//裝置離線
if(字典1中存在裝置特徵碼ID)
      字典1.Remove(ID);

從虛擬碼中很容易看出來由於裝置上線的時候往字典2中新增了內容但是裝置離網以及裝置建立重複連線的時候並沒有更新字典2中的內容導致了同一個裝置會存在很多無用的socket物件。而這些物件沒有業務上的意義而且還因為具有root而無法被清除。

總結

記憶體洩漏問題是後臺服務中比較常見的一類故障,在發生記憶體洩漏事故時,如果單純從服務執行場景的角度來分析往往得不到太好的效果而且耗時長並且難以找到準確的故障點。藉助於windbg及sos外掛的功能,綜合使用gcrootdumpheapfinalizequeue等指令快速定位記憶體洩漏的準確位置,並在此基礎上結合一些業務方面的知識和一些程式碼上的分析,就可以快速分析出記憶體洩漏的場景和原因,並針對性的制定出相應的修復計劃。

參考文獻

相關文章