細說UI線程和Windows消息隊列
在 Windows應用程序中,窗體是由一種稱為“ UI線程( User Interface Thread)”的特殊類型的線程創建的。
首先, UI線程是一種“線程”,所以它具有一個線程應該具有的所有特征,比如有一個線程函數和一個線程 ID。
其次,“ UI線程”又是“特殊”的,這是因為 UI線程的線程函數中會創建一種特殊的對象——窗體,同時,還一并負責創建窗體上的各種控件。
窗體和控件大家都很熟悉了,這些對象具有接收用戶操作的功能,它們是用戶使用整個應用程序的媒介,沒有這樣一個媒介,用戶就無法控制整個應用程序的運行和停止,往往也無法直接看到程序的運行過程和最終結果。
那么,窗體和控件又是如何作到對用戶操作進行響應的呢?這一響應是不是由窗體和控件自己“主動”完成的?
換句話說:
窗體和控件具不具備獨立地響應用戶操作(比如鍵盤和鼠標操作)的功能?
答案是否定的。
那就奇怪了,比如我們用鼠標點擊了一個按鈕,并且看到它“陷”下去了,然后又還原,之后,我們確實看到了程序執行了此按鈕所對應的任務。難道不是按鈕來響應用戶操作的嗎?
這實際上是一個錯覺。這個錯覺產生的根源在于不了解 Windows內部的運作機理。
簡單地說,窗體和控件之所以能響應用戶操作,關鍵在于負責創建它們的 UI線程擁有一個“消息循環( Message Loop ) ”。這個消息循環由線程函數負責啟動,通常具有以下的“模樣”(以C++代碼表示):
MSG msg; //代表一條消息
BOOL bRet;
//從 UI線程消息隊列中取出一條消息
while( (bRet = GetMessage( &msg, NULL, 0, 0 )) != 0)
{
if (bRet == -1)
{
//錯誤處理代碼,通常是直接退出程序
}
else
{
TranslateMessage(&msg); //轉換消息格式
DispatchMessage(&msg); //分發消息給相應的窗體
}
}
可以看到, 所謂消息循環,其實就是一個While循環語句罷了。
其中, GetMessage()函數每次從消息隊列中取出一條消息,此消息的內容被填充到變量msg中。
TranslateMessage()函數主要用于將 WM_KEYDOWN和 WM_KEYUP消息轉換 WM_CHAR消息。
提示:
使用C++開發Windows程序時,各種消息都有一個對應的符號常量,比如,這里的WM_KEYDOWN和WM_KEYUP代表用戶按下一個鍵后所產生的消息。
消息處理的關鍵是 DispatchMessage()函數。這個函數根據取出的消息中所包含的窗體句柄,將這一消息轉發給引此句柄所對應的窗體對象。
而窗體負責響應消息的函數稱為“窗體過程( Window Procedure ) ”,窗體過程是一個函數,每個窗體一個 ,它大致擁有以下的“模樣”( C++代碼):
LRESULT CALLBACK MainWndProc(…… )
{
//……
switch (uMsg) //依據消息標識符進行分類處理
{
case WM_CREATE:
// 初始化窗體 .
return 0;
case WM_PAINT:
// 繪制窗體
return 0;
//
//處理其他消息
//
default:
//如果窗體沒有定義處理此種消息的代碼,則轉去調用系統默認的消息處理函數
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
//……
}
可以看到, “窗體過程”不過就是一個多分支語句罷了 ,在這個語句中,窗體對不同類型的消息進行處理。
在 Windows中, UI控件也被視為一個“ Window”,它也擁有自己的“窗體過程”,因此,它也可以同窗體一樣,具備處理消息的能力。
由此我們可以知道 UI線程所完成的大致工作就是:
UI 線程啟動一個消息循環,每次從本線程所對應的消息隊列中取出一條消息,然后根據消息所包容的信息,將其轉發給特定的窗體對象,此窗體對象所對應的“窗體過程”函數被調用以處理這些消息。
上述描述只介紹了事情的后半段,還需要了解事情的前半段,那就是:
用戶操作消息是怎樣“跑”到UI線程的消息隊列中的?
我們知道,Windows同時可以運行多個進程,每個進程又擁有多個線程,其中有一些線程是UI線程,這些 UI線程可能會創建不止一個窗體,那么問題發生了:
用戶在屏幕上某個位置按了一下鼠標,相關信息是怎樣傳給特定的UI線程,并最終由特定窗體的“窗體過程”負責處理?
答案是操作系統負責完成消息的投寄工作。
操 作系統會監控計算機上的鍵盤和鼠標等輸入設備,為每一個輸入事件(由用戶操作所引發,比如用戶按了某個鍵)生成一個消息。根據事件發生時的情況(比如當前 激活的窗體負責接收用戶按鍵,而依據用戶點擊鼠標的坐標可以知道用戶在哪個窗體區域內點擊了鼠標),操作系統會確定出此消息應該發給哪個窗體對象。
這些生成的消息會統一地先臨時放置在一個“系統消息隊列( system message queue )”中,然后,操作系統有一個專門的線程負責從這一隊列中取出消息,根據消息的目標對象(就是窗體的句柄),將其移動到創建它的 UI線程所對應的消息隊列中。操作系統在創建進程和線程時,都同時記錄了大量的控制信息(比如通過進程控制塊和句柄表可以查找到進程所創建的所有線程和引用的核心對象),因此,根據窗體句柄來確定此消息應屬于哪個 UI線程對于操作系統來說是很簡單的一件事。
注意, 每個UI線程都有一個消息隊列,而不是每個窗體一個消息隊列!
那么, 操作系統是不是會為每一個線程都創建一個消息隊列呢?
答案是:只有當一個線程調用 Win32 API中的 GDI( Graphics Device Interface)和 User函數時,操作系統才會將其看成是一個 UI線程,并為它創建一個消息隊列。
需要注意的是,消息循環是由UI線程的線程函數啟動的 ,操作系統不管這件事,它只管為UI線程創建消息隊列。因此,如果某個 UI線程的線程函數中沒有定義消息循環,那么,它所擁有的窗體是無法正確繪制的。
請看以下代碼:
class Program
{
static void Main(string[] args)
{
Form1 frm = new Form1();
frm.Show();
Console.ReadKey();
}
}
上述代碼屬于一個控制臺應用程序,在 Main()函數中,創建了一個 Form1窗體對象,調用它的Show()方法顯示,然后調用 Console.ReadKey()方法等待用戶按鍵結束進程。
程序運行的截圖如下:
如上圖所示,會發現窗體顯示一個空白方框,不接收任何的鼠標和鍵盤操作。
原因何在?
產生這一現象的原因可以解釋如下:
由于控制臺程序需要運行于一個“控制臺窗口”中,因此,操作系統認為它是一個UI線程,會為其創建一個消息隊列。
Main() 函數由于是程序入口點,所以執行它的線程是進程的第一個線程(即主線程),在主線程中,創建了一個 Form1 窗體對象,對其 Show() 方法的調用只是設置其 Visible 屬性 =true ,這將導致 Windows 調用相應的 Win32 API 函數顯示窗體,但這一調用并非阻塞調用,也沒有啟動一個消息循環,所以 Show() 方法很快返回,繼續執行下一句“ Console.ReadKey(); ”,此句的執行導致主線程調用相應的 Win32 API 函數等待用戶按鈕,阻塞執行。
注意,如果這時用戶用鼠標點擊窗體,嘗試與窗體交互,相應的消息的確發到了控制臺應用程序主線程的消息隊列中,但主線程并未啟動一個消息循環(你看到 Main() 函數中有任何的循環語句嗎?)以取出消息隊列中的消息并“分發”給窗體,因此,窗體函數沒被調用,自然無法正確繪制了。
如果窗體本身是調用 ShowDialog() 方法顯示的,這是一個阻塞調用,它會在內部啟動一個消息循環,此消息循環可以從主線程的消息隊列是提取消息,從而讓此窗體成為一個“正常”的窗體。
當用戶關閉窗體后, Main() 方法后繼的代碼繼續執行,直到運行結束。
如果在創建窗體對象并調用 Show() 方法顯示后,主線程沒有調用“ Console.ReadKey(); ”之類方法“暫停”,而是直接退出,這將導致操作系統中止整個進程,回收所有核心對象,因此,創建的窗體也會被銷毀,不可能再看見它。
現在再考慮復雜一些:如果我們在另一個線程中創建并顯示窗體,又將如何?
class Program
{
static void Main(string[] args)
{
Thread th = new Thread(ShowWindow);
th.Start() ;// 在另一個線程中創建并顯示窗體
Console.WriteLine(" 窗體已創建 , 敲任意鍵退出 ...");
Console.ReadKey();
Console.WriteLine(" 主線程退出 ...");
}
static void ShowWindow()
{
Form1 frm = new Form1();
frm.ShowDialog();
}
}
程序運行結果如下:
可以看到,由于窗體使用 ShowDialog() 顯示,因此,控制臺窗口和應用程序窗體都能正常地接收用戶的鍵盤和鼠標消息。即使主線程退出了,只要窗體沒有關閉,操作系統會認為“進程”仍在執行,因此,控制臺窗口會保持顯示,直到窗體關閉,整個進程才結束。
在這種情況下,本示例程序中有兩個 UI 線程,一個是控制臺窗口,另一個創建應用程序窗體的那個線程。
如果在線程函數中創建窗體后,改為 Show() 方法顯示,由于 Show() 方法沒有啟動消息循環,所以窗體不能正確繪制,并且會隨著創建它的 UI 線程的終止而被操作系統回收資源。
有趣的是,我們可以使用 Visual Studio 設置“控制臺應用程序”不創建“控制臺窗口”,只需將項目類型改為“Windows Application” 即可。
這時,示例程序運行時, Visual Studio 會報告錯誤:
引發這一錯誤的原因是應用程序主線程不再創建控制臺窗口,操作系統不再認為它是 UI 線程,不為其創建消息隊列,主線程將無法接收到任何按鍵消息, 因此 Console.ReadKey() 底層調用的 Win32API 函數無法正常運行,引發程序異常。
結束語:
本文是我個人探索.NET技術內幕過程中的一個小結,希望能對大家開發多線程程序有所幫助。特別是,對本文涉及到的技術我的理解若有錯誤,歡迎指正。
===========================
網友Analyst指出:
最后一段理解錯誤,這個異常告訴你控制臺窗口不存在或者控制臺輸入被重定向到文件,跟消息隊列毫無關系。在大部分語言的runtime庫中都定義 有2個標準的輸入輸出接口,在C里面叫stdin/stdout,C++里面叫cin/cout,.NET里面也有,但是用Console類給封裝了,輸 入輸出接口可以被重定向到控制臺窗口或者文件或者管道上。因為你的程序沒有控制臺窗口,輸入沒有定向到控制臺窗口,.net運行期檢測到這一狀況,所以給 你拋了個異常。回復 Analyst :
我問一下Analyst網友: cin 中的按鍵信息從哪來?不從消息隊列從哪?難道Windows操作系統允許一個用戶進程自己直接監控鍵盤這一硬件?
事實上,每個 Console 窗口都可以有一個(或多個)“屏幕緩沖區( Screen buffer ) ”和一個“輸入緩沖區”,這些緩存區在創建 Console 時被同步創建。當輸入緩沖區創建之后,可以從線程消息隊列中提取按鍵信息。
cin/cout 只不過是對這些緩沖區的面向對象封裝罷了,被稱為“標準輸入 / 輸出流”。沒有消息隊列,你緩沖什么?當前激活的屏幕緩沖區句柄就是標準輸出(standard output)和標準錯誤(standard error)句柄
Console.ReadKey ()在底層調用 Win32 API 函數 ReadConsoleInput ()接收按鍵,此函數的聲明如下:
BOOL WINAPI ReadConsoleInput(
__in HANDLE hConsoleInput,
__out PINPUT_RECORD lpBuffer,
__in DWORD nLength,
__out LPDWORD lpNumberOfEventsRead
);
注意其第一個參數是代表輸入緩沖區的句柄。由于示例程序中輸入緩沖區不存在,所以引發異常。
如果調用Console.Read()方法,則不會引發異常。因為此方法在內部調用StreamReader.Read()方法,當屏幕緩區不存在時,它調用StreamReader.Null.Read(),此方法不會引發異常。
相關的關鍵代碼如下:
try
{
//……
Stream stream = OpenStandardInput(0x100);
if (stream == Stream.Null)
{
@null = StreamReader.Null;
}
else
{
//……
}
Thread.MemoryBarrier();
_in = @null;
}
finally
{
//...
}
return _in;
RFID管理系統集成商 RFID中間件 條碼系統中間層 物聯網軟件集成