Microsoft Windows的訊息迴圈

微軟視窗操作系統是以事件驅動做為程式設計的基礎。程式的執行緒會從作業系統獲取訊息。應用程式會不斷循環呼叫GetMessage函式(或是PeekMessage函式)來接收這些訊息,這個循環稱之為「事件迴圈」。基本上事件迴圈的程式碼如下所示(C語言 / C++程式語言):

MSG msg; //用于存储一条消息
BOOL bRet;

//从UI线程消息队列中取出一条消息
while( (bRet = GetMessage( &msg, NULL, 0, 0 )) != 0)
{
   if (bRet == -1)
   {
       //错误处理代码,通常是直接退出程序
    }
    else
    {
       TranslateMessage(&msg); //按键消息转换为字符消息
       DispatchMessage(&msg); //分发消息给相应的窗体
     }
}

雖然在程序上並沒有很嚴格的規定與要求,但是一般來說,它的事件迴圈通常會呼叫TranslateMessage函式與DispatchMessage函式,這兩個函式會傳遞訊息給回呼函式,以及調用相應視窗的消息處理函數。

現在的繪圖介面架構程式設計,例如Visual BasicQt基本上是不會要求應用程式直接擁有視窗程式的訊息迴圈,但是會以鍵盤與滑鼠的按鍵動作來作為事件的處理機制。在這些架構底下,訊息迴圈的痕跡還是可以被找到的。

注意:在上述的原始碼裡,尤其在while迴圈大於零的條件。即使GetMessage函式的傳回值型態是英文字大寫的BOOL,但是在Win32視窗程式裡,它是被定義成int整數型態,它有兩個值,TRUE是整數的1,FALSE是整數的0。整數 -1代表error(例如第二個參數為輸出的窗口句柄但取不到值的時候),整數0值當GetMessage獲取到WM_QUIT訊息。假如有其他訊息,那麼非零值會當成傳回值(有訊息的傳回值通常是正值,但是有些程式設計的說明文件不一定會說明的很詳細[1][2])。

歷史

16位Windows系統為非搶先單線程模式,應用程序沒有發送消息隊列,向窗口發送一個消息總是按同步方式執行,也即發送程序要在接受消息的窗口完全處理完消息之後才能繼續運行。這通常是一個所期望的特性。但是,如果接收消息的窗口花很長的時間來處理消息或者出現掛起,則發送程序就不能再執行。這意味着系統是不強壯的。[3]如果應用程序消息隊列(只用於存放投寄的消息)為空,由於沒有虛擬輸入消息隊列,SendMessage或PeekMessage函數訪問系統事件隊列查取可用的鼠標或鍵盤輸入消息。如果系統隊列中沒有需要處理的事件,SendMessage或PeekMessage函數掃描所有窗口以處理需要修改重繪的區域。如果沒有需要重繪的區域,則交出CPU控制權。恢復CPU控制權時,查看是否有定時器過期。至此如果沒有消息可返回,SendMessage進入睡眠,直至被輸入事件喚醒;PeekMessage如果沒有設置PM_NOYIELD標記,則會讓出CPU控制權,但不會讓線程休眠,重新獲得CPU後PeekMessage將控制權返回到線程,並返回一個空值指出這個線程沒有要處理的消息了。

本文主要關注Win32系統的消息處理機制。

背景

UI線程

Windows系統規定,窗口和鈎子(hook)這兩種User對象分別由建立窗口和安裝鈎子的線程所擁有,一旦該線程結束,操作系統會自動刪除窗口或卸載鈎子。而其他的User對象(圖標icon、光標cursor、窗口類WndClass、菜單、加速鍵表等)則歸進程所有,進程結束時操作系統會自動刪除這些對象。

建立窗口的線程必須就是處理窗口所有消息的線程,即UI線程(User Interface Thread)創建了窗體及窗體上的各種控件,系統為UI線程分配一個消息隊列用於窗口消息的派送(dispatch)。為了使窗口處置這些消息,線程必須有它自己的「消息循環」。只有當一個線程調用Windows API中的GDI(Graphics Device Interface)和User函數時,操作系統才會將其看成是一個UI線程,並為它分配一些另外的資源,創建一套線程消息隊列;否則,操作系統把非UI線程視作普通工作線程(Workhorse),不會為它創建消息隊列。因此,調用PostThreadMessage前,這個線程必須是UI線程從而有投寄消息的隊列,通常可在該線程中調用一次PeekMessage函數以達到這個目的。

如果一個UI線程結束運行,操作系統會自動回收它所創建的所有窗體。

窗體過程

窗體過程(Window Procedure)是一個函數,每個窗體有一個窗體過程,負責處理該窗體的所有消息。

UI控件也是獨立的「Window」,擁有自己的「窗體過程」。

消息隊列

Windows操作系統的內核空間中有一個系統消息隊列(system message queue),在內核空間中還為每個UI線程分配各自的線程消息隊列(Thread message queue)。在發生輸入事件之後,Windows操作系統的輸入設備驅動程序將輸入事件轉換為一個「消息」投寄到系統消息隊列;操作系統的一個專門線程從系統消息隊列取出消息,分發到各個UI線程的輸入消息隊列中。

每個UI線程的線程信息塊TIB分配一個THREADINFO的結構,該結構包含一族成員變量,包括:[4]

  • 發送消息隊列(send-message queue)指針:其他發起線程通過SendMessage、SendMessageTimeout、SendMessageCallback、SendNotifyMessage、ReplyMessage等函數產生的消息放入該隊列,發起的線程阻塞(掛起)在該隊列上(對於SendMessageCallback、SendNotifyMessage不被阻塞)直至消息處理完或者超時返回。
  • 投寄消息隊列(posted-message queue)指針:其他線程通過PostMessage函數或PostThreadMessage函數投寄的消息;
  • 虛擬輸入消息隊列(virtualized-input queue)指針:鍵盤與鼠標事件。該隊列最多只保存一個鍵盤消息,僅當應用程序處理完這個鍵盤消息,Windows才會從操作系統消息隊列取出下一個鍵盤消息放入線程的虛擬輸入消息隊列。這種方式至少有兩點用途:一是如果用戶的鍵盤輸入速度快於應用程序處理鍵盤消息的速度,並且特定按鍵會使輸入焦點從一個窗口切換到另一個窗口,隨後的按鍵就應該是另一個窗口的輸入;二是Windows API函數TranslateMessage把按鍵消息轉化為字符消息,如WM_KEYDOWN轉化為WM_CHAR,然後放入線程的虛擬輸入消息隊列中,成為下一個待處理的鍵盤消息。
  • 回復消息隊列(reply-message queue)指針:調用SendMessage函數的線程在這個函數上阻塞後,實際上仍可能被系統使用該線程執行其他處理,因此SendMessage函數的目標線程把窗口函數的返回值登記到這個隊列作為SendMessage的返回值,以便SendMessage函數從阻塞狀態恢復時能取到該返回值(16位Windows系統是單線程的,因此不可能存在這種需求)。另外一種使用情形是SendMessageCallback函數(給所有重疊(overlapped)窗口廣播)時,總是調用後立即返回並繼續執行,因此接收了此消息的線程把窗口函數執行結果登記到發起線程的回覆消息隊列,在發起線程下一次調用GetMessage、PeekMessage、WaitMessage或某個SendMessage掛起時從回復消息隊列中取出該msg並執行登記的ResultCallBack函數。
  • nExitCode:由PostQuitMessage函數設置該成員,作為線程的退出碼。
  • 喚醒標誌(wake flage)
  • 局部輸入狀態變量
    • QS_POSTMESSAGE位:投寄消息隊列是否為空;
    • QS_QUIT位:由PostQuitMessage函數給該標誌置位。
    • QS_SENDMESSAGE位:發送消息隊列是否為空;
    • QS_KEY:有按鍵消息
    • QS_MOUSE:有鼠標消息
    • QS_PAINT:有WM_PAINT
    • QS_TIMER:有WM_TIMER

應用程序的每個UI線程中有一段稱之為「消息循環」的代碼,通過GetMessage系統調用(或是PeekMessage系統調用)訪問系統空間中的對應的UI線程的消息隊列,並依照下述次序處理:

  • QS_SENDMESSAGE置位:則對發送消息隊列中的每個消息,依次調用各個發送消息的窗口函數直接處理,GetMessage不返回;直至所有發送消息隊列中的消息處理完畢。
  • QS_POSTMESSAGE置位:則填充GetMessage函數參數的MSG結構為相應的投寄消息,GetMessage返回為真。該消息通過DispatchMessage系統調用把消息分發給相應窗口的消息處理函數。
  • QS_QUIT置位:則填充GetMessage函數參數的MSG結構為WM_QUIT,QS_QUIT復位,GetMessage返回為假。
  • QS_INPUT置位:則填充GetMessage函數參數的MSG結構為相應的輸入消息,GetMessage返回為真。該消息通過DispatchMessage系統調用把消息分發給相應窗口的消息處理函數。
  • 再一次檢查QS_SENDMESSAGE置位,如是則處理髮送消息隊列中的每個消息。
  • QS_PAINT置位:則填充GetMessage函數參數的MSG結構為WM_PAINT,GetMessage返回為真。GetMessage不從隊列中刪除WM_PAINT消息(即不對QS_PAINT復位)。
  • QS_TIMER置位:則填充GetMessage函數參數的MSG結構為WM_TIMER,QS_TIMER復位,GetMessage返回為真。如果QS_TIMER復位狀態,則當前線程掛起(hung)。

需要注意的是,GetMessage如果在應用程序消息隊列未獲取消息,則GetMessage調用不返回,該線程掛起,CPU使用權交給操作系統。即GetMessage為阻塞調用。

由此可見,Windows的事件驅動模式,並不是操作系統把消息主動分發給應用程序;而是由應用程序的每個UI線程通過「消息循環」代碼從UI線程消息隊列獲取消息。

Windows消息類別

  • 鍵盤消息:
    • 按鍵消息:WM_SYSKEYDOWN、WM_SYSKEYUP、WM_KEYDOWN、WM_KEYUP等消息。
      • wParam包含虛擬鍵碼(virtual-key code),表示按下或釋放的鍵
      • lParam包含按鍵6個字段信息:
        • 重複按鍵次數(Repeat Count,0~15 位):通常設為1。大於1說明按鍵速度大於程序處理能力。可以根據實際需要忽略或處理。
        • OEM掃描碼(scan code,16~23位):硬件產生的代碼。
        • 擴充鍵標誌(extended key,24位):如果為擴充鍵(如右側的Alt鍵或Ctrl鍵)按下時為1,否則為0。
        • 保留位(25~28位):保留位是系統缺省保留的,一般不用。
        • 上下文代碼(context code,29位):如同時按下ALT,標誌為1;否則為0。WM_SYSKEYUP或WM_SYSKEYDOWN常為1。WM_KEYUP或WM_KEYDOWN常為0。當所有程序都最小化時,沒有窗口具有輸入焦點,Windows仍將發送鍵盤消息給活動窗口;所有的按鍵都會產生WM_SYSKEYUP與WM_SYSKEYDOWN消息,此情況下如果沒按下ALT,該字段為0,這樣使最小化的活動窗口不處理這些按鍵。對於一些非英文鍵盤,有些字符是shift等組合鍵產生的,這時內容代碼為1,但是其是非系統按鍵。
        • 鍵先前狀態(previous key state,位30):鍵此前是釋放的,則為0,還則為1。很明顯UP為1,DOWN可以為1或0,為1表示該鍵自動重複。
        • 轉換狀態(transition state,31位):鍵被按下為0,鍵被鬆開時為1。如UP為1,DOWN為零。
      • SYSKEY:按下F10(將激活菜單條)或者按下Alt後再按下別的鍵,或者沒有窗口具有鍵盤輸入焦點(WM_SETFOCUS指示窗口得到輸入焦點,WM_KILLFOCUS指示窗口失去輸入焦點)時的按鍵,為SYSKEY。lParam的第29位為context code,如果為1表示Alt被按下,如果為0表示WM_SYSKEYDOWN發出時沒有窗口具有鍵盤輸入焦點。無論用戶處理與否,必須傳送給Windows默認窗口過程處理此類按鍵消息。
      • 其他情形為普通按鍵。除Print鍵之外都有「按下」消息;所有鍵都存在「彈起」消息。
    • 字符消息:按鍵消息WM_SYSKEYDOWN、WM_KEYDOWN被Windows API函數TranslateMessage處理後該函數在線程消息隊列投寄(post)相應的字符消息,wParam參數是ASCII或Unicode的character code;這取決於RegisterClass函數是A或W版;IsWindowUnicode函數判斷窗口過程會接受哪種編碼。產生字符消息的按鍵有:任何字符鍵、回退鍵(BACKSPACE)、回車鍵(carriage return)、ESC、SHIFT + ENTER(linefeed換行)、TAB。因為TranslateMessage函數從WM_KEYDOWN和WM_SYSKEYDOWN消息產生了字符消息,所以字符消息是夾在按鍵消息之間傳遞給窗口消息處理程序的。如果使用者按住一個鍵不放,會自動重複產生一系列的WM_KEYDOWN消息;對每條WM_KEYDOWN消息,都會得到一條字符消息。如果某些WM_KEYDOWN消息的重複計數大於1,那麼相應的WM_CHAR消息將具有同樣的重複計數。
      • WM_SYSCHAR:按下Alt後再按下別的鍵的WM_SYSKEYDOWN消息被翻譯,
      • WM_CHAR:WM_KEYDOWN消息被翻譯為WM_CHAR消息。
      • WM_DEADCHAR:TranslateMessage函數處理「死鍵」(dead key)的WM_KEYUP消息,向具有輸入焦點的窗口投寄(post)出WM_DEADCHAR消息。死鍵是產生附加符號的按鍵。例如在德語鍵盤,銳音符被按下、釋放後,再按下A,將獲得字母á的WM_CHAR。如果在死鍵之後跟有不能帶此附件符號的字母(例如銳音符後跟「s」),那麼將接收到兩條WM_CHAR消息:前一個消息的wParam等於附加符號本身的ASCII碼(與傳遞到WM_DEADCHAR消息的wParam值相同),第二個消息的wParam等於字母的ASCII代碼。
      • WM_SYSDEADCHAR:按下Alt時又按下了「死鍵」的WM_SYSKEYUP消息。
  • 鼠標消息:
    • 客戶區鼠標消息:WM_MOUSEMOVE及鼠標按鍵的DOWN、UP、DBLCLK消息。雙擊事件的處理只有窗口類定義接收(CS_DBLCLKS)時,才起作用,這時接收到的鼠標消息順序為:DOWN、UP、DBLCLK、UP。鼠標消息發送給被單擊的窗口或鼠標經過的窗口,即使該窗口處於非活動或不帶輸入焦點;例外情況有「捕獲鼠標」時或模式對話框處於活動狀態時。消息參數wParam(指出那個鼠標按鈕、Shift鍵、Ctrl鍵被按下;lParam的低位表示x坐標,高位表示y坐標的鼠標位置。
    • 非客戶區鼠標消息:為WM_NC*形式。
      • 擊中測試消息:WM_NCHITTEST。Windows利用此消息來產生其他所有的鼠標消息。
    • WM_MOUSEWHEEL發送給具有焦點的窗口(注意不一定是鼠標下面的窗口)
  • 定時器消息
  • 控件消息
  • 跨進程發送數據的消息:WM_SETTEXT、WM_GETTEXT、WM_COPYDATA,系統自動分配使用可在進程間共享的內存映射文件來傳遞數據。

鍵盤輸入時需要明確插入符位置,相關API函數為:CreateCaret、SetCaretPos、ShowCaret、HideCaret、DestroyCaret、GetCaretPos、GetCaretBlinkTime、SetCaretBlinkTime。

3個獲得鍵狀態的函數:GetKeyState、GetAsyncKeyState、GetKeyboardState。

對於自定義的控件,當單擊子窗口時,父窗口會得到焦點。但對於標準子窗口控件,單擊時會自動獲得焦點(子窗口過程在WM_LBUTTONDOWN中實現了SetFocus(hwnd))。如果一個子窗口擁有輸入焦點,鼠標單擊另一個兄弟子窗口,則兄弟子窗口獲得輸入焦點。

同步與阻塞

Windows API函數SendMessage是個同步調用,即它發出的Windows消息沒被處理完之前這個函數就不返回。但這個函數不是阻塞的。分兩種情形:[5]

  • 如果被指定的窗口是發起調用SendMessage的線程創建的,則該線程立即執行窗口過程(window procedure);
  • 如果被指定的窗口不是發起調用SendMessage的線程創建的,操作系統切換到該窗口所屬的線程執行相應的窗口過程。消息處理完之前,發送線程不從調用SendMessage處返回,但發送線程這期間可以處理非隊列消息(nonqueued message)。這是「同步非阻塞」。

異步發送或投寄消息的函數,如PostMessage、SendMessageCallback、SendNotifyMessage,消息參數中不能使用指針,否則函數調用失敗。

GetMessage偽算法

BOOL GetMessage(MSG *lpMsg, HWND hWnd , UINT wMsgFilterMin, UINT wMsgFilterMax)
{
         //查看QS_SENDMESSAGE标志,如果有的话循环处理,直到没有消息位置
         DWORD dwRetVal = 0;
         ThreadInfo threadInfo;
 
FLAG_SENDPROCLOOP:
         GetThreadInfo(GetCurrentThreadId(), &threadInfo);
         while (threadInfo.QS_SENDMESSAGE == QS_SIGNALSET) {
                   //从发送消息队列中获取消息
                   dwReturnVal = GetMsgFromQueue(QUEUE_SEND, lpMsg, hWnd,wMsgFilterMin, wMsgFilterMax);
                   //判断是否取到消息,有则调用窗口函数,无则复为QS_SENDMESSAGE标志
                   If (dwReturnVal == GETMESSAGE_HASMESSAGE) {
                            //调用指定窗口的窗口函数
                            CallWindowProc(hWnd, &threadInfo, lpMsg);
                   }
                   else {
                            QS_SENDMESSAGE = QS_SIGNALRESET;
                            break;
                   }
         }
         //在继续处理之前再次检查发送消息队列
         if (threadInfo.QS_SENDMESSAGE == QS_SIGNALSET) goto FLAG_SENDPROCLOOP;
         //检查发送消息队列, 如果有消息则取发送消息
         //判断是否还有发送消息,没有了则复位QS_POSTMESSAGE标志
         if (threadInfo.QS_POSTMESSAGE == QS_SIGNALSET) {
                   dwReturnVal = GetMsgFromQueue(QUEUE_POST, lpMsg, hWnd, wMsgFilterMin, wMsgFilterMax);
                   if (dwReturnVal == GETMESSAGE_LASTMESSAGE)
                            threadInfo.QS_POSTMESSAGE = QS_SIGNALRESET;
                  
                   return TRUE;
         }       
 
         //如果退出标志被置位
         if (threadInfo.QS_QUIT == QS_SIGNALSET) {
                   threadInfo.QS_QUIT = QS_SIGNALRESET;
                   FillMessage(lpMsg, MESSAGE_QUIT);
                   return FALSE;
         }
 
         //检查输入消息队列
         if (threadInfo.QS_INPUT == QS_SIGNALSET) {
                   DWORD dwRetVal = GetMessageFromQueue(QUEUE_INPUT, lpMsg, hWnd, wMsgFilterMin, wMsgFilterMax);
                   //检查是否有键盘,鼠标消息
                   if (Test(dwRetVal, QS_KEY) == QS_LASTMOUSEKEYMESSAGE)
                            threadInfo.QS_KEY = QS_SIGNALRESET;
                   if (Test(dwRetVal, QS_MOUSEBUTTON) == QS_LASTMOUSEMESSAGE)
                            threadInfo.QS_MOUSEBUTTON = QS_SIGNALRESET;
 
                   return TRUE;
         }
 
         //测试QS_PAINT
         if (threadInfo.QS_PAINT == QS_SIGNALSET) {
                   //填充MSG,如果没有窗口过程确认窗口,则复位QS_PAINT标志
                   //...
                   //返回TRUE
                   threadInfo.QS_PAINT = QS_SIGNALRESET;
                   return TRUE;
         }
 
         if (threadInfo.QS_TIMER == QS_SIGNALSET) {
                   //填充MSG,如果没有定时器报时,则复位QS_TIMER标志
                   //...
                   //返回TRUE
                   return TRUE;
         }
 
         //等待有消息到达
         dwRetVal = MsgWaitForMultipleObjectsEx(...);
         if (...)
                   goto FLAG_SENDPROCLOOP;
 
         //等待失败
         return FALSE;
}

參考資料

  1. ^ GetMessage function. [2009-11-01]. (原始內容存檔於2008-04-12). 
  2. ^ PeekMessage function. [2009-11-01]. (原始內容存檔於2008-04-08). 
  3. ^ Bob Gunderson:《GetMessage and PeekMessage Internals》,Microsoft Developer Network Technology Group,December 11, 1992
  4. ^ (美)Jeffrey Richter:Programming Applications for Microsoft Windows, Microsoft Press,2000,Fourth edition,「第26章 窗口消息」,《Windows核心編程》中文版,機械工業出版社2008年5月1日。 ISBN:9787111237914.
  5. ^ MSDN:SendMessage function. [2017-11-29]. (原始內容存檔於2018-01-14). 

相關條目

外部連結