.NET对象与Windows句柄(二):句柄分类和.NET句柄泄露的例子

上一篇文章介绍了句柄的基本概念,也描述了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.Threading.ManualResetEvent
System.Xaml.XamlBackgroundReader

事件

内核对象

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
System.Drawing.BufferedGraphicsManager
System.Drawing.Bitmap

位图

GDI对象

System.Drawing.SolidBrush
System.Drawing.TextureBrush

画刷

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是如何工作的。

posted @ 2017-01-15 14:44  遥望星空  阅读(558)  评论(0编辑  收藏  举报