上一篇文章介紹了句柄的基本概念,也描述了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是如何工作的。