GUI 开发中的事件驱动机制
前置知识
并发
很显然,您手头正在使用的计算机允许你同时运行好几个应用程序,这归功于您手头正在使用的计算机的操作系统支持“并发”(concurrency)。多个应用程序被抽象为多个进程,然后由操作系统来调度这多个进程。每个进程会被操作系统分配一个时间片,即每次被 CPU 选中来执行当前进程所用的时间。由于 CPU 的频率太高,进程之间的切换并不会让用户感到卡顿,所以给人一种“几个应用程序同时在运行”的感觉。
一个进程可以拥有多个线程(Thread),不同的线程完成不同的任务,一个进程中线程之间的调度同样也是分配时间片、切换线程的模式。
值得强调的是,进程与线程的调度是由操作系统进行的。
- 同步与异步
进程间存在相互制约的关系。例如,进程 A 的某一个任务需要某一个资源才能继续进行,而这个资源需要进程 B 释放后进程 A 才能使用。那么,这之间就存在进程调度的问题。这里有进程同步与进程异步两种方案:
在进程同步(Synchronous Processing)中,进程 A 等待进程 B 的回应,期间不执行任务。在进程异步(Asynchronous Processing)中,进程 A 在等待进程 B 的过程中仍然在处理任务。
回调
回调函数作为另一个函数的参数被传递过去。回调函数作为形式参数可以被别的函数所调用。
有时,我们希望在程序中某个耗时任务结束后执行一个函数。以上面的异步进程方案为例,我希望进程 A 在收到来自进程 B 的回应后进行相关的处理。
例如在 javascript 中:
1 | function callback(){ |
setTimeout 函数在计时器达到 1000ms 后调用 callback
函数。在这里我们可以看到,调用函数的主体并不是应用程序,而是负责底层运作的其他程序。在同步异步编程中,我们常常用到回调函数。
事件驱动
什么是事件驱动
也许您之前编写的程序的工作流程是这样的:
打开程序 -> 程序执行 -> 执行完毕,自行退出
对于一个事件驱动的程序而言,其工作流程是这样的:
打开程序 -> 程序初始化后等待用户操作 -> 用户操作,执行相应代码,继续等待用户操作 -> 用户退出,程序结束
我们可以看到,对于一个事件驱动的程序而言,他的程序的执行并不是按照某个既定顺序的。事件驱动的程序面向收到的事件编程:某个事件产生了,应用程序做出相应的回应。
事件驱动的实例:消息循环
下面这张图展示了一个事件驱动型系统的事件分发模型。
具体到 Windows 下的 GUI 开发而言,Event Producers 可以是鼠标点击、键盘按下这样的事件,也可以是定时器事件(到了某一个特定的时间点,产生一个事件)。在 Windows 操作系统中,每一个线程存在一个消息队列,操作系统来维护线程的消息队列,各类事件的分发都由操作系统来执行。在这里,Windows 操作系统担任了 Event Ingestion 的角色。最后,线程在自己的消息队列中通过操作系统接口获得 Windows 操作系统为其提供的 Events,并将其进行处理,担任了 Event Consumers 的角色。
下面是 Win32 SDK 提供的 GetMessage
函数的使用例子,这里的程序代码被真实地使用在 GUI 开发中。(细节被省略了)
1 | int APIENTRY wWinMain(args...) |
前面的代码看起来不知所云,我们只需要关心最后几行,也就是“主消息循环”的部分:GetMessage
让线程得以从消息队列中获取消息(传递到变量 msg 中,注意到这里是引用传递)。如果获得的是退出的消息,那么GetMessage
将会返回 0 让程序退出消息循环。如果没有得到消息,那么GetMessage
函数将会阻塞当前线程(也就是让当前线程“睡着”),不返回值。
一个典型的事件驱动的程序,包含一个不断执行循环(在上面这个例子中,就是消息循环),并以一个线程的形式存在,这个循环包括两个部分,第一个部分是按照一定的条件接收并选择一个要处理的事件,第二个部分就是事件的处理过程。程序的执行过程就是选择事件和处理事件,而当没有任何事件触发时,程序会因查询事件队列失败而进入睡眠状态,从而释放cpu。接下来是程序处理事件的程序段:
1 |
|
这里的 WndProc
函数是一个事件处理函数,如同他前面标注的宏一样,他是一个 CALLBACK
函数(回调函数)。消息循环在获得消息后,该条消息就会被翻译、分发,分发函数再进行处理后回调 WndProc
函数处理事件,在程序段中,你可以看到该函数利用 switch
语句进行了事件类型的判别,并且对不同的事件类型做出了不同的回应。
async / await:对事件驱动机制的进一步封装
以下内容需要您有 JavaScript 编程的经验
JavaScript 与 C# 支持 async/await 操作,这两个语言又正好是 GUI 开发的主力语言。这里笔者以 JavaScript 的 async/await 操作为例,表明其与事件驱动机制的关联。下面是一则 JavaScript 拉取数据的代码。
1 |
|
JavaScript 引擎单线程,也就意味着跑在某个 JS 引擎上的一段代码不会并发,只会运行一处。事实上,JavaScript 的所有耗时异步操作全部由embedder(例如 libuv 等)提供支持,JS 自身只从 embedder 提供的 callback queue 中获得事件。
js 引擎从 embedder 提供的 callback queue 获取事件并进行处理的机制,就叫做 event loop 机制。QuickJS 对 event loop 机制的实现如下:
1 | /* main loop which calls the user JS callbacks */ |
os_poll_func与上文提到的 GetMessage
有着类似的行为。你会惊讶的发现,这两者的底层实现完全同构,只是 JavaScript 的事件循环机制被引擎给封装好了。
我们回到上面关于 async/await 的讨论。首先,被挂上了 async 的函数,其最终返回的会是一个 Promise 对象。顾名思义,所以所有挂了 async 的函数,在调用时,会被当作一个异步函数对待。
JavaScript 的 async/await 机制高妙的地方在于,他用 await 符阻塞了当前代码块,让原先本应当是回调函数的部分被以顺序控制流的形式展现出来。我们用另外两种代码书写方式(callback,链式调用)来重新书写以上代码:
1 |
|
我们可以看到,await/async 机制让 JavaScript 用户不感受到自己正在写回调函数,在编写者看来,他们似乎利用 await 阻塞了当前的控制流。然而实际上,后面的代码块是作为回调函数出现的。
可以说,JS 利用 await/async 机制(或者 Promise 链式调用机制),将消息循环(事件驱动机制)和回调屏蔽了,用户可以不和这两个东西打交道。但是理解 JS 的事件驱动机制的原理仍然是很重要的。