细说UI线程和Windows消息队列
在 Windows应用程序中,窗体是由一种称为“ UI线程( User Interface Thread)”的特殊类型的线程创建的。
首先, UI线程是一种“线程”,所以它具有一个线程应该具有的所有特征,比如有一个线程函数和一个线程 ID。
其次,“ UI线程”又是“特殊”的,这是因为 UI线程的线程函数中会创建一种特殊的对象——窗体,同时,还一并负责创建窗体上的各种控件。
窗体和控件大家都很熟悉了,这些对象具有接收用户操作的功能,它们是用户使用整个应用程序的媒介,没有这样一个媒介,用户就无法控制整个应用程序的运行和停止,往往也无法直接看到程序的运行过程和最终结果。
那么,窗体和控件又是如何作到对用户操作进行响应的呢?这一响应是不是由窗体和控件自己“主动”完成的?
换句话说:
窗体和控件具不具备独立地响应用户操作(比如键盘和鼠标操作)的功能?
答案是否定的。
那就奇怪了,比如我们用鼠标点击了一个按钮,并且看到它“陷”下去了,然后又还原,之后,我们确实看到了程序执行了此按钮所对应的任务。难道不是按钮来响应用户操作的吗?
这实际上是一个错觉。这个错觉产生的根源在于不了解 Windows内部的运作机理。
简单地说,窗体和控件之所以能响应用户操作,关键在于负责创建它们的 UI线程拥有一个“消息循环( Message Loop ) ”。这个消息循环由线程函数负责启动,通常具有以下的“模样”(以C++代码表示):
MSG msg; //代表一条消息
BOOL bRet;
//从 UI线程消息队列中取出一条消息
while( (bRet = GetMessage( &msg, NULL, 0, 0 )) != 0)
{
if (bRet == -1)
{
//错误处理代码,通常是直接退出程序
}
else
{
TranslateMessage(&msg); //转换消息格式
DispatchMessage(&msg); //分发消息给相应的窗体
}
}
可以看到, 所谓消息循环,其实就是一个While循环语句罢了。
其中, GetMessage()函数每次从消息队列中取出一条消息,此消息的内容被填充到变量msg中。
TranslateMessage()函数主要用于将 WM_KEYDOWN和 WM_KEYUP消息转换 WM_CHAR消息。
提示:
使用C++开发Windows程序时,各种消息都有一个对应的符号常量,比如,这里的WM_KEYDOWN和WM_KEYUP代表用户按下一个键后所产生的消息。
消息处理的关键是 DispatchMessage()函数。这个函数根据取出的消息中所包含的窗体句柄,将这一消息转发给引此句柄所对应的窗体对象。
而窗体负责响应消息的函数称为“窗体过程( Window Procedure ) ”,窗体过程是一个函数,每个窗体一个 ,它大致拥有以下的“模样”( C++代码):
LRESULT CALLBACK MainWndProc(…… )
{
//……
switch (uMsg) //依据消息标识符进行分类处理
{
case WM_CREATE:
// 初始化窗体 .
return 0;
case WM_PAINT:
// 绘制窗体
return 0;
//
//处理其他消息
//
default:
//如果窗体没有定义处理此种消息的代码,则转去调用系统默认的消息处理函数
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
//……
}
可以看到, “窗体过程”不过就是一个多分支语句罢了 ,在这个语句中,窗体对不同类型的消息进行处理。
在 Windows中, UI控件也被视为一个“ Window”,它也拥有自己的“窗体过程”,因此,它也可以同窗体一样,具备处理消息的能力。
由此我们可以知道 UI线程所完成的大致工作就是:
UI 线程启动一个消息循环,每次从本线程所对应的消息队列中取出一条消息,然后根据消息所包容的信息,将其转发给特定的窗体对象,此窗体对象所对应的“窗体过程”函数被调用以处理这些消息。
上述描述只介绍了事情的后半段,还需要了解事情的前半段,那就是:
用户操作消息是怎样“跑”到UI线程的消息队列中的?
我们知道,Windows同时可以运行多个进程,每个进程又拥有多个线程,其中有一些线程是UI线程,这些 UI线程可能会创建不止一个窗体,那么问题发生了:
用户在屏幕上某个位置按了一下鼠标,相关信息是怎样传给特定的UI线程,并最终由特定窗体的“窗体过程”负责处理?
答案是操作系统负责完成消息的投寄工作。
操 作系统会监控计算机上的键盘和鼠标等输入设备,为每一个输入事件(由用户操作所引发,比如用户按了某个键)生成一个消息。根据事件发生时的情况(比如当前 激活的窗体负责接收用户按键,而依据用户点击鼠标的坐标可以知道用户在哪个窗体区域内点击了鼠标),操作系统会确定出此消息应该发给哪个窗体对象。
这些生成的消息会统一地先临时放置在一个“系统消息队列( system message queue )”中,然后,操作系统有一个专门的线程负责从这一队列中取出消息,根据消息的目标对象(就是窗体的句柄),将其移动到创建它的 UI线程所对应的消息队列中。操作系统在创建进程和线程时,都同时记录了大量的控制信息(比如通过进程控制块和句柄表可以查找到进程所创建的所有线程和引用的核心对象),因此,根据窗体句柄来确定此消息应属于哪个 UI线程对于操作系统来说是很简单的一件事。
注意, 每个UI线程都有一个消息队列,而不是每个窗体一个消息队列!
那么, 操作系统是不是会为每一个线程都创建一个消息队列呢?
答案是:只有当一个线程调用 Win32 API中的 GDI( Graphics Device Interface)和 User函数时,操作系统才会将其看成是一个 UI线程,并为它创建一个消息队列。
需要注意的是,消息循环是由UI线程的线程函数启动的 ,操作系统不管这件事,它只管为UI线程创建消息队列。因此,如果某个 UI线程的线程函数中没有定义消息循环,那么,它所拥有的窗体是无法正确绘制的。
请看以下代码:
class Program
{
static void Main(string[] args)
{
Form1 frm = new Form1();
frm.Show();
Console.ReadKey();
}
}
上述代码属于一个控制台应用程序,在 Main()函数中,创建了一个 Form1窗体对象,调用它的Show()方法显示,然后调用 Console.ReadKey()方法等待用户按键结束进程。
程序运行的截图如下:
如上图所示,会发现窗体显示一个空白方框,不接收任何的鼠标和键盘操作。
原因何在?
产生这一现象的原因可以解释如下:
由于控制台程序需要运行于一个“控制台窗口”中,因此,操作系统认为它是一个UI线程,会为其创建一个消息队列。
Main() 函数由于是程序入口点,所以执行它的线程是进程的第一个线程(即主线程),在主线程中,创建了一个 Form1 窗体对象,对其 Show() 方法的调用只是设置其 Visible 属性 =true ,这将导致 Windows 调用相应的 Win32 API 函数显示窗体,但这一调用并非阻塞调用,也没有启动一个消息循环,所以 Show() 方法很快返回,继续执行下一句“ Console.ReadKey(); ”,此句的执行导致主线程调用相应的 Win32 API 函数等待用户按钮,阻塞执行。
注意,如果这时用户用鼠标点击窗体,尝试与窗体交互,相应的消息的确发到了控制台应用程序主线程的消息队列中,但主线程并未启动一个消息循环(你看到 Main() 函数中有任何的循环语句吗?)以取出消息队列中的消息并“分发”给窗体,因此,窗体函数没被调用,自然无法正确绘制了。
如果窗体本身是调用 ShowDialog() 方法显示的,这是一个阻塞调用,它会在内部启动一个消息循环,此消息循环可以从主线程的消息队列是提取消息,从而让此窗体成为一个“正常”的窗体。
当用户关闭窗体后, Main() 方法后继的代码继续执行,直到运行结束。
如果在创建窗体对象并调用 Show() 方法显示后,主线程没有调用“ Console.ReadKey(); ”之类方法“暂停”,而是直接退出,这将导致操作系统中止整个进程,回收所有核心对象,因此,创建的窗体也会被销毁,不可能再看见它。
现在再考虑复杂一些:如果我们在另一个线程中创建并显示窗体,又将如何?
class Program
{
static void Main(string[] args)
{
Thread th = new Thread(ShowWindow);
th.Start() ;// 在另一个线程中创建并显示窗体
Console.WriteLine(" 窗体已创建 , 敲任意键退出 ...");
Console.ReadKey();
Console.WriteLine(" 主线程退出 ...");
}
static void ShowWindow()
{
Form1 frm = new Form1();
frm.ShowDialog();
}
}
程序运行结果如下:
可以看到,由于窗体使用 ShowDialog() 显示,因此,控制台窗口和应用程序窗体都能正常地接收用户的键盘和鼠标消息。即使主线程退出了,只要窗体没有关闭,操作系统会认为“进程”仍在执行,因此,控制台窗口会保持显示,直到窗体关闭,整个进程才结束。
在这种情况下,本示例程序中有两个 UI 线程,一个是控制台窗口,另一个创建应用程序窗体的那个线程。
如果在线程函数中创建窗体后,改为 Show() 方法显示,由于 Show() 方法没有启动消息循环,所以窗体不能正确绘制,并且会随着创建它的 UI 线程的终止而被操作系统回收资源。
有趣的是,我们可以使用 Visual Studio 设置“控制台应用程序”不创建“控制台窗口”,只需将项目类型改为“Windows Application” 即可。
这时,示例程序运行时, Visual Studio 会报告错误:
引发这一错误的原因是应用程序主线程不再创建控制台窗口,操作系统不再认为它是 UI 线程,不为其创建消息队列,主线程将无法接收到任何按键消息, 因此 Console.ReadKey() 底层调用的 Win32API 函数无法正常运行,引发程序异常。
结束语:
本文是我个人探索.NET技术内幕过程中的一个小结,希望能对大家开发多线程程序有所帮助。特别是,对本文涉及到的技术我的理解若有错误,欢迎指正。
===========================
网友Analyst指出:
回复 Analyst :
我问一下Analyst网友: cin 中的按键信息从哪来?不从消息队列从哪?难道Windows操作系统允许一个用户进程自己直接监控键盘这一硬件?
事实上,每个 Console 窗口都可以有一个(或多个)“屏幕缓冲区( Screen buffer ) ”和一个“输入缓冲区”,这些缓存区在创建 Console 时被同步创建。当输入缓冲区创建之后,可以从线程消息队列中提取按键信息。
cin/cout 只不过是对这些缓冲区的面向对象封装罢了,被称为“标准输入 / 输出流”。没有消息队列,你缓冲什么?当前激活的屏幕缓冲区句柄就是标准输出(standard output)和标准错误(standard error)句柄
Console.ReadKey ()在底层调用 Win32 API 函数 ReadConsoleInput ()接收按键,此函数的声明如下:
BOOL WINAPI ReadConsoleInput(
__in HANDLE hConsoleInput,
__out PINPUT_RECORD lpBuffer,
__in DWORD nLength,
__out LPDWORD lpNumberOfEventsRead
);
注意其第一个参数是代表输入缓冲区的句柄。由于示例程序中输入缓冲区不存在,所以引发异常。
如果调用Console.Read()方法,则不会引发异常。因为此方法在内部调用StreamReader.Read()方法,当屏幕缓区不存在时,它调用StreamReader.Null.Read(),此方法不会引发异常。
相关的关键代码如下:
try
{
//……
Stream stream =
OpenStandardInput(0x100);
if (stream ==
Stream.Null)
{
@null =
StreamReader.Null;
}
else
{
//……
}
Thread.MemoryBarrier();
_in = @null;
}
finally
{
//...
}
return _in;