以及,DirectDraw 是这个星球上最屎的 API。
消息驱动应用的出现把软件开发的复杂度提升了一个数量级。消息驱动应用的开发者要处理来自用户、操作系统、其它应用、其它库和扩展、以及自己编写的代码产生的几百种事件。每种事件的个数和发生的顺序都几乎无法预测。预测无穷的组合需要逻辑,而开发过软件的人知道我们从来不用逻辑。
第一消息循环
学过 Win32,Cocoa,或者 QT 开发的人一定知道主消息循环(main event loop)这个概念。(如果你只会用 Java,那么可以学习一下这些东西再来往下看;或者马上放弃这篇文章,并且默念『线程、线程 ⋯⋯』。)主消息循环是一段类似下面的代码(Cocoa 的例子):

只熟悉 Win32 的人只要把 nextEventMatchingMask
当成 GetMessage()
,把 sendEvent
当成 SendMessage()
也可以大致看懂。nextEventMatchingMask
从消息队列里取出形形色色的消息 [1],以 sendEvent
为 call stack 发端调用到开发者编写的不同代码。在一个消息驱动程序里的大部分代码的 call stack 都会包含 sendEvent
或者 SendMessage
,没有运用多线程的应用(也就是代码都在主线程里运行 [2])这个比例会达到几乎 100% [3]。
大多数时候,除了在 debugger 的 call stack 里,是看不到这个消息循环的。它已经被封装到诸如 NSApplicationMain()
(Cocoa)或者 CApplication::Run()
(MFC)这样的函数中。为了和下面的描述对应,我们把这个不常见面的东西称为『第一消息循环』。
第二消息循环
讨论第二消息循环最好先明白它要解决什么问题。第二消息循环要解决的并非单一问题。其中比较重要的一个源自 Windows 3.x 时代。那时候 GUI 环境还是单线程,而且整个 Win16 系统只有一个单独的线程。所以,如果某一个程序处理按钮按下的代码一口气运行了五分钟,那么整个系统也会僵死五分钟。比如下面这个事件处理的片断:

所以,那时候非常『先进』的技术就是在运行时间很长的代码里不时调用 GetMessage()
和 SendMessage()
这对 API。这样就可以给其它的事件处理代码一个运行的机会。比如上面那段代码就会改成下面这样:

运行后会出现这样的 call stack(这个例子里另一个窗口的鼠标拖拽操作的消息得以有机会被处理):

第二消息循环未必是个字面意义上循环,因为 get event 和 send event 并不一定要放到循环里。但是一般来说都是这么做的,所以这个名称就沿用下来。『第二』不是指代码中出现的第二个消息循环,而是指在运行时出现在第一消息循环里的第二层循环(见下文的『第三消息循环』)。
陷阱!
所以,现在你的程序可以在按钮按下但是无法弹起(因为 onClick
不会很快返回)的这段时间里处理其它的事物了。听起来不错!但是也可能是个通向灾难的陷阱。你必须保证开启第二循环的函数在 sendEvent
调用的前后不会访问任何其它消息处理函数可能修改的数据结构。
马上你会意识到,如果对第二消息循环不加限制(就是像第一循环那么简单),那么在 send event 前后保护数据完整性是个不可能完成的任务。消息驱动的程序开发最重要的假设就是事件处理函数可以在某种程度上被认为是近乎原子的操作 [4]。而第二消息循环粗暴的打破了这个假设。如果第二消息循环和第一循环一样,那么任何事件处理函数都可能是被第一或第二循环调起,包括打开第二消息循环的函数,幸运的话你会拥会有第三消息循环!
所以第二消息循环的处理必须有严格限制。实际上,在 Win16 时代,用来防止整个系统锁死的第二消息循环是不会 get 和 send 任何属于同一个应用的事件的(也就是不能像例子中那样用 NSEventAnyMask)。如果为了防止用户产生程序僵死的印象,第二消息循环也会用来显示一个进度条,或者一个不断回旋的图标,以及重绘某些重要的窗口。但是对同一应用的消息处理也通常仅限于此。模态对话框也是通过第二消息循环完成,这个循环不会处理任何事件,除了对话框本身的事件和重绘其它窗口。所以,模态对话框的代码通常只会修改那些在打开对话框的时候新创建的数据结构,不会修改和访问程序的其它部分;而且任何窗口的重绘处理都要避免修改任何数据 [5]。
接下来,在后 Win16 时代,第二消息循环最主要的作用是处理鼠标拖拽(drag, drag and drop)。处理鼠标拖拽的第二消息循环只能处理鼠标的 move 和 drag 事件,并且遇到 mouse release 要立刻退出循环 [6]。即便如此,鼠标拖拽仍然是最容易导致应用崩溃的 use case。
今天我们有强占式多任务操作系统,有多线程,完全可以避免仅仅为了防止 UI 僵死而使用第二消息循环。在这方面有许多更好的替代方案。一种做法是把耗时的任务放到主线程之外的其它线程。线程提供了更完备的同步保护操作。在使用第二消息循环的情况下模拟线程的同步保护不是不可能,比如,在 sendEvent 的前面设置一个 flag,后面清空这个 flag,然后在其它事件处理函数里加上这种代码:

但是这样做会让同步保护的操作和要保护的数据在代码中分离。你会越来越无法看清你真正要保护的是什么。最终你只能假定第二消息循环会破坏『一切』数据,让后在『一切』事件处理函数中加上这样的保护。
另一种方法是把很耗时的操作切分成多段,把每段放到一个 timer 里运行 [7]。Timer 的好处在于每个消息处理函数和 timer 函数都是原子操作(除去有其它线程干扰的情形)。缺点在于在每次 timer 运行之间要保持中间数据,并且正确处理有可能被其它事件处理函数修改的数据结构,不过以函数为边界来保证数据结构处于完整的状态的做法带来的复杂度仍然相对低廉。
所以,你没有控制全局!
不加控制的第二消息循环是灾难。有人会傻到使用它吗?用用 Windows 的 DirectDraw 你就知道了。DirectDraw 的很多 API 函数都会在内部开启第二消息循环。使用 DirectDraw 的开发者根本无法对这个循环做任何控制。所以用 DirectDraw 根本没法编写一个安全的应用程序。
举个例子,如果你的程序里对于某个控件的鼠标单击和双击分别对一个 DirectDraw 的内容进行不同的操作,恭喜你!首先,在 DriectDraw 之外,Windows 会为你做一件事情:在用户一次双击鼠标的时候会分别触发一个单击消息和一个双击消息。此时你的单击消息处理函数会首先被调用,如果里面调用了 DirectDraw 的 API,那么很有可能这个 API 会开启第二消息循环,后者会调用你的双击消息处理函数。你应该用什么样的 mental model 来设计这两个函数?最简单的方案就是用 crash 来教育用户!
所以,别把 DirectDraw 嵌入到你的文件浏览器类型的应用程序或者 email client 里。在这种有复杂 UI 交互的应用里使用 DirectDraw 是自寻死路。DirectDraw 是给全屏程序和一次播放一个电影的播放器用的(即使有问题,用户体验的是电影而不是你的 UI,他们没空动鼠标,只会在上厕所之前按空格)。
结论
今天,只有模态对话框和鼠标拖拽还需要使用第二消息循环。长时间的操作应该使用 timer 和多线程避免 UI 锁死。为了降低复杂度,即使使用 timer 和多线程避免 UI 锁死,也应该尽可能在此期间限制用户操作(比如用模态对话框显示进度条,禁止『取消当前操作』之外的一切 UI 操作。如果不喜欢模态对话框,可以在主窗口显示操作状态并在用户发出其它 UI 操作的时候自动取消后台任务(大多数文件浏览器都在后台遍历当前文件夹,用户转向其它文件夹的时候自动取消操作)。如果后台任务和用户的 UI 操作必须同时进行,那么,祝你好运。
脚注
- 消息队列的消息产生于何处对开发者来说并不可见。一般只要理解成用户的各种操作会自然的产生队列中的各种事件就好。良好的设计原则是对消息在队列中是否重复以及顺序如何尽量不做假设。
- 这里说的应用代码不包括 Cocoa 这样的 framework,Cocoa 和 QT 自己都会创建一些线程。
- 同理不包括 framework 本身。
- 这是因为大多数事件驱动的 framework 都把事件处理放到单一的主线程,即使有多个窗口也是如此。曾经有一个特例 —— BeOS,每个窗口拥有自己的独立线程。BeOS 的开发复杂度也是它消失的原因之一。Win32 可以有多个线程拥有独立的消息队列和消息循环。不过实际中很少用到。
- Mac OS X 里面早就提供 buffered window,Windows 终于从 Vista 开始提供,从此窗口的重绘不再单单依靠开发者编写的重绘代码(在内容没有变化的时候)。所以重绘消息现在通常被排除在第二消息循环需要处理的消息之外。
- 事实上,处理鼠标拖拽的循环的目的就在于过滤信息,而并非改变应用的响应行为和代码运行顺序。但是这种做法沿袭了 Win16 时代为了提高响应敏捷度的方法的副作用。
- Timer 这种方法用 Win16 的系统就足以实现。但是 timer 要求用户编写的第一消息循环在程序比较空闲的时候能够调用 timer 处理函数。在 Win16 时代,第一消息循环主要还是手工编写,所以 timer 这种方式和手工编写第二消息循环相比并不省事。今天第一消息循环都被封装在各种 framework 里,而且基本都内建对 timer 的支持。