GUI 开发中的事件驱动机制

前置知识

并发

很显然,您手头正在使用的计算机允许你同时运行好几个应用程序,这归功于您手头正在使用的计算机的操作系统支持“并发”(concurrency)。多个应用程序被抽象为多个进程,然后由操作系统来调度这多个进程。每个进程会被操作系统分配一个时间片,即每次被 CPU 选中来执行当前进程所用的时间。由于 CPU 的频率太高,进程之间的切换并不会让用户感到卡顿,所以给人一种“几个应用程序同时在运行”的感觉。

一个进程可以拥有多个线程(Thread),不同的线程完成不同的任务,一个进程中线程之间的调度同样也是分配时间片、切换线程的模式。

值得强调的是,进程与线程的调度是由操作系统进行的。

  • 同步与异步

进程间存在相互制约的关系。例如,进程 A 的某一个任务需要某一个资源才能继续进行,而这个资源需要进程 B 释放后进程 A 才能使用。那么,这之间就存在进程调度的问题。这里有进程同步与进程异步两种方案:

async

在进程同步(Synchronous Processing)中,进程 A 等待进程 B 的回应,期间不执行任务。在进程异步(Asynchronous Processing)中,进程 A 在等待进程 B 的过程中仍然在处理任务。

回调

回调函数作为另一个函数的参数被传递过去。回调函数作为形式参数可以被别的函数所调用。

有时,我们希望在程序中某个耗时任务结束后执行一个函数。以上面的异步进程方案为例,我希望进程 A 在收到来自进程 B 的回应后进行相关的处理。

例如在 javascript 中:

1
2
3
4
function callback(){
// do something...
}
setTimeout(callback,1000);//it means: call callback after 1000ms

setTimeout 函数在计时器达到 1000ms 后调用 callback 函数。在这里我们可以看到,调用函数的主体并不是应用程序,而是负责底层运作的其他程序。在同步异步编程中,我们常常用到回调函数。

事件驱动

什么是事件驱动

也许您之前编写的程序的工作流程是这样的:

打开程序 -> 程序执行 -> 执行完毕,自行退出

对于一个事件驱动的程序而言,其工作流程是这样的:

打开程序 -> 程序初始化后等待用户操作 -> 用户操作,执行相应代码,继续等待用户操作 -> 用户退出,程序结束

我们可以看到,对于一个事件驱动的程序而言,他的程序的执行并不是按照某个既定顺序的。事件驱动的程序面向收到的事件编程:某个事件产生了,应用程序做出相应的回应。

事件驱动的实例:消息循环

下面这张图展示了一个事件驱动型系统的事件分发模型。

event_driven

具体到 Windows 下的 GUI 开发而言,Event Producers 可以是鼠标点击、键盘按下这样的事件,也可以是定时器事件(到了某一个特定的时间点,产生一个事件)。在 Windows 操作系统中,每一个线程存在一个消息队列,操作系统来维护线程的消息队列,各类事件的分发都由操作系统来执行。在这里,Windows 操作系统担任了 Event Ingestion 的角色。最后,线程在自己的消息队列中通过操作系统接口获得 Windows 操作系统为其提供的 Events,并将其进行处理,担任了 Event Consumers 的角色。

下面是 Win32 SDK 提供的 GetMessage函数的使用例子,这里的程序代码被真实地使用在 GUI 开发中。(细节被省略了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int APIENTRY wWinMain(args...)
{
prepare();

MSG msg;
// 主消息循环:
while (GetMessage(&msg, nullptr, 0, 0))
{
if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
return (int) msg.wParam;
}

前面的代码看起来不知所云,我们只需要关心最后几行,也就是“主消息循环”的部分:GetMessage让线程得以从消息队列中获取消息(传递到变量 msg 中,注意到这里是引用传递)。如果获得的是退出的消息,那么GetMessage将会返回 0 让程序退出消息循环。如果没有得到消息,那么GetMessage函数将会阻塞当前线程(也就是让当前线程“睡着”),不返回值。

一个典型的事件驱动的程序,包含一个不断执行循环(在上面这个例子中,就是消息循环),并以一个线程的形式存在,这个循环包括两个部分,第一个部分是按照一定的条件接收并选择一个要处理的事件,第二个部分就是事件的处理过程。程序的执行过程就是选择事件和处理事件,而当没有任何事件触发时,程序会因查询事件队列失败而进入睡眠状态,从而释放cpu。接下来是程序处理事件的程序段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

//
// 函数: WndProc(HWND, UINT, WPARAM, LPARAM)
//
// 目标: 处理主窗口的消息。
//
// WM_COMMAND - 处理应用程序菜单
// WM_PAINT - 绘制主窗口
// WM_DESTROY - 发送退出消息并返回
//
//
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;
}

这里的 WndProc 函数是一个事件处理函数,如同他前面标注的宏一样,他是一个 CALLBACK 函数(回调函数)。消息循环在获得消息后,该条消息就会被翻译、分发,分发函数再进行处理后回调 WndProc 函数处理事件,在程序段中,你可以看到该函数利用 switch 语句进行了事件类型的判别,并且对不同的事件类型做出了不同的回应。

async / await:对事件驱动机制的进一步封装

以下内容需要您有 JavaScript 编程的经验

JavaScript 与 C# 支持 async/await 操作,这两个语言又正好是 GUI 开发的主力语言。这里笔者以 JavaScript 的 async/await 操作为例,表明其与事件驱动机制的关联。下面是一则 JavaScript 拉取数据的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13

async function handleMessage() {
try {
let data = await useAPI('url...')
//handle data...
return ...
} catch {
//handle error...
return ...
}
// useAPI 异步,他从网络的某个url拉取数据后将数据返回给变量data
}

JavaScript 引擎单线程,也就意味着跑在某个 JS 引擎上的一段代码不会并发,只会运行一处。事实上,JavaScript 的所有耗时异步操作全部由embedder(例如 libuv 等)提供支持,JS 自身只从 embedder 提供的 callback queue 中获得事件。

js_event_queue

js 引擎从 embedder 提供的 callback queue 获取事件并进行处理的机制,就叫做 event loop 机制。QuickJS 对 event loop 机制的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* main loop which calls the user JS callbacks */
void js_std_loop(JSContext *ctx)
{
JSContext *ctx1;
int err;

for(;;) {
/* execute the pending jobs */
for(;;) {
err = JS_ExecutePendingJob(JS_GetRuntime(ctx), &ctx1);
if (err <= 0) {
if (err < 0) {
js_std_dump_error(ctx1);
}
break;
}
}

if (!os_poll_func || os_poll_func(ctx))
break;
}
}

os_poll_func与上文提到的 GetMessage有着类似的行为。你会惊讶的发现,这两者的底层实现完全同构,只是 JavaScript 的事件循环机制被引擎给封装好了。

我们回到上面关于 async/await 的讨论。首先,被挂上了 async 的函数,其最终返回的会是一个 Promise 对象。顾名思义,所以所有挂了 async 的函数,在调用时,会被当作一个异步函数对待。

JavaScript 的 async/await 机制高妙的地方在于,他用 await 符阻塞了当前代码块,让原先本应当是回调函数的部分被以顺序控制流的形式展现出来。我们用另外两种代码书写方式(callback,链式调用)来重新书写以上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

//回调

function callbackStyle() {
function handleData(){
//...
}
function handleError() {
//...
}
useAPI({url: 'url...',success: handleData,failed: handleError});
}

//链式调用

function chainStyle() {
uesAPI('url').then(()=>{
//handle data...
}).catch(()=>{
//handle error...
}).finally(()=>{
//...
})
}

//async/await 调用

async function awaitAsyncStyle() {
try {
let data = await useAPI('url...')
//handle data...
return ...
} catch {
//handle error...
return ...
}
// useAPI 异步,他从网络的某个url拉取数据后将数据返回给变量data
}

我们可以看到,await/async 机制让 JavaScript 用户不感受到自己正在写回调函数,在编写者看来,他们似乎利用 await 阻塞了当前的控制流。然而实际上,后面的代码块是作为回调函数出现的。

可以说,JS 利用 await/async 机制(或者 Promise 链式调用机制),将消息循环(事件驱动机制)和回调屏蔽了,用户可以不和这两个东西打交道。但是理解 JS 的事件驱动机制的原理仍然是很重要的。