在.NET编程中,得益于有效的内存管理机制,对象的创建和使用比较方便,大多数情况下我们无须关心对象创建和分配内存的细节,也可以放心的把对象的清理交给自动垃圾回收来完成。由于.NET类库对系统底层对象进行了封装,我们也不需要调用Windows API来操作非托管对象。但不直接操作非托管对象,并不意味着程序不会间接创建这些对象,如果不了解.NET对象与非托管资源的关系,我们很有可能因为不恰当的使用这些托管对象,而导致非托管资源泄露。本文尝试说明Windows对象和句柄的基本概念,以及.NET编程中的对象与它们的关系,并结合一些简单的示例程序来探讨句柄泄露的话题。
一、什么是句柄?
Windows编程中,程序需要访问各种各样的资源,如文件、网络、窗口、图标和线程等。不同类型的资源被系统封装成不同的数据结构,当需要使用这些资源时,程序需要依据这些数据结构创建出不同的对象,当操作完毕并不再需要这些对象时,程序应当及时释放它们。在Windows中,应用程序不能直接在内存中操作这些对象,而是通过一系列公开的Windows API由对象管理器(Object Manager)来创建、访问、跟踪和销毁这些对象。当调用这些API创建对象时,它们并不直接返回指向对象的指针,而是会返回一个32位或64位的整数值,这个在进程或系统范围内唯一的整数值就是句柄(Handle)。随后程序再次访问对象,或者删除对象,都将句柄作为Windows API的参数来间接对这些对象进行操作。在这个过程中,句柄作为系统中对象的标识来使用。
对象管理器是系统提供的用来统一管理所有Windows内部对象的系统组件。这里所说的内部对象,不同于高级编程语言如C#中“对象”的概念,而是由Windows内核或各个组件实现和使用的对象。这些对象及其结构,要么不对用户代码公开,要么只能使用句柄由封装好的Windows API进行操作。C#编程中,多数情况下,我们并不需要与这些Windows API打交道,这是因为.NET类库对这些API又进行了封装,但我们的托管程序仍然会间接创建出很多Windows内部对象,并持有它们的句柄。
如上所说,句柄是一个32位或64位的整数值(取决于操作系统),所以在32位系统中,C#完全可以用int来表示一个句柄。但.NET提供了一个结构体System.IntPtr专门用来代表句柄或指针,在需要表示句柄,或者要在unsafe代码中使用指针时,应当使用IntPtr类型。
二、C#中创建文件句柄的过程
举例来说,文件属于一种非托管的系统资源。在C#中,可以用File类的静态方法Open来得到一个FileStream对象,来对磁盘文件进行读写操作。FileStream对象本身是托管对象,它是如何与文件这个非托管资源产生联系的呢?
大致说来,C#中打开文件的操作会经过下列步骤:
- 调用.NET静态方法System.IO.File.Open时,File类会创建一个FileStream对象并传入必要的参数,如文件路径,FileMode和FileAccess选项。FileMode枚举表明是希望创建新文件,打开已有文件,覆盖原有文件或是在原文件上追加新内容;FileAccess枚举表明是希望读文件、写文件或两者都有。
- 接着FileStream调用自己的Init方法进行初始化,在这个过程中,有更多细节需要考虑。为了创建一个文件,初始化方法需要更多额外的信息和检查,比如本进程在使用文件时是否允许其它进程读写文件,文件路径是否有效,是否有足够的权限,目标文件是否是允许被访问的文件类型,是否正确设置了FileMode和FileAccess选项的组合等。
- 完成这些必要的检查后,FileStream.Init调用Win32Native.SafeCreateFile方法。
- Win32Native类封闭了大量的Windows API,SafeCreateFile方法以P/Invoke的方式调用kernel32.dll中的CreateFile API,并返回SafeFileHandle。SafeFileHandle是一个有趣的类型,继承自SafeHandle,包含了真正的IntPtr类型的文件句柄。.NET的设计者有意让这个句柄字段对外不可见,但如果你非要拿到这个句柄值,SafeFileHandle也提供了DangerousGetHandle()方法满足你的要求:都告诉你Dangerous了,你自己看着办。
- 包含着文件句柄的SafeFileHandle会被返回并存放在FileStream对象中。随后的读取和写入操作,FileStream都会使用这个句柄与Windows API进行交互,直到最终关闭句柄。至始至终,我们的代码都无需直接关心句柄的存在,FileStream负责了绝大部分工作。
三、通过句柄操作对象的好处
Windows不允许应用程序直接访问内存中更底层的对象,而是由对象管理器统一管理,总的来说,至少有以下好处:
- 在操作系统层面上,为所有程序使用系统资源提供了统一的接口和机制。如果没有对象管理器,不同程序会有各种各样的实现方式来访问资源,并且这些代码散落在各种,难以规范,也无从协调解决资源的争用。
- 将需要在系统级别保护的对象隔离起来,提供更高安全性。
- 所有对系统关键资源的访问都经由对象管理器,使得系统可以方便的追踪和限制资源的使用,进行权限控制。
四、查看进程的句柄数量
到现在为止,本文讨论的全是看不见的概念,有必要来直观的看一下系统中的句柄使用情况。有多种方式可以查看进程的句柄使用情况,先从两个工具开始,Windows任务管理器和Process Explorer。
任务管理器默认不显示句柄数,需要在“查看”-“选择列”中勾选“句柄数”后,才会显示进程中当前打开的句柄数量。如下图所示,可以看到记事本进程当前打开59个句柄。
系统自带的任务管理器查看句柄数量很方便,但如果想知道这些句柄具体是什么,可以使用Process Explorer。Process Explorer是Windows Sysinternals工具包中的一个进程查看器,可以从这里下载。如果你看到的视图跟下图不同,可以点击View,选中Show Lower Pane,并在Lower Pane View中选择Handles。在列表中选择进程后,下方面板中会显示该进程中句柄的详细列表。
五、为什么关注句柄数
句柄指向的是诸如窗口、线程、文件、菜单、进程和定时器之类的系统资源,和所有被称为“资源”的事物一样,稀缺性是它们共同的特点。对于计算机和操作系统来讲,内存是一种稀缺资源,而所有的句柄和对象都存储在内存中。基于这个事实,操作系统不允许进程无限制的创建对象和句柄。对于任务管理器中的“句柄数”来讲,每一进程允许打开的句柄数理论上来讲可达2^24个,但由于内存的限制,实际数字大打折扣。在我的测试中,32位的.NET进程“句柄数”在达到1500万以上后,程序开始出现各种各样的问题。事实上绝大多数程序不会使用到这么多句柄,除非特殊需要,在软件编程中,如果自己的程序“句柄数”上千甚至是几千时,就需要引起特别注意,这一般说明程序中已经存在句柄泄露的情况。
你可能已经留意到,本文前面任务管理器中,除了显示进程的“句柄数”之外,还显示了“用户对象”和“GDI对象”的数量,它们属于另外两种句柄。具体的区别我们将在后面介绍,现在我们需要清楚的是,系统对于这两种对象同样设置了数量限制。对于“用户对象”和“GDI对象”来说,每个进程允许创建的数量上限是在注册表中设定的,分别是HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows中的USERProcessHandleQuota项和GDIProcessHandleQuota项,在Windows 7的32位操作系统上,两个项都被默认设置为10000。你可以更改这个设置,用户对象最多只能设定为18000个,GDI对象最多为65536个。但是改变这个设置是不被推荐的,一般情况下当你的应用程序需要用到超过10000个用户对象或GDI对象时,应该首先检查哪里出现了句柄泄露,而不是更改上限数量;另一方面,更改上限并不意味着应用程序就真的可以创建和使用这么多对象句柄,实际可用的数量同时受制于当前系统可用内存。