为 C# 开发者理解 Windows 消息队列






4.77/5 (19投票s)
深入了解 Windows 操作系统的一些核心底层。
引言
本文探讨了 Windows 系列操作系统中消息系统的背后机制。与其说是给你一个新的编码玩具,不如说是向 C# 开发者解释 Windows 工作原理的一些基础知识。反过来,更好地理解 Windows 可以帮助你成为一名更优秀的程序员。
概念化这个混乱的局面
在不同线程甚至不同进程之间传递信息的一种常用方法是消息传递。这是一个线程或进程可以安全地发布消息,由另一个线程或进程接收。Windows 使用消息来通知其窗口发生了诸如绘制、调整大小、键入和点击等事件。每当这些操作之一发生时,一个消息就会被发送到窗口,通知它发生了该事件,此时窗口就可以处理它。例如,在 Form.MouseMove
事件的底层,它就是为了响应来自 Windows 的 WM_MOUSEMOVE
消息而被触发的。
这对于用户界面相关的东西来说是合理的,但我们也可以将消息传递用于不可见的窗口,并定义我们自己的消息来发布到窗口。通过这种方式,我们可以使用其中一个不可见的窗口作为消息接收器。每当它收到消息时,它就会被调用,我们可以挂钩这个过程。使用窗口的优势在于,向窗口发送和接收消息是线程安全的,并且可以跨进程工作。值得注意的是,.NET 中有更好的远程处理选项,它们不会将你限制在 Windows 操作系统上,但我们还是要探讨这个,因为 WinForms 和 Windows 一样,都是建立在这个基础上的。
窗口到底是什么?
窗口是一个对象,它有关联的消息队列,并且可能(但不总是)呈现用户界面。它有一个关联的“窗口类”,告诉我们它是什么类型的窗口,我们也可以定义自己的窗口类,我们接下来就会这么做。此外,它有一个句柄可以用来引用它,并且窗口类有一个回调机制来通知该类的窗口有传入的消息。对于一个或多个窗口,有一个线程在循环运行,获取消息然后分发消息。这个线程驱动所有关联的窗口。通常,所有可见的窗口都是在应用程序的主线程上创建的。让我们更深入地探讨一下。
让我们看一个简单的 C 程序来注册一个自定义窗口类然后显示一个窗口。这是一个微软的示例,就是做这个的。如果你不懂 C 也没关系,尽量跟着看,因为我会解释,并且我们最终会接触到一些 C# 代码,我保证。
HINSTANCE hinst;
HWND hwndMain;
int PASCAL WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
LPSTR lpszCmdLine, int nCmdShow)
{
MSG msg;
BOOL bRet;
WNDCLASS wc;
UNREFERENCED_PARAMETER(lpszCmdLine);
// Register the window class for the main window.
if (!hPrevInstance)
{
wc.style = 0;
wc.lpfnWndProc = (WNDPROC) WndProc;
wc.cbClsExtra = 0;
wc.cbWndExtra = 0;
wc.hInstance = hInstance;
wc.hIcon = LoadIcon((HINSTANCE) NULL,
IDI_APPLICATION);
wc.hCursor = LoadCursor((HINSTANCE) NULL,
IDC_ARROW);
wc.hbrBackground = GetStockObject(WHITE_BRUSH);
wc.lpszMenuName = "MainMenu";
wc.lpszClassName = "MainWndClass";
if (!RegisterClass(&wc))
return FALSE;
}
hinst = hInstance; // save instance handle
// Create the main window.
hwndMain = CreateWindow("MainWndClass", "Sample",
WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT,
CW_USEDEFAULT, CW_USEDEFAULT, (HWND) NULL,
(HMENU) NULL, hinst, (LPVOID) NULL);
// If the main window cannot be created, terminate
// the application.
if (!hwndMain)
return FALSE;
// Show the window and paint its contents.
ShowWindow(hwndMain, nCmdShow);
UpdateWindow(hwndMain);
// Start the message loop.
while( (bRet = GetMessage( &msg, NULL, 0, 0 )) != 0)
{
if (bRet == -1)
{
// handle the error and possibly exit
}
else
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
// Return the exit code to the system.
return msg.wParam;
}
所以这里有三到四个基本步骤,取决于你的需求。
- 用关于窗口类的详细信息填充一个
WNDCLASS
结构体,包括它的名称 "MainWndClass"、其关联的回调过程以及一些样式,这些样式通常不重要,除非窗口呈现 UI。创建后,向操作系统注册该窗口类。 - 使用我们注册的窗口类、几个样式标志、我们程序的实例句柄(
hinst
)和一个标题 "Sample" 来创建窗口。 - 显示并绘制窗口。这仅在窗口需要呈现 UI 时适用。我们不会这样做。在这种情况下,使用
Form
几乎总是更好的选择,它封装了所有这些操作。 - 最后启动消息循环。如我所说,消息循环获取消息然后分发消息。在这里,我们看到它还翻译消息,这主要是 Windows 处理键盘“加速键”快捷方式的方式。最后,它分发消息。对于收到的每条消息,这会调用我们之前传递的 WndProc 窗口回调函数。没有消息循环,窗口将是“冻结”的,因为它无法响应消息,所以它永远不会知道何时移动、绘制、调整大小或处理用户输入。再次强调,一个线程的消息循环可以驱动多个窗口。
那么消息循环呢?
当你在 .NET WinForms 应用程序中调用 Application.Run()
时,你会注意到它会阻塞直到你的应用程序退出。在 Application.Run()
内部,很可能埋藏在层层封装之下的就是这样一个消息循环。它看起来像 while(0!=GetMessage(ref msg)) { TranslateMessage(ref msg); DispatchMessage(ref msg); ... }
,基本上和前面的 C 代码一样。那个循环就是将你的程序阻塞在 Application.Run()
调用上的原因。你创建的每一个 Form
都会共享同一个线程的消息循环。这个线程被称为 UI 线程。UI 线程很重要,因为它是“用户看到的”那个线程,其他线程通常需要与它通信以接受或更新来自 UI 的信息,但它们必须以线程安全的方式进行。这就是为什么我们使用消息传递,它可以安全地跨线程发送和接收消息。我们可以通过这种方式从辅助线程通信回 UI 线程,然后 UI 线程本身可以使用该信息更新 UI 窗口,这是一个安全的操作,因为它在那个时候没有从辅助线程执行任何操作。
在本文中,我们不会创建自己的消息循环,但我们会做其余的部分。我们还将通过在该线程上创建另一个窗口来“接入”UI 线程的消息循环,然后在其上接收消息。这样,最终,我们的代码将从 Application.Run()
循环的某个地方执行。
消息是什么样子的?
一个消息由一个 int
类型的消息标识符和两个 IntPtr
类型的参数组成,这两个参数的含义根据消息标识符的不同而变化。好消息是,它就只有这些。坏消息是,它就只有这些。如果你需要传递的信息比两个 IntPtr
能容纳的更多,那么你必须想办法将其拆分到多个消息中,或者从非托管堆中分配内存来保存参数,并将指针作为其中一个 IntPtr
传递。最后一种技术不能跨进程工作。
我们打算用它们做什么?
在演示应用程序中,我们有两个基本部分,分别在左侧和右侧。左侧是线程间通信的简单演示。右侧是进程间通信的简单演示。每次你点击“运行任务”时,左侧会生成一个任务,该任务使用消息队列将进度反馈给 UI 线程。在右侧,你可以选择连接到应用程序的另一个实例(如果存在),启动另一个实例,然后使用 ListBox
和提供的 NumericUpDown
控件将你设置的任何数字发送到列表框中选定的窗口。另一个应用程序随后会显示一个消息框来确认消息的接收。ListBox
只显示窗口句柄,这不是很友好。可以为这些窗口创建一个友好的应用程序实例编号系统,但这会增加很多复杂性,并且会分散对核心目标的注意力。
编写这个混乱的程序
MessageWindow 类
首先,我们必须创建一个 MessageWindow
类,它处理所有窗口机制,包括创建、接收和发送消息,以及枚举其他窗口。我最初尝试使用 System.Windows.Forms.NativeWindow
来做这件事,这可能行得通,但它没有注册自定义窗口类的功能,而我们的进程间通信需要这个功能。我很可能可以通过一些技巧绕过它,但考虑到我还需要对窗口进行所有其他的 P/Invoke 调用,它只会为我节省微不足道的代码量,而且我不想在以后使用它时遇到不可预见的限制,因为我们正在偏离常规用法。让我们看看构造函数,因为这里的代码与我们之前看到的 C 代码的一部分相似。
if (string.IsNullOrEmpty(className))
className = "MessageWindow";
_wndProc = WndProc;
// Create WNDCLASS
var wndclass = new WNDCLASS();
wndclass.lpszClassName = className;
wndclass.lpfnWndProc = _wndProc;
var classAtom = RegisterClassW(ref wndclass);
var lastError = Marshal.GetLastWin32Error();
if (classAtom == 0 && lastError != ERROR_CLASS_ALREADY_EXISTS)
{
throw new Exception("Could not register window class");
}
// Create window
_handle = CreateWindowExW(
0,
wndclass.lpszClassName,
String.Empty,
0,
0,
0,
0,
0,
IntPtr.Zero,
IntPtr.Zero,
IntPtr.Zero,
IntPtr.Zero
);
注意,我们正在注册一个 WNDCLASS
并像之前在 C 中那样创建窗口。不过,这次没有消息循环。这是因为在这个项目中,我们使用的是 Application.Run()
内部的消息循环。没有标志或其他设置可以指定这一点。基本上,Windows 会根据窗口创建时所在的线程“知道”哪个窗口属于哪个消息循环,所以我们不需要指定我们正在使用哪一个。我们所要做的就是在主线程上创建窗口。我想指出,MessageWindow
的代码最初来自 StackOverflow 上的 MoreChilli。由于它写得很好,为我节省了一些时间,因为我只需要根据我的目的进行修改,而不是从头开始编写。
让我们继续看这个类的一些其他重要部分。让我们看看我们的 WndProc
窗口过程回调。
IntPtr WndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
{
if (msg >= WM_USER && msg < WM_USER + 0x8000)
{
var args = new MessageReceivedEventArgs(msg - WM_USER, wParam, lParam);
MessageReceived?.Invoke(this, args);
if (!args.Handled)
return DefWindowProcW(hWnd, msg, wParam, lParam);
return IntPtr.Zero;
}
return DefWindowProcW(hWnd, msg, wParam, lParam);
}
每当有消息发送到我们的消息窗口时,这个例程就会被调用。在这里,我们寻找的是在 WM_USER
(0x400) 到 WM_USER+0x7FFF
范围内的 Windows 消息 ID。如果是,我们用消息信息创建一个新的 MessageReceivedEventArgs
,从 msg
中减去 WM_USER
使其从零开始,然后我们触发 MessageReceived
事件。事件之后,我们检查是否有任何事件接收器将 Handled
的值设置为 true
。如果是,我们就不调用窗口的默认窗口过程,因为我们不想将它传递给进一步处理。使用 WM_USER
的原因是因为我们想定义自己的窗口消息,而在我们接受的范围内的消息是 Windows 保留的“用户定义”窗口消息范围。我们不想处理像鼠标移动这样的消息,因为这个窗口不呈现用户界面。我们不关心我们可能收到的标准消息——只关心用户定义的消息。
MessageWindow
的另一个非常重要的特性是枚举当前机器上所有活动的消息窗口。这是为 IPC 演示提供的。我们需要看到所有可用的窗口,以便将列表呈现给用户,然后他们可以用它来与外部的 MessageWindow
通信。
public static IReadOnlyList<IntPtr> GetMessageWindowHandlesByClassName(string className)
{
if (string.IsNullOrEmpty(className))
className = "MessageWindow";
var result = new List<IntPtr>();
var sb = new StringBuilder(256);
EnumWindows(new EnumWindowsProc((IntPtr hWnd, IntPtr lParam) =>
{
GetClassNameW(hWnd, sb, sb.Capacity);
if (className == sb.ToString())
{
result.Add(hWnd);
}
return true;
}), IntPtr.Zero);
Thread.Sleep(100);
return result;
}
这是一个奇怪的例程,因为 EnumWindows()
的工作方式。你可能会认为窗口的枚举应该有一个明确的计数,但它没有。它会反复回调你,提供新的窗口句柄,但从不告诉你什么时候给完了所有的句柄。因此,我们必须 Sleep()
100 毫秒,给它时间来枚举。这应该是足够的时间了。我不喜欢这样,但也没什么办法。请注意,在添加每个返回的窗口之前,我们正在将其窗口类名与传入的 className
进行比较。你也可以向 MessageWindow
构造函数传递一个 className
。它们必须匹配。这样,你可以用不同的类名创建新的消息窗口,然后获取具有你想要的类名的窗口列表。
PostMessage()
以线程安全的方式向窗口发送消息。PostRemoteMessage()
以线程安全的方式向另一个窗口发送消息。它们都在底层使用 Win32 的 PostMessage()
调用来实现。
public void PostMessage(int messageId, IntPtr wParam, IntPtr lParam)
{
PostMessage(_handle, messageId + WM_USER, wParam, lParam);
}
public static void PostRemoteMessage
(IntPtr hWnd, int messageId, IntPtr parameter1, IntPtr parameter2)
{
PostMessage(hWnd, messageId + WM_USER, parameter1, parameter2);
}
这两个方法的主要区别在于 PostRemoteMethod()
是 static
的,并且接受一个远程窗口的句柄——实际上是任何窗口,因为 Windows 不关心它是否是进程本地的。
其余代码只是 P/Invoke 定义和一些杂项。
演示应用程序
如前所述,该演示允许你使用 MessageWindow
从其他线程或进程接收消息。演示代码还具有向其他进程传输消息的功能。它使用 MessageWindow
来完成繁重的工作。首先,我们在窗体的构造函数中创建它,并在 OnClosed()
虚方法中添加相应的代码以安全地销毁它。
MessageWindow _msgWnd;
public Main()
{
InitializeComponent();
_msgWnd = new MessageWindow("MainMessageWindow");
_msgWnd.MessageReceived += _msgWnd_MessageReceived;
ProcessListBox.Items.Clear(); // sanity
foreach (var hWnd in MessageWindow.GetMessageWindowHandlesByClassName("MainMessageWindow"))
if(_msgWnd.Handle!=hWnd)
ProcessListBox.Items.Add(hWnd.ToInt64().ToString());
}
protected override void OnClosed(EventArgs e)
{
if (null != _msgWnd)
{
_msgWnd.Dispose();
_msgWnd = null;
}
base.OnClosed(e);
}
注意我们是如何给窗口指定了 "MainMessageWindow" 这个窗口类名的。我们还挂钩了 _msgWnd
的 MessageReceive
事件,以便我们能够响应传入的消息。之后,我们清空列表以防万一(虽然它应该是空的,但如果你在设计器中添加了东西,它就不会是空的),并枚举系统上除我们自己之外的所有窗口,用它们的值填充 ListBox
。
如前所述,我们的 MessageReceived
处理程序负责响应 _msgWnd
上传入的窗口消息。
void _msgWnd_MessageReceived(object sender, MessageReceivedEventArgs args)
{
switch(args.MessageId)
{
case MSG_REMOTE:
MessageBox.Show("Value is " + args.Parameter1.ToInt32().ToString(),
"Remote message received");
args.Handled = true;
break;
case MSG_PROGRESS:
var ctrl =
TaskPanel.Controls[args.Parameter1.ToInt32()-1] as WorkerProgressControl;
if(null!=ctrl)
ctrl.Value = args.Parameter2.ToInt32();
args.Handled = true;
break;
}
}
在这里,我们关心两种可能性:接收远程消息,以及接收来自其他线程的任何当前执行任务的进度消息。在第一种可能性中,我们只显示一个消息框,显示消息的第一个参数,该参数包含了远程进程指定的值。这将是远程进程的 NumericUpDown
控件中的值,我们稍后会讲到。
第二种可能性是一条消息,告诉我们更新相应任务的 ProgressBar
。此消息来自代表该任务的本地线程。第一个参数包含任务的 ID,所以我们知道要更新哪个进度条。第二个参数是进度条的值。由于 MessageReceived
事件在 UI 线程上触发,因为它是在 Application.Run()
内部调用的,所以我们可以安全地更新我们的 Form
控件。
我们还有两部分代码需要介绍,第一部分是发送远程消息的代码,每当我们的 NumericUpDown
控件的 Value
发生变化时,我们就会执行此操作。
void TransmitUpDown_ValueChanged(object sender, EventArgs e)
{
if(-1< ProcessListBox.SelectedIndex)
{
MessageWindow.PostRemoteMessage(
new IntPtr(int.Parse(ProcessListBox.SelectedItem as string)),
MSG_REMOTE,
new IntPtr((int)TransmitUpDown.Value),
IntPtr.Zero);
}
}
在这里,我们检查 ListBox
中是否选中了某个句柄,如果是,我们就向选定的句柄发送一个 ID 为 MSG_REMOTE
的远程消息,并将 Value
作为第一个消息参数发送。这将导致远程进程的 MessageReceived
事件触发,并显示我们前面提到的相应的 MessageBox
。
我们需要介绍的下一部分代码是我们可以创建的本地任务,每当点击“运行任务” Button
时就会创建这些任务。
void RunTaskButton_Click(object sender, EventArgs e)
{
var id = TaskPanel.Controls.Count + 1;
var wpc = new WorkerProgressControl(id);
TaskPanel.SuspendLayout();
TaskPanel.Controls.Add(wpc);
wpc.Dock = DockStyle.Top;
TaskPanel.ResumeLayout(true);
Task.Run(() => {
for (var i = 0; i <= 100; ++i)
{
_msgWnd.PostMessage(MSG_PROGRESS, new IntPtr(id), new IntPtr(i));
Thread.Sleep(50);
}
});
}
在这里,他创建了一个新的 WorkerProgressControl
,它只包含一个 Label
、一个 ProgressBar
和一个 Value
。我们在构造函数中给它一个 id
,然后我们将其停靠到我们自动滚动的任务 Panel
的顶部,以创建一个快速简陋的任务列表。最后,我们 Run()
一个 Task
,它执行一些伪“工作”,这就是那个循环及其内部所有内容所做的事情。请注意,我们使用 _msgWind.PostMessage()
报告进度,其中第一个参数是我们的任务 id
,这样我们就知道要更新哪个 WorkerProgressControl
,第二个参数是进度值。
Windows 使用其窗口消息队列来通知 UI 鼠标点击等事件,并在任务之间创建线程安全的交互,或在进程之间进行 IPC。我们涵盖了很多内容,但希望现在你对这是如何工作的有了更深入的理解。就写到这里了!
免责声明
本文中的代码不适用于生产环境。在正常情况下,我不建议在 .NET 中使用 Windows 消息队列。还有其他方法可以进行远程处理、消息传递和线程同步,这些方法是跨平台的、功能更强大,并且更符合我们在 .NET 中的做法。本文仅为演示目的,旨在让你更好地了解 Windows。
历史
- 2020年7月22日 - 首次提交