65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.77/5 (19投票s)

2020年7月22日

MIT

13分钟阅读

viewsIcon

34814

downloadIcon

851

深入了解 Windows 操作系统的一些核心底层。

Windows Message Queue Demo

引言

本文探讨了 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; 
} 

所以这里有三到四个基本步骤,取决于你的需求。

  1. 用关于窗口类的详细信息填充一个 WNDCLASS 结构体,包括它的名称 "MainWndClass"、其关联的回调过程以及一些样式,这些样式通常不重要,除非窗口呈现 UI。创建后,向操作系统注册该窗口类。
  2. 使用我们注册的窗口类、几个样式标志、我们程序的实例句柄(hinst)和一个标题 "Sample" 来创建窗口。
  3. 显示并绘制窗口。这仅在窗口需要呈现 UI 时适用。我们不会这样做。在这种情况下,使用 Form 几乎总是更好的选择,它封装了所有这些操作。
  4. 最后启动消息循环。如我所说,消息循环获取消息然后分发消息。在这里,我们看到它还翻译消息,这主要是 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" 这个窗口类名的。我们还挂钩了 _msgWndMessageReceive 事件,以便我们能够响应传入的消息。之后,我们清空列表以防万一(虽然它应该是空的,但如果你在设计器中添加了东西,它就不会是空的),并枚举系统上除我们自己之外的所有窗口,用它们的值填充 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日 - 首次提交
© . All rights reserved.