細說UI線程和Windows訊息佇列

來源:互聯網
上載者:User

細說UI線程和Windows訊息佇列

註:

  由於本人對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;

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.