内存泄漏问题分析之非托管资源泄漏

在某次巡查生产环境监控数据的时候,发现某个程序的内存占用偏高(大于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等指令快速定位内存泄漏的准确位置,并在此基础上结合一些业务方面的知识和一些代码上的分析,就可以快速分析出内存泄漏的场景和原因,并针对性的制定出相应的修复计划。

参考文献