上一篇文章介绍了句柄的基本概念,也描述了C#中创建文件句柄的过程。我们已经知道句柄代表Windows内部对象,文件对象就是其中一种,但显然系统中还有更多其它类型的对象。本文将简单介绍Windows对象的分类。
句柄可以代表的Windows对象分为三类,内核对象(Kernel Object)、用户对象(GDI Object)和GDI对象,上一篇文章中任务管理器中的“句柄数”、“用户对象”和“GDI对象”计数就是与这几类对象对应的。为什么要这样分类呢?原因就在于这几类对象对于操作系统而言有不同的作用,管理和引用的方式也不同。内核对象主要用于内存管理、进程执行以及进程间通信,用户对象用于系统的窗口管理,而GDI对象用来支持图形界面。
一、观察句柄变化的小实验
在列举Windows对象的分类之前,我们再看一个关于句柄数量的实验,与之前文件对象的句柄不同,本例中的句柄属于用户对象。程序运行过程中,对象的创建和销毁是动态进行的,句柄数量也随之动态变化,即使是一个最简单的Windows Form程序也可以直观的反映这一点。下图是一个只有文本框和按钮的窗体程序,程序启动后默认输入焦点在文本框上,可以按下Tab键将焦点在文本框和按钮之间交替切换。当我们这样做时,在任务管理器中可以看到:用户对象的数量在21和20之间不断变化。这一数字在你的运行环境中可能不同,但至少说明在焦点切换过程中有一个用户对象在不断的被创建销毁,这个对象就是Caret(插入符号)。
Caret是用户对象的一种,这个闪烁的光标指示输入的位置。我们可以通过Windows API创建这个符号,定制它的样式,也可以设置闪烁时间。创建Caret时,Windows API并不返回它的句柄,原因是一个窗口只能显示一个插入符号,可以通过窗口的句柄对它进行访问,或者更简单的,看哪个线程在调用这些API即可。但无论如何,Caret对象和其句柄是真实存在的,即便我们不需要获取这个句柄。
二、Windows对象的分类
前面提到了Windows对象分为内核对象、用户对象和GDI对象,也举了文件对象和Caret对象的例子,除此之外还有很多其它类型的对象。Windows对象的完整列表,可以参考MSDN中关于Object Categories (Windows) 的描述,其中列举了每个类别的对象,并且针对每种对象都有详细的说明,你可以从中找到这些对象的用法,和对应的Windows API等。本文主要讨论.NET对象和Windows对象的关系,因此在这里只简单列举这些对象以供快速参考。
内核对象:访问令牌、更改通知、通信设备、控制台输入、控制台屏幕缓冲区、桌面、事件、事件日志、文件、文件映射、堆、作业、邮件槽、模块、互斥量、管道、进程、信号量、套接字、线程、定时器、定时器队列、定时器队列定时器、更新资源和窗口站。
用户对象:加速键表、插入符号、光标、动态数据交换会话、钩子、图标、菜单、窗口和窗口位置。
GDI对象:位图、画刷、设备上下文、增强型图元文件、增强型图元文件设备上下文、字体、内存设备上下文、图元文件、图元文件设备上下文、调色板、画笔和区域。
如前所述,不同类别的对象具有不同的作用和特点。内核对象主要用于内存管理、进程执行以及进程间通信。多个进程可以共用同一个内核对象(如文件和事件),但每个进程必须独自创建或打开这个对象以获取自己的句柄,并指定不同的访问权限,这种情况下,一个内核对象会被多个进程的句柄引用;用户对象用于系统的窗口管理,与内核对象不同的是,一个用户对象仅能有一个句柄,但句柄是对其它进程公开的,因此其它进程可以获取并使用这个句柄来访问用户对象。以窗口(Windows)对象为例,一个进程可以获取另一个进程创建的窗口对象的句柄,并向其发送各种消息,这也是很多自动化测试工具得以实现的前提;而GDI对象用来支持图形界面,也只支持单个对象单个句柄,但与用户对象不同的是,GDI对象的句柄是进程私有的。
三、与Windows对象对应的.NET对象
.NET中有不少类型封装了上面所列举Windows对象,我们在使用时要特别注意对这些对象的进行重用和适时销毁。下表是一些对应关系的例子(注意这不是完整列表,也并非严格的一一对应关系),后续文章将会讨论其中一些重要类型的用法。
.NET对象 |
引用到的Windows对象句柄 |
分类 |
System.Threading.Tasks.Task |
访问令牌 |
内核对象 |
System.IO.FileSystemWatcher |
更改通知 |
内核对象 |
System.IO.FileStream |
文件 |
内核对象 |
System.Threading.AutoResetEvent |
事件 |
内核对象 |
System.Diagnostics.EventLog |
事件日志 |
内核对象 |
System.Threading.Thread |
线程 |
内核对象 |
System.Threading.Mutex |
互斥量 |
内核对象 |
System.Threading.Semaphore |
信号量 |
内核对象 |
System.Windows.Forms.Cursor |
光标 |
用户对象 |
System.Drawing.Icon |
图标 |
用户对象 |
System.Windows.Forms.Menu |
菜单 |
用户对象 |
System.Windows.Forms.Control |
窗口 |
用户对象 |
System.Windows.Forms.Control |
位图 |
GDI对象 |
System.Drawing.SolidBrush |
画刷 |
GDI对象 |
System.Drawing.Font |
字体 |
GDI对象 |
四、.NET中与句柄泄露相关的异常和现象
上一篇文章提到了句柄的限制,当进程或系统的句柄数量达到上限时,程序运行就会出现异常。常见的错误是System.ComponentModel.Win32Exception的“Error creating window handle”,或者“存储空间不足,无法处理此命令”等,错误出现时内存往往也会有显著增长。如果是达到了系统级别的句柄上限,其它程序的运行也受到影响,系统可能无法打开任何新的菜单和窗口、窗口也会出现绘制不完整的情况。这时及时抓取Dump并终止泄露句柄的进程,系统往往立即恢复正常。
五、第一个句柄泄露的例子
下面的示例代码包含句柄泄露的问题,为了演示方便,实现代码被最简单化,设计的合理性也暂且不作深究。代码模拟了一个应用场景:程序包含一个DataReceiver不断从某个数据源获取实时数据,DataReceiver同时会启动一个DataAnalyzer,定时分析这些数据。设想程序有一个专门的子窗口来显示这些数据,当子窗口被临时关闭时,数据的实时获取和分析过程也可以暂时终止。程序长时间运行的过程中,子窗口可能被用户多次关闭和打开,因此DataReceiver会被创建多次,程序启动后的代码模拟DataReceiver被创建和Dispose了1000次。
using System; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using Timer = System.Threading.Timer; namespace LeakExample { public partial class Form1 : Form { public Form1() { InitializeComponent(); // 模拟程序运行过程中多次创建DataReceiver的情况 Task.Factory.StartNew(() => { for (int i = 0; i < 1000; i++) { using (IDisposable receiver = new DataReceiver()) { Thread.Sleep(100); } } }); } } public class DataReceiver : IDisposable { private Timer dataSyncTimer = null; private IAnalyzer analyzer = null; private bool isDisposed = false; public DataReceiver() : this(new DataAnalyzer()) { } public DataReceiver(IAnalyzer dataAnalyzer) { dataSyncTimer = new Timer(GetData, null, 0, 500); analyzer = dataAnalyzer; analyzer.Start(); } private void GetData(object state) { // 获取数据并放入缓存 } public void Dispose() { if (isDisposed) return; if (dataSyncTimer != null) { dataSyncTimer.Dispose(); } isDisposed = true; } } public interface IAnalyzer { void Start(); void Stop(); } public class DataAnalyzer : IAnalyzer { private Timer analyzeTimer = null; public void Start() { analyzeTimer = new Timer(DoAnalyze, null, 0, 1000); } public void Stop() { if (analyzeTimer != null) { analyzeTimer.Dispose(); } } private void DoAnalyze(object state) { // 从缓存中取得数据并分析,耗时600毫秒 Thread.Sleep(600); } } }
当运行这段程序时,可以从任务管理器观察到句柄数持续增长,最终基本稳定在某一个较高的数字。虽然DataReceiver被多次创建,但句柄数的增长最终远远超过其被创建的次数。由于代码简单,你很可能已经看出问题所在,然而在实际的项目中,由于软件架构和业务逻辑代码更为复杂,很难一眼就看出问题的根源。下一篇文章将从这个例子入手,结合一些工具来分析问题存在的原因,并讨论Timer是如何工作的。