代码改变世界

用Windbg调试.NET程序的资源泄漏

2010-08-15 17:46  liangshi  阅读(3199)  评论(3编辑  收藏  举报

在产品环境中的一个Windows服务出现了异常情况。这是一个基于WCF的.NET程序,它向网络应用(Web Application)提供WCF服务,同时也调用其他WCF服务以完成任务。突然,它不能响应网络应用的WCF调用。在它的日志文件中,我发现如下异常记录:

System.Net.Sockets.SocketException: An operation on a socket could not be performed because the system lacked sufficient buffer space or because a queue was full.

该异常暗示进程耗尽了系统的socket资源。开发人员告诉我,以前他们曾经发现这个程序有句柄泄漏的情况,但是没能够修复,这次的问题很可能是句柄泄漏造成的。由于问题发生在产品环境,我没有权限进行现场调试(live debugging)。于是,我请运营团队的同事用Windbg生成该程序的内存转储文件(memory dump)。具体命令如下,其中wcf_service.exe是发生问题的程序。

c:\debuggers\windbg.exe -pn wcf_service.exe -c ".dump /ma /u C:\wcf_service.dmp;qd"

在获得内存转储文件后,我用Windbg打开该文件,并加载Windbg调试扩展项Psscor2。Psscor2是调试扩展项SOS的增强版,在原有调试命令的基础上,又增加了一批实用的命令,是.NET程序调试的利器。

首先,调用!handle,检查句柄实用情况。

0:025>!handle
 
27604 Handles
Type             Count
None             9
Event            360
Section          55
File             16439
Directory        2
Mutant           6
Semaphore        64
Key              44
Token            10539
Thread           51
IoCompletion     5
Timer            5
KeyedEvent       1
TpWorkerFactory  24

该程序竟然拥有1万6千多个文件句柄(File)、1万多个令牌句柄(Token),果然存在严重的句柄泄漏。这些句柄对应了操作系统的本地资源(native resource),对于.NET程序,它们被称为非托管资源(unmanaged resource)。大多数.NET程序不直接向操作系统申请、释放非托管资源,它们通过调用.NET Framework Library的类来完成相应的操作。这些类大多实现了特殊的终结函数(Finalize函数)和Dispose惯用法。Finalize函数供CLR的Finalizer线程调用,Dispose函数供程序员调用,它们都会释放非托管资源(技术细节请参考《Effective C#》条款18:实现标准Dispose模式)。

实现了Finalize函数的对象被称为可终结对象。CLR(Common Language Runtime)在创建可终结对象时候,会在一个特殊的全局队列中保持它们的引用,这个队列被称为终结队列(Finalization Queue)。

于是,调用!FinalizeQueue来查看Finalization Queue,看看进程中有多少可终结对象。

0:025> !FinalizeQueue

SyncBlocks to be cleaned up: 0
MTA Interfaces to be released: 0
STA Interfaces to be released: 0
----------------------------------
generation 0 has 56 finalizable objects (0000000020099428->00000000200995e8)
generation 1 has 36 finalizable objects (0000000020099308->0000000020099428)
generation 2 has 98971 finalizable objects (000000001ffd7e30->0000000020099308)
Ready for finalization 0 objects (00000000200995e8->00000000200995e8)
Statistics:
        MT    Count    TotalSize Class Name
0x000007fef8dea958        1           24 System.Threading.OverlappedDataCache
0x000007fef8dd90c0        1           32 System.Security.Cryptography.SafeProvHandle
0x000007fef820e5d8        1           32 Microsoft.Win32.SafeHandles.SafeProcessHandle

...

0x000007fef8dbf730       16        1,664 System.Threading.Thread
0x000007fef81f7280       12        2,208 System.Net.Sockets.OverlappedAsyncResult
0x000007fef8db7a90      151        4,832 System.WeakReference
0x000007fef8db7b30      125        8,000 System.Threading.ReaderWriterLock
0x000007fef81efea0      254       14,224 System.Net.AsyncRequestContext
0x000007fef89994d8      256       30,720 System.Threading.OverlappedData
0x000007fef8dfa148      500       36,000 System.Reflection.Emit.DynamicResolver
0x000007fef8dd9298   10,539      337,248 Microsoft.Win32.SafeHandles.SafeTokenHandle
0x000007fef81f8730   16,341      522,912 System.Net.SafeCloseSocket+InnerSafeCloseSocket
0x000007fef81f93a8   16,339      653,560 System.Net.SafeCloseSocket
0x000007fef81f2850   16,362      785,376 System.Net.SafeFreeCredential_SECURITY
0x000007fef820d7a0    5,288      888,384 System.Diagnostics.PerformanceCounter
0x000007fef81f2be0   16,333      914,648 System.Net.SafeDeleteContext_SECURITY
0x000007fef81f84c8   16,339    1,960,680 System.Net.Sockets.Socket
Total 99,063 objects, Total size: 6,169,424

由输出可知,该程序拥有16个线程对象(线程也是非托管资源)、1万多个SafeTokenHandle对象、1万6千多个Socket对象。联系!handle的输出可以推知:SafeTokenHandle对象对应于非托管的令牌句柄(该程序对WCF调用实施Windows认证,这些令牌应该是认证所需要的安全令牌),Socket对象对应于非托管的文件句柄。这些对象的拥有者没有及时调用它们的Dispose方法,Finalizer线程也没有调用其Finalize方法,导致非托管资源没有得到及时地释放,终于导致socket资源耗尽。

那么是谁拥有这些对象呢?调用!dumpheap命令,获得所有Socket对象的地址。

0:025> !dumpheap -mt 0x000007fef81f84c8
Loading the heap objects into our cache.
         Address               MT     Size
0000000001200270 000007fef81f84c8      120    2 System.Net.Sockets.Socket

...

00000000012401e8 000007fef81f84c8      120    2 System.Net.Sockets.Socket
00000000012544d8 000007fef81f84c8      120    2 System.Net.Sockets.Socket

对于其中一个Socket对象,调用!gcroot命令,看看它是否被栈变量或全局变量所引用。

0:025> !gcroot 0000000001200270

DOMAIN(0000000000ABF100):HANDLE(Pinned):121790:Root:  00000000111c78e8(System.Object[])->
  00000000011f43e8(System.ServiceModel.ChannelFactoryRefCache`1[[IJobServiceServer, JobServiceClientProxy]])->
  000000000121b480(System.ServiceModel.ChannelFactoryRef`1[[IJobServiceServer, JobServiceClientProxy]])->
  00000000011f4578(System.ServiceModel.ChannelFactory`1[[IJobServiceServer, JobServiceClientProxy]])->
  00000000016f6710(System.ServiceModel.Channels.ServiceChannelFactory+ServiceChannelFactoryOverDuplexSession)->
  0000000001672ea0(System.ServiceModel.Channels.TcpChannelFactory`1[[System.ServiceModel.Channels.IDuplexSessionChannel, System.ServiceModel]])->
  0000000001515978(System.ServiceModel.Channels.TcpConnectionPoolRegistry+TcpConnectionPool)->
 
  ...
 
  00000000012097b8(System.ServiceModel.Channels.BufferedConnection)->
  0000000001209678(System.ServiceModel.Channels.SocketConnection)->
  0000000001200270(System.Net.Sockets.Socket)

这些对象被WCF的ChannelFactoryRefCache所引用。它是一个全局的缓存,保存了可用的WCF通道(channel)。这些通道所对应的WCF调用与JobService有关。JobService是一个WCF服务,出问题的程序要定时轮询该服务以获取计算结果。IJobServiceServer是JobService的客户端代理对象所实现的接口。于是用“IJobServiceServer”在源代码树上搜索,找到了代理对象的定义:

class JobServiceServerClient : System.ServiceModel.ClientBase<IJobServiceServer>, IJobServiceServer { ...

JobServiceServerClient继承了WCF提供的ClientBase, 拥有非托管的socket资源,并实现了Finalize函数和Dispose函数。于是,搜索调用JobServiceServerClient的代码。很快,在源代码树上发现如下代码:

private static void CheckJobStatus(object state)
{
    JobServerClient client = new JobServerClient ();
    ...

该函数被定时地调用,以轮询JobService的计算结果。不幸的是,每次调用都会产生非托管资源的泄露,积少成多以至于难以为继。当JobServiceServerClient的对象创建时,它会被加入ChannelFactoryRefCache。由于程序员忘记调用Dispose函数,该对象拥有的scoket资源没有被释放。忘记调用Dispose函数的另一个后果是,该对象没有从ChannelFactoryRefCache中移除。这使得该对象始终是可达对象(reachable object),垃圾回收器(Garbage Collector) 不会处理它,这导致Finalizer线程不会调用它的Finalize函数,使得socket资源始终得不到释放。关于垃圾回收和Finalizer线程的技术细节请参考《CLR via C#》(第3版,第21章)。

对于函数CheckJobStatus,正确的实作是利用C#的using语句,确保对象client在退出using作用域时,其Dispose函数被CLR调用。

private static void CheckJobStatus(object state)
{
    using(JobServerClient client = new JobServerClient ())
    {
    ...

实际上,所有涉及非托管资源管理的文献,几乎都“严厉要求”正确地实现Dispose模式,并尽可能地利用using语句来确保Dispose函数被及时调用。这次遇到的问题就是违反了这条基本的资源管理规则。

回顾这次调试过程,可获得如下小结。

  1. 命令!handle可以查看本地资源(非托管资源)的句柄。
  2. 命令!FinalizeQueue可以查看终结队列,对于调试非托管资源泄漏很有帮助。
  3. 正确的实现Dispose模式并严格地使用using语句,可以避免非托管资源的泄露。

最后,介绍一个调试句柄泄漏的好工具ProceXp。以管理员权限启动该程序,选中目标进程,按下快捷键Ctrl+H(或点击View > Lower Pane View > Handles),可以在下方面板中看到该进程所拥有的全部句柄。此时,按下快捷键Ctrl + S (或点击 File > Save),可以将目标进程及其句柄信息保存为文本文件,以供深入分析。