Windows的应用程序一般包含窗口(Window),它主要为用户提供一种可视化的交互方式,窗口是由线程(Thread)创建的。Windows系统通过消息机制来管理交互,消息(Message)被发送,保存,处理,一个线程会维护自己的一套消息队列(Message Queue),以保持线程间的独占性。队列的特点无非是先进先出,这种机制可以实现一种异步的需求响应过程。
消息的是什么样子的?
消息由一个叫MSG的结构体定义,包括窗口句柄(HWND),消息ID(UINT),参数(WPARAM, LPARAM)等等:
- struct MSG
- {
- HWND hwnd;
- UINT message;
- WPARAM wParam;
- LPARAM lParam;
- DWORD time;
- POINT pt;
- };
消息ID是消息的类型标识符,由系统或应用程序定义,消息ID为消息划分了类型。同时,也可以看出消息是对应于特定的窗口(窗口句柄)的。
消息是如何分类的?其前缀都代表什么含义?
消息ID只是一个整数,Windows系统预定义了很多消息ID,以不同的前缀来划分,比如WM_*,CB_*等等。
具体见下表:
Prefix Message category ABM Application desktop toolbar BM Button control CB Combo box control CBEM Extended combo box control CDM Common dialog box DBT Device DL Drag list box DM Default push button control DTM Date and time picker control EM Edit control HDM Header control HKM Hot key control IPM IP address control LB List box control LVM List view control MCM Month calendar control PBM Progress bar PGM Pager control PSM Property sheet RB Rebar control SB Status bar window SBM Scroll bar control STM Static control TB Toolbar TBM Trackbar TCM Tab control TTM Tooltip control TVM Tree-view control UDM Up-down control WM General window
应用程序可以定义自己的消息,其取值范围必须大于WM_USER。
如何通过消息传递任何参数?
Windows系统的消息机制都包含2个长整型的参数:WPARAM, LPARAM,可以存放指针,也就是说可以指向任何内容了。
传递的内容因消息各异,消息处理函数会根据消息的类型进行特别的处理,它知道传递的参数是什么含义。
消息在线程内传递时,由于在同一个地址空间中,指针的值是有效的。但是夸线程的情况就不能直接使用指针了,所以Windows系统提供了 WM_SETTEXT, WM_GETTEXT, WM_COPYDATA等消息,用来特殊处理,指针的内容会被放到一个临时的内存映射文件(Memory-mapped File)里面,通过它实现线程间的共享数据。
消息队列和线程的关系是什么?消息队列的结构是什么样子的?
Windows系统本身会维护一个唯一的消息队列,以便于发送给各个线程,这是系统内部的实现方式。
而对于线程来说,每个线程可以拥有自己的消息队列,它和线程一一对应。在线程刚创建时,消息队列并不会被创建,而是当GDI的函数调用发生时,Windows系统才认为有必要为线程创建消息队列。
消息队列包含在一个叫THREADINFO的结构中,有四个队列:
Sent Message Queue
Posted Message Queue
Visualized Input Queue
Reply Message Queue
之所以维护多个队列,是因为不同消息的处理方式和处理顺序是不同的。
线程和窗口是一一对应的吗?如果想要有两个不同的窗口对消息作出不同反应,但是他们属于同一个线程,可能吗?
窗口由线程创建,一个线程可以创建多个窗口。窗口可由CreateWindow()函数创建,但前提是需要提供一个已注册的窗口类(Window Class),每一个窗口类在注册时需要指定一个窗口处理函数(Window Procedure),这个函数是一个回调函数,就是用来处理消息的。而由一个线程来创建对应于不同的窗口类的窗口是可以的。
由此可见,只要注册多个窗口类,每个窗口都可以拥有自己的消息处理函数,而同时,他们属于同一个线程。
如何发送消息?
消息的发送终归通过函数调用,比较常用的有PostMessage(),SendMessage(),另外还有一些Post*或Send*的函数。函数的调用者即发送消息的人。
这二者有什么不同呢?SendMessage()要求接收者立即处理消息,等处理完毕后才返回。而PostMessage()将消息发送到接收者队列中以后,立即返回,调用者不知道消息的处理情况。
他们的的原型如下:
- LRESULT SendMessage(
- HWND hwnd,
- UINT uMsg,
- WPARAM wParam,
- LPARAM lParam);
- LRESULT PostMessage(
- HWND hwnd,
- UINT uMsg,
- WPARAM wParam,
- LPARAM lParam);
SendMessage()要求立即处理,所以它会直接调用窗口的消息处理函数(Window Procedure),完成后返回处理结果。
但这仅限于线程内的情况,夸线程时它调不到处理函数,只能把消息发送到接收线程的队列Sent Message Queue里。如果接收线程正在处理别的消息,那么它不会被打断,直到它主动去获取队列里的下一条消息时,它会拿到这一条消息,并开始处理,完成后他会通知发送线程结果(猜测是通过ReplyMessage()函数)。
在接收线程处理的过程中,发送线程会挂起等待SendMessage()返回。但是如果这时有其他线程发消息给这个发送线程,它可以响应,但仅限于非队列型(Non-queued)消息。
这种机制可能引起死锁,所以有其他函数比如SendMessageTimeout(), SendMessageCallback()等函数来避免这种情况。
PostMessage()并不需要同步,所以比较简单,它只是负责把消息发送到队列里面,然后马上返回发送者,之后消息的处理则再受控制。
消息可以不进队列吗?什么消息不进队列?
可以。实际上MSDN把消息分为队列型(Queued Message)和非队列型(Non-queued Message),这只是不同的路由方式,但最终都会由消息处理函数来处理。
队列型消息包括硬件的输入(WM_KEY*等)、WM_TIMER消息、WM_PAINT消息等;非队列型的一些例子有WM_SETFOCUS, WM_ACTIVE, WM_SETCURSOR等,它们被直接发送给处理函数。
其实,按照MSDN的说法和消息的路由过程可以理解为,Posted Message Queue里的消息是真正的队列型消息,而通过SendMessage()发送到消息,即使它进入了Sent Message Queue,由于SendMessage要求的同步处理,这些消息也应该算非队列型消息。也许,Windows系统会特殊处理,使消息强行绕过队列。
谁来发送消息?硬件输入是如何被响应的?
消息可以由Windows系统发送,也可以由应用程序本身;可以向线程内发送,也可以夸线程。主要是看发送函数的调用者。
对于硬件消息,Windows系统启动时会运行一个叫Raw Input Thread的线程,简称RIT。这个线程负责处理System Hardware Input Queue(SHIQ)里面的消息,这些消息由硬件驱动发送。RIT负责把SHIQ里的消息分发到线程的消息队列里面,那RIT是如何知道该发给谁呢?如果是鼠标事件,那就看鼠标指针所指的窗口属于哪个线程,如果是键盘那就看哪个窗口当前是激活的。一些特殊的按键会有所不同,比如 Alt+Tab,Ctrl+Alt+Del等,RIT能保证他们不受当前线程的影响而死锁。RIT只能同时和一个线程关联起来。
有可能,Windows系统还维护了除SHIQ外地其他队列,分发给线程的队列,或者直接发给窗口的处理函数。
消息循环是什么样子?线程何时挂起?何时醒来?
想象一个通常的Windows应用程序启动后,会显示一个窗口,它在等待用户的操作,并作出反应。
它其实是在一个不断等待消息的循环中,这个循环会不断去获取消息并作出处理,当没有消息的时候线程会挂起进入等待状态。这就是通常所说的消息循环。
一个典型的消息循环如下所示(注意这里没有处理GetMessage出错的情况):
- while(GetMessage(&msg, NULL, 0, 0 ) != FALSE)
- {
- TranslateMessage(&msg);
- DispatchMessage(&msg);
- }
这里GetMessage()从队列里取出一条消息,经过TranslateMessage(),主要是将虚拟按键消息(WM_KEYDOWN等)翻译成字符消息(WM_CHAR等)。
DispatchMessage()将调用消息处理函数。这里有一个灵活性,消息从队列拿出之后,也可以不分发,进行一些别的特殊操作。
下面在看看GetMessage()的细节:
- BOOL GetMessage(
- LPMSG lpMsg,
- HWND hWnd,
- UINT wMsgFilterMin,
- UINT wMsgFilterMax
- );
GetMessage()会从队列中取出消息,填到MSG结构中通过参数返回。如果此时的消息是WM_QUIT,也就标识线程需要结束,则 GetMessage()返回FALSE,那么while循环会终止。返回TRUE表示取到其他消息,可以继续循环并运行里面的内容。如果返回-1表示 GetMessage()出错。
其他几个参数是用来过滤消息的,可以指定接收消息的窗口,以及确定消息的类型范围。
这里还需要提到一个概念是线程的Wake Flag,这是一个整型值,保存在THREADINFO里面和4个消息队列平级的位置。它的每一位(bit)代表一个开关,比如QS_QUIT, QS_SENDMESSAGE等等,这些开关根据不同的情况会被打开或关闭。GetMessage()在处理的时候会依赖这些开关。
GetMessage()的处理流程如下:
1. 处理Sent Message Queue里的消息,这些消息主要是由其他线程的SendMessage()发送,因为他们不能直接调用本线程的处理函数,而本线程调用 SendMessage()时会直接调用处理函数。一旦调用GetMessage(),所有的Sent Message都会被处理掉,并且GetMessage()不会返回;
2. 处理Posted Message Queue里的消息,这里拿到一个消息后,GetMessage()将它拷贝到MSG结构中并返回TRUE。注意有三个消息WM_QUIT, WM_PAINT, WM_TIMER会被特殊处理,他们总是放在队列的最后面,直到没有其他消息的时候才被处理,连续的WM_PAINT消息甚至会被合并成一个以提高效率。从后面讨论的这三个消息的发送方式可以看出,使用Send或Post消息到队列里情况不多。
3. 处理QS_QUIT开关,这个开关由PostQuitMessage()函数设置,表示线程需要结束。这里为什么不用Send或Post一个 WM_QUIT消息呢?据称:一个原因是处理内存紧缺的特殊情况,在这种情况下Send和Post很可能失败;其次是可以保证线程结束之前,所有Sent 和Posted消息都得到了处理,这是因为要保证程序运行的正确性,或者数据丢失?不得而知。
如果QS_QUIT打开,GetMessage()会填充一个WM_QUIT消息并返回FALSE。
4. 处理Virtualized Input Queue里的消息,主要包括硬件输入和系统内部消息,并返回TRUE;
5. 再次处理Sent Message Queue,来自MSDN却没有解释。难道在检查2、3、4步骤的时候可能出现新的Sent Message?或者是要保证推后处理后面两个消息;
6. 处理QS_PAINT开关,这个开关只和线程拥有的窗口的有效性(Validated)有关,不受WM_PAINT的影响,当窗口无效需要重画的时候这个开关就会打开。当QS_PAINT打开的时候,GetMessage()会返回一个WM_PAINT消息。处理QS_PAINT放在后面,因为重绘一般比较慢,这样有助于提高效率;
7. 处理QS_TIMER开关,和QS_PAINT类似,返回WM_TIMER消息,之所以它放在QS_PAINT之后是因为其优先级更低,如果Timer消息要求重绘但优先级又比Paint高,那么Paint就没有机会运行了。
如果GetMessage()中任何消息可处理,GetMessage()不会返回,而是将线程挂起,也就不会占用CPU时间了。
类似的WaitMessage()函数也是这个作用。
还有一个PeekMessage(),其原型为:
- BOOL PeekMessage(
- LPMSG lpMsg,
- HWND hWnd,
- UINT wMsgFilterMin,
- UINT wMsgFilterMax,
- UINT wRemoveMsg
- );
它的处理方式和GetMessage()一样,只是多了一个参数wRemoveMsg,可以指定是否移除队列里的消息。最大的不同应该是,当没有消息可处理时,PeekMessage()不是挂起等待消息的到来,而是立即返回FALSE。
WM_DESTROY, WM_QUIT, WM_CLOSE消息有什么不同?
而其他两个消息是关于窗口的,WM_CLOSE会首先发送,一般情况程序接到该消息后可以有机会询问用户是否确认关闭窗口,如果用户确认后才调用 DestroyWindow()销毁窗口,此时会发送WM_DESTROY消息,这时窗口已经不显示了,在处理WM_DESTROY消息是可以发送 PostQuitMessage()来设置QS_QUIT开关,WM_QUIT消息会由GetMessage()函数返回,不过此时线程的消息循环可能也即将结束。
窗口内的消息的路由是怎样的?窗口和其控件的关系是什么?
一个窗口(Window)可以有一个Parent属性,对一个Parent窗口来说,属于它的窗口被称为子窗口(Child Window)。控件(Control)或对话框(Dialog)也是窗口,他们一般属于某个父窗口。
所有的窗口都有自己的句柄(HWND),消息被发送时,这个句柄就已经被指定了。所以当子窗口收到一个消息时,其父窗口不会也收到这个消息,除非子窗口手动的转发。
关于更详细的窗口和控件,会在另一篇中讨论。
谁来处理消息?消息处理函数能发送消息么?
由消息处理函数(Window Procedure)来处理。消息处理函数是一个回调函数,其地址在注册窗口类的时候注册,只有在线程内才能调用。
其原型为:
- typedef LRESULT (CALLBACK* WNDPROC)(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
处理函数内部一般是一个switch-case结构,来针对不同的消息类型进行处理。Windows系统还为所有窗口预定义了一个默认的处理函数 DefWindowProc(),它提供了最基本的消息处理,一般在不需要特殊处理的时候(即在switch的default分支)会调用这个函数。
由同一个窗口类创建的一组窗口共享一个消息处理函数,所以在编写处理函数的时候要小心处理窗口实例的局部变量。
处理函数里可以发送消息,但是可以想象有可能出现循环。另外处理函数还常常被递归调用,所以要减少局部变量的使用,以避免递归过深是栈溢出。
最后关于处理函数特化的问题将在另外的文章讨论。
1. Windows 的历史
中国人喜欢以史为鉴,而事实也确实是,如果你能知道一件事情的来龙去脉,往往可以更容易地理解事物为什么会表现为当前这样的现状。所以,我的介绍性开场白通常会以一段历史开始。不过,我不会以精确到年月日的那种方式详细讲述,而是选取几个对我们的编程生涯有重要影响的关键点。
Windows 是真正的图形化界面操作系统的普及者,无论任何人,争夺什么第一个实现的GUI、第一个商业化的GUI之类的虚名,都替代不了 Windows 的历史功绩,让最普通的用户能够容易地操纵PC。
第一个声名大噪的版本是Windows 3.0(也有人认为应该是它的更加健康强壮的弟弟Windows 3.1),从那个时候开始,我们就和本文中以下的几个关键角色有了不尽的情缘:
- while(GetMessage(&msg, NULL,0, 0))
- {
- TranslateMessage(&msg);
- DispatchMessage (&msg);
- }
while(GetMessage(&msg, NULL,0, 0))
{
TranslateMessage(&msg);
DispatchMessage (&msg);
}
上面代码中的这三个相关函数,会在后文中提到。
第二个大红大紫的版本则非Windows 95莫属。这个版本的主要变化在于,无论如何,它是一个大众化的所谓32位系统了。之所以要加上“所谓的”三个字,是因为这个系统是个混血儿,在32位代码中混杂有大量的从之前的Windows3.x上移植过来的16位代码。
此时间稍后,另一支潜力股的关键进化过程结束,Windows NT 4.0隆重登场,这个分支的操作系统是全32位的,成为了 Windows 95 系列的掘墓者,也是我们现在所使用几乎所有的 Windows 桌面系统(Windows2000/XP/2003/Vista/2008)的前辈。但是,这个版本由于对系统硬件的要求甚高(在当时),所以没有引起普通用户的广泛关注。
下一个里程碑就是Windows 2000了,微软实现了Windows9x/Me分支和Windows NT分支的合并。紧接着,Windows XP 现身。从有关消息方面来考察,Windows2000 做了微小的改进,在此之前,我们在很多情况下需要创建一个正常的、隐藏的、完整的窗口来处理消息,而 Windows 2000 引入了一种特殊类型的窗口用于此类需求。道理上来讲,应该会减少一些资源占用。
此后经过五六年的时间,Windows Vista诞生。事实上,从 Windows2000 开始,Windows 家族的编程模型,尤其是对原生态代码(native code)而言,已经基本没有太大的变化。通常只是增加了新的API或者用户控件,或者现有控件增加了新的功能或者风格。尽管 Windows Vista 中有很多的变化,但是对于我们今天要讲到的主题,影响不大。最主要的一个影响,是消息的发送方和接收方之间有了等级限制,不像之前可以随意互相进行消息传递,这是出于安全性的考虑。
2. Windows 的宏观构造
从最原始的版本开始,有三个比较大的功能块占据了Windows系统的绝大部分,这三个块,就是赫赫有名的Kernel、GDI、User。从Windows 95起,另两个在先前不太起眼的部分也迅速崛起,那就是大名鼎鼎的Registry和Shell。
这几个大块的分工是这样的:Kernel,望文生义,负责内核部分,这是任何一个可以称之为操作系统的东西的基石,主要职责有:内存管理、任务调度、外设管理等;GDI,则是对可以进行图形化操纵的设备的操作接口,对外提供的主要功能是在设备上:提供坐标系统,绘制点、线、形状,进行填充,文本绘制,管理画笔、画刷、字体等绘图对象;User,则是前两者的粘合剂,使系统能够通过图形化操作方式和使用者(也就是User)进行交互,把零散的GDI对象有机地组织起来,抽象为窗口,用以接受用户的输入,进行相应的运算(广义上的,并不是局限于算数运算),并最终将结果呈现给用户。当然,User 部分通常是指可以实现上述的功能的基础构造,真正的实现部分需要大量的额外工作,这也是 Shell 部分的主要工作。而Registry,则是提供给用户一种与物理存储无关的统一的数据访问方式。
很容易就可以看出,消息功能,这种被我们一直以窗口间通讯最为自然的方式所使用的机制,应该隶属于 User 部分。
对于 Windows Mobile 系统来说,底层的实现上与桌面系统大相径庭,例如,它本身并没有kernel32.dll、gdi32.dll、user32.dll这几个众所周知的系统库,而是有一个多合一的coredll.dll,而且内核被实现为一个更接近于正常进程的nk.exe进程,而不是桌面系统下的那个抽象的执行体。尽管如此,但是在逻辑上,我们依然可以将之与桌面系统同等看待。
3. Windows 的消息概念
在我们的通常认识上,消息事实就是一个数值。我们检查一下消息相关的各个回调函数的原型就会发现,表示消息的那个参数的数据类型是 UINT,也就是无符号的整数类型。不过,我们通常也会发现,消息往往还附带有两个其他类型的数据,一个是 WPARAM 类型的,一个是 LPARAM 类型的,如果算上消息的目标窗口的句柄,那么,一个消息以及相关信息才能够说是比较完整。为什么说是比较呢?看一下 MSG 这个结构的定义就会发现,其实还有另外两个我们不太经常使用的数据,是与一条消息有关系的。MSG 的完整声明如下:
- typedef struct {
- HWND hwnd;
- UINT message;
- WPARAM wParam;
- LPARAM lParam;
- DWORD time;
- POINT pt;
- } MSG, *PMSG;
typedef struct {
HWND hwnd;
UINT message;
WPARAM wParam;
LPARAM lParam;
DWORD time;
POINT pt;
} MSG, *PMSG;
前四项正是我们已经提及过的,而后两项,一个表示消息发生时的时间,一个表示此消息发生时的按屏幕坐标表示的鼠标光标的位置。
从这个结构也可以看出,我们经常所说的消息,更多是指代表了一个确定的消息的数值。
我们可能还会听到有这样的称呼:命令消息、通知消息、反射消息等等。首先需要声明的一点是,这并不是对 Windows 系统中的消息的科学分类,而是在某些特定场景下的通俗称谓。命令消息,一般特指 WM_COMMAND 消息,此消息通常由控件或者菜单发出,表示用户执行/发出了一个命令。通知消息,一般特指WM_NOTIFY 消息,此消息通常由公用控件(CommonControls)发出,表示一些事件发生了,需要处理。反射消息,一般用于对 Windows API 的封装类或者类库中。这是一类消息的总称,它们的处理需要经过一种被称为“反射”的机制。这一机制的具体方式下一节中会有描述。
Windows 的消息分类不好分(如果非要划分的话,可以分为系统定义的消息和应用程序定义的消息),不过有一个区段划分。从 0x0000 到 0x03FF,为系统定义的消息,常见的 WM_PAINT、WM_CREATE 等均在其中;从 0x0400 到 0x7FFF,专用于用户自定义的消息,可以使用 WM_USER + x 的形式自行定义,其中WM_USER 的值就是 0x0400,x 取一个整数;从 0x8000 到 0xBFFF,从 Windows 95 开始,也用作用户自定义的消息范围,可以使用 WM_APP + x 的形式自行定义。根据微软的建议,WM_APP类消息用于程序之间的消息通信,而 WM_USER 类消息则最好用于某个特定的窗口类。微软自己遵循这一惯例,所以,公用控件的消息,如 TVM_DELETEITEM,基本都是 WM_USER 类属。从 0xC000 开始,到 0xFFFF,这个区段的消息值保留给 RegisterWindowMessage 这个 API,此 API 可以接受一个字符串,把它变换成一个唯一的消息值。在桌面系统上,最常见的源字符串,可能就是“TaskbarCreated”了,由它对应的消息会发送到所有的顶级窗口,通知任务栏刚刚被创建(可能是由于资源管理崩溃后重新启动导致的)。
由上也可以看出,Windows 的消息值是一个 16 位的数字,这是 16 系统时代留给我们的痕迹。另外的一个痕迹是WPARAM 和 LPARAM 这两个数据类型,在 16 位时代,WPARAM 是 16 位的,其名字的意思是 wordparameter,LPARAM 是 32 位的,其名字的意思是 longparameter。
4. Windows 的消息机制
4.1. 消息队列
说到消息机制,可能连最初级的 Windows 程序员都会对消息队列(MessageQueue)这个名词耳熟(不过不见得能详)。对于这样一个基本概念,Windows 操作系统提供的针对消息队列的API 却少的可怜(GetQueueStatus、GetInputState、GetMessageExtraInfo、SetMessageExtraInfo),而且,这些 API 的出镜率也相当的低,甚至有不少经验丰富的程序员也从来没有使用过它们。在 Windows Mobile 上,这些 API 干脆付诸阙如,不过有一个同样极少使用的GetMessageQueueReadyTimeStamp 函数在充门面。
这一切,都归功于在 API 层极好的封装性,减少了开始接触这个平台时需要了解的概念。但是,对于我们这样既想知其然,又想知其所以然的群体,还是有必要对消息队列有充分的了解。
4.1.1. 系统消息队列
这是一个系统唯一的队列,输入设备(键盘、鼠标或者其他)的驱动程序会把用户的操作输入转化成消息放置于系统队列中,然后系统会把此消息转到目标窗口所在线程的消息队列中等待处理。
4.1.2. 线程消息队列(应用程序消息队列)
应用程序消息队列这个名称是历史遗留,在 32 位(以及之后的 64 位)系统中,正确的名称应该是线程消息队列。每一个GUI线程都会维护这样一个线程消息队列。(这个队列只有在线程调用 User 或者 GDI 函数时才会创建,默认并不创建)。然后线程消息队列中的消息会被本线程的消息循环(有时也被称为消息泵)派送到相应的窗口过程(也叫窗口回调函数)处理。
4.2. 消息的生命期
4.2.1. 消息的产生
消息产生的源头有两个,一个是系统,一个是应用程序。系统产生的消息又可以大致分为两类,一类是由输入设备导致的,例如 WM_MOUSEMOVE,一类是User部分(或者是系统内的其他部分通过User部分)为了实现自身的正常行为或者管理功能而主动生成的,如 WM_WINDOWPOSCHANGED。
产生的方式也有两种,一种称为发送(Send),另一种称为投递(Post,也有译作张贴的),对应于大家极为熟悉的两个 API,SendMessage 和 PostMessage。系统产生的消息,虽然我们看不到代码,不过我们还是可以粗略地划拨一下,基本上所有的输入类消息,都是以投递的方式抵达应用的,而其他的消息,则大部分是采取了发送方式。
至于应用程序,可以随意选用适合自己的消息产生方式。
4.2.2. 消息的处理
在绝大部分情况下,消息总是有一个目标窗口的,因此,消息也绝大部分是被某个窗口所处理的。处理消息的地方,就是这个窗口的回调函数。
窗口的回调函数,之所以被称作“回调”,就是因为这个函数一般并不是由用户(程序员)主动调用它的,而是系统认为在恰当的时候对它进行调用。那么,这个“恰当的时候”是什么时候呢?根据消息产生的方式,“恰当的时候”也有两个时机。
第一个时机是,DispatchMessage 函数被调用时,另一个时机是SendMessage 函数被调用时。
我们正常情况下以系统处理对一个顶级窗口的关闭按钮的鼠标左键点击事件为例来说明。
这个点击事件完成的标志性消息是 WM_NCLBUTTONUP,表示在一个窗口的非客户区的鼠标左键释放动作,另外,这个鼠标消息的其他数据中会表明,发生这个动作的位置是在关闭按钮上(HTCLOSE)。这是一个鼠标输入事件,从前文可以知道,它会被系统投递到消息队列中。
于是,在消息循环中GetMessage 的某次执行结束后,这个消息被取到了 MSG 结构里。从文章开头的消息循环代码可知,这个消息接下来会被 TranslateMessage 函数做必要的(事实上是“可能的”)翻译,然后交给 DispatchMessage 来全权处理。
DispatchMessage 拿到了 MSG 结构,开始自己的一套办事流程。
首先,检查消息指定的目标窗口句柄。看系统内(实际上是本线程内)是不是确实存在这样一个窗口,如果没有,那说明这个消息已经不会有需要对它负责的人选了,那么这个消息就会被丢弃。
如果有,它就会直接调用目标窗口的回调函数。终于看到,我们写的回调函数出场了,这就是“恰当的时机”之一。当然,为了叙述清晰,此处省略了系统做的一些其他处理。
这样,对于系统来说,一条投递消息就处理完成,转而继续 GetMessage。
不过对于我们上面的例子,事情还没有完。
我们都清楚,对于 WM_NCLBUTTONUP 这样一条消息,通常我们是无暇去做额外处理的(正事还忙不过来呢……)。所以,我们一般都会把它扔到那个著名的垃圾堆里,没错,DefWindowProc。尽管如此,我们还是可以看出,DefWindowProc其实已经成了我们的回调函数的一个组成部分,唯一的差别在,这个函数不是我们自己写的而已。
DefWindowProc 对这个消息的处理也是相当轻松,它基本上没有做什么实质性的事情,而是生成了另外一个消息,WM_SYSCOMMAND,同时在 wParam 里指定为 SC_CLOSE。这一次,消息没有被投递到消息队列里,而是直接 Send 出来的。
于是,SendMessage 的艰难历程开始。
第一步,SendMessage 的方向和DispatchMessage 几乎一模一样,检查句柄。
第二步,事情就来了,它需要检查目标窗口和自己在不在一个线程内。如果在,那就比较好办,按照 DispatchMessage 趟出来的老路走:调用目标窗口的回调函数。这,就是“恰当的时机”之二。
可是要是不在一个线程内,那就麻烦了。道理很简单,别的线程有自己的运行轨迹,没有办法去让它立即就来处理这个消息。
现在,SendMessage该怎么处理手里的这个烫手山芋呢?(作者注:写到此处时,很有写上“欲知后事如何,且听下回分解”的冲动)
微软的架构师做了个非常聪明的选择:不干涉其他线程的内政。我不会生拉硬拽让你来处理我的消息,我会把消息投递给你(这个投递是内部操作,从外面看,这条消息应该一直被认为是发送过去的),然后—— 我等着。
这下,球踢到了目标线程那边。目标线程一点也不含糊,既然消息来到了我的队列里,那我的 GetMessage 会按照既定的流程走,不过,和上文WM_NCLBUTTONUP 的经历有所不同。鉴于这条消息是外来客,而且是Send 方式,于是它以优先于线程内部的其他消息进行处理(毕竟友邦在等着啊),处理完毕之后,把结果返回给消息的源线程。可以参见下文中对 GetMessage 函数的叙述。
在我们的现在讨论的这个例子里,由于 SendMessage(WM_SYSCOMMAND) 是属于本线程内的,所以就会递归调用回窗口的回调函数里。此后的处理,还是另外的几个消息被衍生出来,如 WM_CLOSE 和 WM_DESTROY。这个例子仅仅出于概念性的展示,而不是完全精确可靠的,而且,在 Windows Mobile 上,干脆就没有非客户区的概念。
这就是系统内所有消息的处理方式。
不过稍等,PostThreadMessage 投递到消息队列里的消息怎么办?答案是:你自己看着办。最好的处理位置,就是在消息循环中的TranslateMessage 调用之前。
另外一个需要稍做注解的问题是消息的返回值问题,这个问题有些微妙。对于大多数的消息,返回值都没有什么意义。对于另外的一些消息,返回值意义重大。我相信有很多人对 WM_ERASEBKGND 消息的返回值会有印象,该消息的返回值直接影响到系统是不是要进行缺省的绘制窗口背景操作。所以,处理完一条消息究竟应该返回什么,查一下文档会更稳妥一些。
这才算是功德圆满了。
4.2.3. 消息的优先级
上一节中其实已经暗示了这一点,来自于其他线程的发送的消息优先级会高一点点。
不过还需要注意,还有那么几个优先级比正常的消息低一点点的。它们是:WM_PAINT、WM_TIMER、WM_QUIT。只有在队列中没有其他消息的时候,这几个消息才会被处理,多个 WM_PAINT 消息还会被合并以提高效率(内幕揭示:WM_PAINT 其实也是一个标志位,所以看上去是被“合并了”)。
其他所有消息则以先进先出(FIFO)的方式被处理。
4.2.4. 没有处理的消息呢?
有人会问出这个问题的。事实上,这差不多就是一个伪命题,基本不存在没有处理的消息。从 4.2.2 节的叙述也可以看出,消息总会流到某一个处理分支里去。
那么,我本人倾向于提问者在问这样一个问题:如果窗口回调函数没有处理某个消息,那这个消息最终怎么样了?其实这还是取决于回调函数实现者的意志。如果你只是简单地返回,那事实上也是进行了处理,只不过,处理的方式是“什么都没做”而已;如果你把消息传递给 DefWindowProc,那么它会处理自己感兴趣的若干消息,对于别的消息,它也一概不管,直接返回。
4.3. 消息死锁
假设有线程A和B, 现在有以下步骤:
1) 线程A SendMessage 给线程B,A 等待消息在线程B 中处理后返回
2) 线程 B 收到了线程A 发来的消息,并进行处理,在处理过程中,B 也向线程 A SendMessage,然后等待从A 返回。
此时线程A正等待从线程B返回,无法处理B发来的消息,从而导致了线程A 和B相互等待,形成死锁。
以此类推,多个线程也可以形成环形死锁。
可以使用 SendNotifyMessage 或 SendMessageTimeout来避免出现此类死锁。
(作者注:对两个线程互相 SendMessage 曾经专门写程序进行过验证,结果却没有死锁,不知道是不是新一些的 Windows 系统作了特殊的处理。请大家自行验证。)
4.4. 模态(Modal)
这个词汇曾给我带来极大的困惑,我曾经做过不少的努力,想弄清楚为什么当初系统的构建者使用“模态”这个词汇来表达这样一种情景,但是最后失败了。我不得不接受这个词,并运用它。直到数天前,我找到了一个对模态的简要介绍,如果有兴趣,各位可以自己去看:http://www.usabilityfirst.com/glossary/main.cgi?function=display_term&term_id=320。(我曾做过的另外一个努力是想知道为什么Handle会被翻译为“句柄”,或者,是谁首先这样翻译的,迄今无解)。Windows 中的模态有好几个场景,比较典型的有:
显示了一个对话框
显示出一个菜单
操作滚动条
移动窗口
改变窗口大小
把我的体会归纳起来,那就是:如果进入了一个模态场景,那么,除了这个模态本身的明确目标,其余操作被一概禁止。概念上可以理解为,模态,是一种独占模式、一种强制模式,一种霸道模式。
在 Windows 里,模态的实现其实很简单,只不过就是包含了自己的消息循环而已,说穿了毫无悬念可言,但是如果不明白这个内幕的话,就会觉得很神秘。那么,根据此结论,我们就可以做一些有趣(或者有意义)的事情了,看一下以下代码,预测一下 TestModal 的执行结果:
- void CALLBACK RequestQuit(HWNDhwnd, UINT uMsg, UINT idEvent, DWORD dwTime);
- void TestModal()
- {
- UINT uTimerId =SetTimer(NULL, 66, 1000, RequestQuit);
- MessageBox(NULL, NULL, NULL,MB_OK);
- KillTimer(NULL, uTimerId);
- }
-
- void CALLBACK RequestQuit(HWND hwnd, UINT uMsg, UINT idEvent, DWORD dwTime)
- {
- PostMessage(NULL, WM_QUIT,0, 0);
- }
void CALLBACK RequestQuit(HWNDhwnd, UINT uMsg, UINT idEvent, DWORD dwTime);
void TestModal()
{
UINT uTimerId =SetTimer(NULL, 66, 1000, RequestQuit);
MessageBox(NULL, NULL, NULL,MB_OK);
KillTimer(NULL, uTimerId);
}
void CALLBACK RequestQuit(HWND hwnd, UINT uMsg, UINT idEvent, DWORD dwTime)
{
PostMessage(NULL, WM_QUIT,0, 0);
}
答案见本大节末尾。
需要提醒的是,模态是用户界面里相当重要而普遍的一个概念,不仅存在于 Windows 环境下,也存在于其他的用户界面系统中,例如 Symbian。
4.5. 与消息处理有关的钩子(Hook)
很多人都或多或少地听说过或者接触过钩子。钩子在处理事务的正常流程之外,额外给予了我们一种监听或者控制的方式(注意:在 Windows Mobile 系统下,钩子并不被正式支持)。
(TODO: 细化,不过由于这个内容针对桌面系统更多,所以暂时可以略过)
4.6. 所谓的反射(Reflection)
上文也已经提到,反射通常会在对 Windows API 的封装类或者类库中出现,这是由于Windows SDK 的 API 是以 C 的风格暴露给使用者的,与 C++ 语言的主要用类编程的风格有一些需要啮合的地方。
举例来说,一个Button,在 SDK 中是一个已经定型的控件,基本上实现了自包容,要扩展它的功能的话(例如,绘制不同的外观),系统把接口(广义上的接口,即一种交互上的契约)制定为发给 Button 的属主(通常就是父窗口)的两条消息(WM_MEASUREITEM 和 WM_DRAWITEM)。其道理在于,使用 Button 控件的父窗口,往往是用户自己实现的,处理起来更方便,而不需要对 Button 自身做什么手脚。
但是,这种交互方式在 C++ 的世界里是相当忌讳的。C++ 的自包容单位是对象,那么一个 Button 对象的封装类,假定是CButton,不能自己处理自己的绘制问题,这是不太符合法则的(尽管不是不可以)。
为了消除这一不和谐音,就有人提出了反射机制。其核心就在于,对于本该子控件自己处理的事件所对应的消息(如前面的 WM_DRAWITEM),父窗口即使收到,也不进行直接处理,而是把这个消息重新发回给子控件本身。
这样带来一个问题,当 Button 收到一个 WM_DRAWITEM消息时,弄不清楚究竟是自己的子窗口发来的(虽说往 Button 上建立子窗口不常见,但不是不可以),还是父窗口把原本是自己的消息反射回来了。所以,最后微软给出一个解决办法,就是反射消息的时候,把消息的值上加一个固定的附加值,这个值就是 OCM__BASE。尽管最初只是微软自己在这样做,这个值也完全可以各取各的,但是后来别的类/类库的编制者几乎都无一例外地和微软保持了一致。
当控件收到消息之后,先把这个附加值减掉,就可以知道是哪一条消息被反射回来了,然后再作相应的处理。
4.4节小测试的答案:一个消息框显示大概 1 秒钟的时间,然后自动消失。有的人根据这一表现,写出了自己的超时候自动关闭的消息框。如果各位有兴趣,可以自己尝试也实现一下。(提示:需要考虑一下用户先于定时器触发就手动关闭了消息框的情况)
5. Windows 的消息本质
一个特殊的事件同步机制,使用多种常规线程间同步机制实现。
6. Windows 的消息操纵
注意:以下讨论中用浅绿色标注的函数,表示在 WindowsMobile 平台上是没有的。
SendMessage
PostMessage
在使用消息的过程中,这两个函数的使用率是最高的。初学者有时会搞不清楚这两个发送消息的函数的使用场景,容易误用。所以放在这里一起说。其实上面已经对 SendMessage 做了很多的介绍,所以在这儿的重点会放在 PostMessage 上。相较 SendMessage而言,PostMessage 的工作要轻松许多,只要找到知道那个的窗口句柄所在的线程,把消息放到该线程的消息队列里就可以了,完全不理会这条消息最终的命运,是不是被正确处理了。
这一点,从 PostMessage 和 SendMessage 的返回值的不同也有体现。PostMessage 函数的返回值是 BOOL 类型,体现的是投递操作是否成功。投递操作是有可能失败的,尽管我们不愿意同时也确实很少看到。例如,目标线程的消息队列已经满(在 16 位时代出现概率较高),或者更糟糕,目标线程根本就没有消息队列。
当然,PostMessage 也要检查窗口句柄的合法性,不过和SendMessage 不同的一点是,它允许窗口句柄是 NULL。在此情况下,对它的调用就等价于调用 PostThreadMessage 向自身所在线程投递一条消息。
从上面的描述可以很容易地看出,PostMessage 和 SendMessage 的本质区别在于前者发出的消息是异步处理的,而后者发出的消息是同步处理的。理解这一点非常重要。
从上面的这个结果推演,还可以得到另外一个有时会很有用的推论。在本线程之内,如果你在处理某个窗口消息的时候,希望在处理之后开展另一项以此消息为前提的工作,那么可以向本窗口 Post 一条消息,来作为该后续工作的触发机制。
GetMessage
检查线程的消息队列,如果有消息就取出该消息到一个传入的 MSG 结构中并返回,没有消息,就等待。等待时线程处于休眠状态,CPU被分配给系统内的其他线程使用。
需要注意的是,由其它线程 Send 过来的消息,会在这里就地处理(即调用相应的窗口回调函数),而不会返回给调用者。
DispatchMessage
这个消息的来龙去脉在上文中已经有较为详细的叙述,故此略去。
- TranslateMessage(<SPAN style="COLOR: #33ff33">TranslateAccelerator</SPAN>)
TranslateMessage(TranslateAccelerator)
这个函数在本质上与消息机制的关系不大,绝大多数的消息循环中都出现它的身影是因为绝大多数的程序员都不知道这个函数真正是干什么的,仅仅是出于惯例或者初学时教科书上给出的范例。这个函数的作用主要和输入有关,它会把 WM_KEYDOWN 和 WM_KEYUP 这样的消息恰当地、适时地翻译出新的消息来,如 WM_CHAR。如果你确信某个线程根本不会有用户输入方面的需求,基本上可以安全地将之从循环中移除。
可以和它相提并论的就是列出的 TranslateAccelerator 函数,这个函数会把用户输入根据指定的加速键(Accelerator)表翻译为适当的命令消息。
PeekMessage
窥探线程的消息队列。无论队列中有没有消息,这个函数都立即返回。它的参数列表与 GetMessage 基本一致,只是多了一个标志参数。这个标志参数指定了如果队列中如果有消息的话,PeekMessage 的行为。如果该标志中含有PM_REMOVE,则 PeekMessage 会把新消息返回到 MSG 结构中,正如 GetMessage 的行为那样。如果标志中指定了 PM_NOREMOVE,则不会取出任何消息。
- <SPAN style="COLOR: #33ff33">WaitMessage</SPAN>
WaitMessage
这个函数的作用是等待一条消息的到来。等待期间线程处于休眠状态,一旦有新消息到来,则立即返回。
了解了 PeekMessage 和 WaitMessage 之后,理论上,我们可以写出自己的 GetMessage 了。
SendNotifyMessage
这个函数很有意思,它的行为属于看人下菜碟型。如果目标线程就是自身所处线程,那么它就是SendMessage;而一旦发现目标线程是其他线程,那它就类似于PostMessage,不等待目标窗口处理完成。不过,仅仅是类似,因为它发出的消息仍然会被目标线程认为是 Send 过来的。
SendMessageTimeout
这个函数可以说是 SendMessage 函数家族(相对PostMessage 而言)之中最强大的函数。它在标准的SendMessage 函数的功能前提下,加入了许多额外的控制选项以及一个超时设定。例如,它可以指定,如果发现目标窗口已经失去响应的话,那么就立即返回;也可以指定如果目标窗口的响应时间超过了指定的超时时限的话也返回,而不是无限等待下去。而且我们知道,SendMessage 是会固执地等待下去的。(内幕揭示:SendMessage 其实就是对 SendMessageTimeout的一个浅封装)
- <SPAN style="COLOR: #33ff33">SendMessageCallback</SPAN>
SendMessageCallback
与 SendMessageTimeout 不同,这个函数在另外一个方向上对标准的 SendMessage 进行了扩展。它的行为与SendNotifyMessage 类似,只不过允许在对方处理完消息之后,指定一个本线程内的后续处理函数。仔细观察可以发现,SendNotifyMessage 其实是本函数的一个特例。
对这个函数的使用场景较少,实际上,作者几乎从来没有见到必须使用它的情况。网上有一些对此函数的讨论和测试代码,但很少有实用价值。(恐怕这也是 Windows Mobile 没有实现此函数的原因之一。)
PostQuitMessage
这个函数的名字具有迷惑性。事实上,它本身并不会投递任何消息,而是偷偷在系统内部置了一个标志,当调用 GetMessage 时会检测此标志位。若此标志位被置位,而且队列中已经没有别的符合条件的投递消息,则 GetMessage 返回 FALSE,用以终止消息循环。
不过,有人会有这样的疑惑。我们知道,PostMessage 当窗口句柄为 NULL 的时候,就相当于 PostThreadMessage(GetCurrentThreadId(), …),那么,为什么不用 PostMessage(NULL, WM_QUIT, 0, 0),而要引入这么一个单独的 API 呢?有的人给出的原因是,这个 API 出现在 Windows 的 16 位时代,当时还没有线程的概念。这个答案仔细推敲的话,其实似是而非,因为完全可以把进程的执行看作是一个线程。真正的原因,可能从前文能得到一些思考线索,尤其注意“队列中已经没有别的符合条件的投递消息”这个叙述。
PostThreadMessage
跨线程投递消息。我们知道,消息队列是属于线程的,所以,可以不指定目标窗口而只指定目标线程就投递消息。投递到目标线程的消息通常会被 GetMessage取出,但是,由于没有指定目标窗口,所以不会被派发到任何一个窗口回调函数中。
请注意上文中的通常二字。这是因为在一般的情况下,我们是按照 GetMessage(&msg, NULL, 0, 0) 这样的形式对 GetMessage 进行调用的,但是,第二个参数是一个窗口句柄,如果指定了一个合法的窗口句柄,那么 GetMessage 就只会取出与该窗口有关的投递消息。如果这样的调用放在线程的主消息循环中,就可能会造成消息积压(这和你在本线程中究竟创建了多少个窗口有关)。所幸的是,迄今我还没有见到过有谁这样使用 GetMessage。
- <SPAN style="COLOR: #33ff33">BroadcastSystemMessage[Ex]</SPAN>
BroadcastSystemMessage[Ex]
我们一般所接触到的消息都是发送给窗口的,其实, 消息的接收者可以是多种多样的,它可以是应用程序(application)、可安装驱动程序(installable driver)、网络驱动程序(networkdriver)、系统级设备驱动程序(system-leveldevice driver)等,用 BroadcastSystemMessage这个API可以对以上系统组件发送消息。
- InSendMessage<SPAN style="COLOR: #33ff33">[Ex]</SPAN>
InSendMessage[Ex]
这个函数用于在处理某条消息时,检查消息是不是来自于其他线程的发送操作。它的使用场景也极其有限,除非你确实计划限制某些消息的来源和产生方式。
- <SPAN style="COLOR: #33ff33">ReplyMessage</SPAN>
ReplyMessage
这个函数在 MSDN 中的解释非常简单,只有寥寥数语,几乎到了模糊不清的地步。从示例代码段来推测,其作用大概是:消息的接收线程(目标线程)在处理过程中可以通过调用此函数使得消息的发送线程(源线程)结束等待状态继续执行。
根据微软的文档,其官方建议是:在处理每个有可能来自于其他线程的消息的时候,如果某一步骤的处理会调用到导致线程移交控制的函数(原文如此:any function that causes the thread to yield control),都应该先调用InSendMessage 类属的函数进行判断,如果返回TRUE,则要立即使用 ReplyMessage 答复消息的源线程。
“会导致线程移交控制的函数”,MSDN 给出的例子是 DialogBox,这使得我做出自己的推测,这样的函数,至少包括会导致进入某种模态场景的函数。
至于“有可能来自于其他线程的消息”,在 Windows 世界里的现实状况是,几乎任何一个消息都会来自于其他线程。
我多年以来的观察可以断定,现实中有无数没有进行以上流程判断的代码都在运行,而且也几乎没有暴露出什么严重的不良后果。这使得我有理由猜测,微软也许已经把对此情况的处理隐含到了系统内部。更何况,Windows Mobile 中根本就没有ReplyMessage 这个 API。
- GetMessagePos
-
- <SPAN style="COLOR: #33ff33">GetMessageTime</SPAN>
GetMessagePos
GetMessageTime
这两个函数用于访问当前处理的消息的另外两个信息,对应于 MSG 结构里的相应域。它们存在的原因是因为窗口回调/消息处理函数一般都不会传递这两个数据。
- MsgWaitForMultipleObjects[Ex]
MsgWaitForMultipleObjects[Ex]
这是一个在讲到消息相关的内容时,十有八九会被人遗忘的 API。它属于传统的 ITC、IPC 和 Windows 特有的消息机制的交叉地带。不过,在 Windows 平台上,如果还没有了解并掌握这个函数,那一定不能称其为专家。
这个函数揭示了以下平时不太为人所注意的细节:
1、 消息和内核对象,有千丝万缕的联系
2、 消息和内核对象可以按照相似的方式去处理
如果说,SendMessageTimeout 是 Windows 平台下最强大的发送消息的机制,那么,MsgWaitForMultipleObjects[Ex] 就是最强大等待机制,它是 WaitMessage 和 WaitFor… 函数族的集大成者。根据我们上面使用 WaitMessage 和 PeekMessage 结合使用可以取代 GetMessage 的论断,我们也可以这样说,MsgWaitForMultipleObjects[Ex]是最强大的消息循环发动机。
仔细描述此函数会超出单纯的消息机制范畴,所以把深入学习它的工作遗留给各位自己去实践。
7. Windows 的消息辨析
7.1. SendMessage和PostMessage的区别
请考虑有面试考官问及此问题时你如何组织回答。J
7.2. SendMessage发送的消息不进入消息队列吗
提示:请考虑跨线程的情况。
这个说法不完全正确。当SendMessage发送的消息跨越线程边界时,消息其实被加入到了目标线程的消息队列里。不过,在线程队列里,别的线程Send过来的消息会被优先处理。
7.3. PostMessage(WM_QUIT)和PostQuitMessage()的区别,可能会产生怎样的差异化执行效果
提示:请考虑发生以上某个调用时,消息队列里不为空的情况。
7.4. 文章开头的经典消息循环正确么?
提示:请注意 GetMessage 的返回值。
曾经有很长一段时间,连微软的例子也这样写。但是,这样写其实是不对的。原因很简单,GetMessage不仅仅是取道消息返回 TRUE,取不到(遇到WM_QUIT 消息)返回FALSE这么单纯,它还会出错。出错时返回 -1。这就了能使得经典循环在GetMessage发生错误时变成死循环。微软的建议是,当GetMessage返回 -1 时,跳出循环,结束程序。
注:本文乃是数年前的培训讲义,文中有某处不完整,迄今未补,读者自察之。