原文链接:https://cloud.tencent.com/developer/article/1857333
一、 基本模型概述
基于消息的事件驱动机制是一个通用模型,广泛应用于桌面软件开发、网络应用程序开发、前端开发等技术方向中。本文主要描述基本模型、基本框架,用于说明不同技术的共性知识。可以理解为外部操作事件,被转化为消息存放于队列中;而每种类型的消息都有对应的处理;通过消息循环,完成读消息、调用消息处理这个过程。这个过程,只要应用不退出,会一直进行下去。下图的模型从Windows应用程序而来,但是具有一定的通用性。
二、模型在MFC程序中的应用
MFC(Microsoft Foundation Classes)是微软的基础类库,对大部分的Windows API进行封装,同时也是桌面软件的UI开发框架,下图是一个用VS2019自动生成的MFC多文档应用。不用做任何开发工作,就可以得到一个自带菜单栏、工具栏、状态栏、属性展示框等丰富的界面框架。不过现在MFC已经没落,除了历史项目,已经很少有新项目,采用MFC。下文会基于鼠标点击后完整的系统响应过程,说明该模型在MFC中的体现。
2.1 从鼠标点击到响应处理的完整过程
-
用户点击鼠标;
-
鼠标驱动产生鼠标点击消息(通过中断实现),进行系统消息队列;
-
系统消息转换为应用程序消息,放入应用程序队列;
-
消息泵从应用程序消息队列中读取消息;
-
消息派发及处理,借助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.1 事件类型
-
用户在某个元素上点击鼠标或悬停光标。
-
用户在键盘中按下某个按键。
-
用户调整浏览器的大小或者关闭浏览器窗口。
-
提交表单。
-
…
完整的浏览器事件清单,可以参考如下链接:
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.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及其对应事件的清单。
五. 总结
通过上文可以看出,在不同的技术方向上,其实是可以挖掘出通性技术,并进行学习的。因此我做了如下归纳:
1)不同技术,采用类似设计思路
2)研究共性,便于知识触类旁通
3)细节差异,通过工程实践掌握
参考资料:
微软官方关于消息及其队列的介绍
Muduo细节
为什么几乎所有的GUI界面都采用事件驱动编程模型?
基于小型单片机的菜单事件驱动框架