基于消息的事件驅動機制(Message Based, Event Driven)
本文轉載自微信公眾號「一個程序員的修煉之路」,作者地下潛行者。轉載本文請聯系一個程序員的修煉之路公眾號。
1. 基本模型概述
基于消息的事件驅動機制是一個通用模型,廣泛應用于桌面軟件開發、網絡應用程序開發、前端開發等技術方向中。本文主要描述基本模型、基本框架,用于說明不同技術的共性知識。可以理解為外部操作事件,被轉化為消息存放于隊列中;而每種類型的消息都有對應的處理;通過消息循環,完成讀消息、調用消息處理這個過程。這個過程,只要應用不退出,會一直進行下去。下圖的模型從Windows應用程序而來,但是具有一定的通用性。
2. 模型在MFC程序中的應用
MFC(Microsoft Foundation Classes)是微軟的基礎類庫,對大部分的Windows API進行封裝,同時也是桌面軟件的UI開發框架,下圖是一個用VS2019自動生成的MFC多文檔應用。不用做任何開發工作,就可以得到一個自帶菜單欄、工具欄、狀態欄、屬性展示框等豐富的界面框架。不過現在MFC已經沒落,除了歷史項目,已經很少有新項目,采用MFC。下文會基于鼠標點擊后完整的系統響應過程,說明該模型在MFC中的體現。
2.1 從鼠標點擊到響應處理的完整過程
1.用戶點擊鼠標;
2.鼠標驅動產生鼠標點擊消息(通過中斷實現),進行系統消息隊列;
3.系統消息轉換為應用程序消息,放入應用程序隊列;
4.消息泵從應用程序消息隊列中讀取消息;
5.消息派發及處理,借助USER模塊,將消息派發至對應窗口的對應消息處理函數;
問題:為什么消息處理函數中不能做長耗時的任務?
消息泵處理消息時是依次處理,處理完一條消息后,再處理下一條消息。如果當前消息的處理事件過長,會導致后續的消息無法得到及時響應,會導致界面卡頓等非常不佳的用戶體驗。
2.2 事件類型
1) 鼠標點擊(單擊、雙擊、右擊)
2) 鍵盤按鍵
3) 用戶在觸摸屏上的點擊事件
4) …
用戶在電腦上的各種操作,對應到各種事件類型、不同的事件類型,會被轉換為不同的消息。
2.3 消息定義
用戶操作事件,會被轉化為消息。消息定義如下:
- /*
- * Message structure
- */
- typedef struct tagMSG {
- HWND hwnd; //接受消息的窗口句柄
- UINT message; //消息常量標識符(消息號)
- WPARAM wParam; //32位消息特定附加信息
- LPARAM lParam; //32位消息特定附加信息
- DWORD time; //消息創建時的時間
- POINT pt; //消息創建時的光標位置
- #ifdef _MAC
- DWORD lPrivate;
- #endif
- } MSG
微軟有提供一系列的消息定義,用戶也可以自定義消息,進行應用程序的開發。
windows 消息類型可以分為以下兩大類:
(1)系統消息:范圍在[0x0000,0x03ff]之間,細分為三小類:
- 窗口消息:與窗口運作有關,窗口創建,窗口繪制,窗口移動,窗口銷毀;
- 命令消息:一般指WM_COMMAND消息,與處理用戶請求有關,通常由控件或者菜單產生。
- 通知消息:特指WM_NOTIFY消息。通常指一個窗口內的子控件發生了一些事情,需要通知父窗口。
微軟官方鏈接,給出了系統消息的范圍:
The system reserves message-identifier values in the range 0x0000 through 0x03FF (the value of WM_USER – 1) for system-defined messages. Applications cannot use these values for private messages.
(2)應用定義的消息
- WM_USER : 【0X0400-0X7FFF】, 用戶自定義的消息范圍。
- WM_APP : 【0X8000-0XBFFF】,用于程序之間的消息通信。
- RegisterWindowMessage :【0XC000-0XFFFF】
微軟官方內容,給出了應用消息的取值范圍:
Values in the range 0x0400 (the value of WM_USER) through 0x7FFF are available for message identifiers for private window classes.
If your application is marked version 4.0, you can use message-identifier values in the range 0x8000 (WM_APP) through 0xBFFF for private messages.
The system returns a message identifier in the range 0xC000 through 0xFFFF when an application calls the RegisterWindowMessage function to register a message. The message identifier returned by this function is guaranteed to be unique throughout the system. Use of this function prevents conflicts that can arise if other applications use the same message identifier for different purposes.
2.4 消息處理映射表(事件處理綁定)
消息處理映射表指每個消息對應的處理函數。只有先做好映射表,當消息到達時,消息泵才知道怎么處理該消息。
2.4.1 Win32應用程序中的消息處理映射表
WndProc為消息處理函數,代碼內部通過switch case,給不同的消息指定不同的處理函數。
- LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
- {
- switch (message)
- {
- case WM_COMMAND:
- {
- int wmId = LOWORD(wParam);
- // 分析菜單選擇:
- switch (wmId)
- {
- case IDM_ABOUT:
- DialogBox(hInst, MAKEINTRESOURCE(IDD_ABOUTBOX), hWnd, About);
- break;
- case IDM_EXIT:
- DestroyWindow(hWnd);
- break;
- default:
- return DefWindowProc(hWnd, message, wParam, lParam);
- }
- }
- break;
- case WM_PAINT:
- {
- PAINTSTRUCT ps;
- HDC hdc = BeginPaint(hWnd, &ps);
- // TODO: 在此處添加使用 hdc 的任何繪圖代碼...
- EndPaint(hWnd, &ps);
- }
- break;
- case WM_DESTROY:
- PostQuitMessage(0);
- break;
- default:
- return DefWindowProc(hWnd, message, wParam, lParam);
- }
- return 0;
- }
2.4.2 MFC中的消息處理映射表
在如下代碼中可以看到,WINDOWS消息WM_CREATE,對應的消息處理函數為OnCreate.當消息到達時,消息泵知道去調用OnCreate函數。
宏BEGIN_MESSAGE_MAP,END_MESSAGE_MAP就是用于定義消息映射表的。
- BEGIN_MESSAGE_MAP(CFileView, CDockablePane)
- ON_WM_CREATE()
- ...
- END_MESSAGE_MAP()
- #define ON_WM_CREATE() \
- { WM_CREATE, 0, 0, 0, AfxSig_is, \
- (AFX_PMSG) (AFX_PMSGW) \
- (static_cast< int (AFX_MSG_CALL CWnd::*)(LPCREATESTRUCT) > ( &ThisClass :: OnCreate)) },
2.5 消息泵(Windows應用程序)
消息泵負責從應用程序的消息隊列中讀取消息、轉換消息、派發消息。
- MSG msg;
- // 主消息循環:
- while (GetMessage(&msg, nullptr, 0, 0))
- {
- if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg))
- {
- TranslateMessage(&msg);
- DispatchMessage(&msg);
- }
- }
以上出現的函數都是Windows API 函數
- GetMessage 從消息隊列中讀取消息
- TranslateMessage 消息翻譯、轉換。
- DispatchMessage 派發消息、找到消息對應的窗口、調用響應函數
2.6 消息隊列
(1)系統消息隊列:這是系統唯一隊列,設備驅動把用戶的操作輸入轉化成消息存放于系統隊列中,然后系統會把此消息放到目標窗口所在的線程消息隊列中等待處理。
(2)線程消息隊列:每一個GUI線程都會維護一個線程消息隊列,然后線程消息隊列中的消息會被送到相應的窗口過程處理。
消息隊列并不可以直接訪問,但是我們可以通過指定接口去訪問消息隊列。
- PostMessage函數,用于向消息隊列中追加消息,并立即返回;
- GetMessage函數,用于從消息隊列中讀取消息;
2.7 Windows消息攔截機制
上文介紹Windows消息的產生、讀取、派發處理等,其實用戶可以通過Windows的消息攔截機制,對消息到達目標窗體之前進行提前處理。這主要通過Windows的Hook機制實現。常用的調試工具SPY++,就是利用HOOK機制截獲窗口消息。
此處只做介紹,不做詳細深入。
2.8 模態對話框和非模態對話框的區別
模態對話框:在子界面活動期間,父窗口是無法進行消息響應。獨占用戶輸入
- 非模態對話框:各窗口之間不影響。
- 模態對話框通過在消息循環內再造消息循環。如果當前窗口內的消息循環不退出,父窗口的消息循環將無法運轉,也即無法響應。從而產生模態對話框獨占響應的效果。
3. 模型在瀏覽器中的應用
在網頁應用程序開發中(前端開發),用戶的點擊操作產生事件,同時在網頁應用程序中進行處理響應。瀏覽器應用,同樣適用于該模型。
3.1 事件類型
1)用戶在某個元素上點擊鼠標或懸停光標。
2)用戶在鍵盤中按下某個按鍵。
3)用戶調整瀏覽器的大小或者關閉瀏覽器窗口。
4)提交表單。
5)…
完整的瀏覽器事件清單,可以參考如下鏈接:
https://developer.mozilla.org/en-US/docs/Web/Events
3.2 事件綁定
在如下示例中,對HTML的DOM元素中進行事件綁定,增加了click事件響應。當用戶點擊該div的時候,響應函數就會執行。瀏覽器中有多種事件綁定方式,此處只用addEventListener,作為示例說明。
3.3 事件傳播
用戶在點擊div后,事件會按照 捕獲階段、目標階段、冒泡階段的過程進行處理。用戶可以通過addEventListener中useCapture字段,決定事件的捕獲階段。
- true - 捕獲階段執行事件響應函數
- false- 冒泡階段執行事件響應函數
3.4 事件循環
事件循環之所以稱之為事件循環,是因為它經常按照類似如下的方式來被實現:
- while (queue.waitForMessage()) {
- queue.processNextMessage();
- }
queue.waitForMessage() 會同步地等待消息到達(如果當前沒有任何消息等待被處理)。
該段內容來自于鏈接:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/EventLoop
3.5 任務隊列
Javascript腳本的執行環境是單線程的,所以必定存在一個任務隊列用于依次存放待響應任務。即3.4章節中的queue.
4. 模型在網絡應用程序中的應用
4.1 點對點的網絡應用程序工作過程
一個服務端角色,一個客戶端角色的兩個進程之間建立通信的完成過程,如下文所述。
4.1.1 服務端
1)創建SOCKET;
2)綁定IP:Port;
3)SOCKET進入監聽模式;
4)等待外部連接請求進入,如果有,建立連接;
5)數據讀寫處理;
6)處理結束,關閉連接。
4.1.2 客戶端
1)創建SOCKET;
2)向指定的IP:Port發起連接請求,并建立連接;
3)發送數據/接收數據;
4)處理結束,關閉連接。
問題:當一臺機器有10W,乃至更多的并發網絡連接,如何處理?
一個線程處理一個SOCKET連接?(大量的線程,會導致CPU資源花在線程切換上,而不是真正的有效工作)
通過SELECT周期性輪詢所有SOCKET,檢查是否可讀、可寫?(主動遍歷所有SOCKET集合,當SOCKET基數特別大、活躍量少的時候,低效。SELECT本身也有數量限制)
通過事件通知,只處理活躍的局部少量SOCKET (參考CPU中斷處理、高效)
4.2事件清單
網絡應用程序中存在一些基本的事件以及圍繞這些事件開展的處理。在陳碩的書籍《Linux多線程服務器端編程》有介紹三個半事件。
1)連接建立,包含服務端接收新連接、客戶端發起連接;
2)連接斷開,包括主動斷開、被動斷開;阿
3)消息到達,表示有數據到緩沖區,可以讀,拷貝到用戶自己控制的緩沖區中;
4)消息發送完畢,算半個事件。
開發人員應針對指定事件,開發對應的處理函數,并通過引擎完成事件處理。
4.3 事件處理引擎
目前操作系統層面提供了高效的網絡通信處理機制,不同的語言也提供了各種類庫。
4.3.1 操作系統層支持
1)Windows IOCP
2)CentOS Epoll
3)xxxBSD kqueue
4.3.2 語言層面的框架支持
1)C/C++ libevent/Muduo/Asio/…
2)Java Netty
3)DotNet DotNetty
4.3.3 Epoll機制說明
1)創建Epoll實例句柄:可以理解為管理其他socket的領頭羊;
2)事件注冊:為每個SOCKET要關注的事件進行注冊,服務端監聽SOCKET
主要關注有沒有新的連接進來;
一般性SOCKET關注是否有數據進來,需要讀取;
超時,事件處理;
…
3)進入等待狀態,有事件進來時,操作系統會進行通知;
4)事件處理,根據操作系統的通知,應用程序進行反饋,調用對應事件的處理函數進行響應。
由于操作系統層面的支持,系統反饋時,只對活躍的SOCKET進行處理,數據量少,檢查量少,處理量也少。因此可以處理大量socket并發。
能夠這么做,是因為網絡應用程序進行數據收發,必然存在網絡延遲,所以才可以這么處理。如果每個SOCKET都是滿負荷運作,那么這種機制也不
能用于大量的連接處理。
4.3.4 Muduo網絡庫說明
Muduo是由陳碩編寫的,基于Epoll,采用Reactor模式開發的開源網絡通信庫。
Reactor模式稱為反應堆模型,是指有一個循環的過程,不斷監聽對應事件是否觸發,事件觸發時調用對應的 callback 進行處理。
如下圖所示:
所有的客戶端連接請求事件都由acceptor處理,并建立新的連接;
所有已建立的連接,按照讀數據、解碼、處理、編碼、數據發送返回的過程進行處理。其中數據讀寫,由反應堆根據事件進行處理。
Muduo的詳細說明,可以參考如下文檔:
https://www.cyhone.com/articles/analysis-of-muduo/
4.3.5 基于Muduo的網絡應用程序開發模式
1)建立一個事件循環器EventLoop(也可以理解為消息泵)
2)建立對應的服務器TcpServer
3)設置TcpServer的Callback(可以理解為建立事件處理映射表)
4)啟動server
5)開啟事件循環,進行事件處理。
此處的消息隊列,可以理解為由操作系統返回的待處理SOCKET及其對應事件的清單。
5. 總結
通過上文可以看出,在不同的技術方向上,其實是可以挖掘出通性技術,并進行學習的。因此我做了如下歸納:
1)不同技術,采用類似設計思路
2)研究共性,便于知識觸類旁通
3)細節差異,通過工程實踐掌握
6. 參考資料
1. 微軟官方關于消息及其隊列的介紹: https://docs.microsoft.com/en-us/windows/win32/winmsg/about-messages-and-message-queues#application-defined-messages
2. Muduo細節: https://github.com/chenshuo/muduo