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