細說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技術內幕過程中的一個小結,希望能對大家開發多執行緒程式有所幫助。特別是,對本文涉及到的技術我的理解若有錯誤,歡迎指正。