天天看點

細說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;

WM_PAINT:

        // 繪制窗體

            //

            //處理其他消息

            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

            Thread

th = new Thread(ShowWindow);

            th.Start() ;// 在另一個線程中建立并顯示窗體

Console.WriteLine(" 窗體已建立 , 敲任意鍵退出 ...");

Console.ReadKey();

Console.WriteLine(" 主線程退出 ...");

         }

        static void ShowWindow()

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;