【问题标题】:Cannot exit message loop from thread using Windows API and C++无法使用 Windows API 和 C++ 从线程退出消息循环
【发布时间】:2021-07-05 19:00:35
【问题描述】:

我正在尝试实现以下场景:

要求

编写一个 C++ 程序来捕获 Windows 操作系统上的所有键盘输入。程序应该开始捕获击键,大约 3 秒后(具体时间不是很相关,可能是 4/5/等),程序应该停止捕获击键并继续执行。

在开始实际的实现细节之前,我想澄清一下,我更喜欢以练习的形式编写需求,而不是提供冗长的描述。我不是想为家庭作业收集解决方案。 (其实我很支持这样的问题,只要做得好,但这里不是这样)。

我的解决方案

在过去几天研究了不同的实现之后,以下是迄今为止最完整的一个:

#include <iostream>
#include <chrono>
#include <windows.h>
#include <thread>

// Event, used to signal our thread to stop executing.
HANDLE ghStopEvent;

HHOOK keyboardHook;

DWORD StaticThreadStart(void *)
{
  // Install low-level keyboard hook
  keyboardHook = SetWindowsHookEx(
      // monitor for keyboard input events about to be posted in a thread input queue.
      WH_KEYBOARD_LL,

      // Callback function.
      [](int nCode, WPARAM wparam, LPARAM lparam) -> LRESULT {
        KBDLLHOOKSTRUCT *kbs = (KBDLLHOOKSTRUCT *)lparam;

        if (wparam == WM_KEYDOWN || wparam == WM_SYSKEYDOWN)
        {
          // -- PRINT 2 --
          // print a message every time a key is pressed.
          std::cout << "key was pressed " << std::endl;
        }
        else if (wparam == WM_DESTROY)
        {
          // return from message queue???
          PostQuitMessage(0);
        }

        // Passes the keystrokes
        // hook information to the next hook procedure in the current hook chain.
        // That way we do not consume the input and prevent other threads from accessing it.
        return CallNextHookEx(keyboardHook, nCode, wparam, lparam);
      },

      // install as global hook
      GetModuleHandle(NULL), 0);

  MSG msg;
  // While thread was not signaled to temirnate...
  while (WaitForSingleObject(ghStopEvent, 1) == WAIT_TIMEOUT)
  {
    // Retrieve the current messaged from message queue.
    GetMessage(&msg, NULL, 0, 0);
    TranslateMessage(&msg);
    DispatchMessage(&msg);
  }

  // Before exit the thread, remove the installed hook.
  UnhookWindowsHookEx(keyboardHook);

  // -- PRINT 3 --
  std::cout << "thread is about to exit" << std::endl;

  return 0;
}

int main(void)
{
  // Create a signal event, used to terminate the thread responsible
  // for captuting keyboard inputs.
  ghStopEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

  DWORD ThreadID;
  HANDLE hThreadArray[1];

  // -- PRINT 1 --
  std::cout << "start capturing keystrokes" << std::endl;

  // Create a thread to capture keystrokes.
  hThreadArray[0] = CreateThread(
      NULL,              // default security attributes
      0,                 // use default stack size
      StaticThreadStart, // thread function name
      NULL,              // argument to thread function
      0,                 // use default creation flags
      &ThreadID);        // returns the thread identifier

  // Stop main thread for 3 seconds.
  std::this_thread::sleep_for(std::chrono::milliseconds(3000));

  // -- PRINT 4 --
  std::cout << "signal thread to terminate gracefully" << std::endl;

  // Stop gathering keystrokes after 3 seconds.
  SetEvent(ghStopEvent);

  // -- PRINT 5 --
  std::cout << "from this point onwards, we should not capture any keystrokes" << std::endl;

  // Waits until one or all of the specified objects are
  // in the signaled state or the time-out interval elapses.
  WaitForMultipleObjects(1, hThreadArray, TRUE, INFINITE);

  // Closes the open objects handle.
  CloseHandle(hThreadArray[0]);
  CloseHandle(ghStopEvent);

  // ---
  // DO OTHER CALCULATIONS
  // ---

  // -- PRINT 6 --
  std::cout << "exit main thread" << std::endl;

  return 0;
}

实现细节

主要要求是在一定时间内捕获击键。在那之后,我们不应该退出主程序。在这种情况下,我认为合适的是创建一个单独的线程,该线程将负责捕获过程并使用事件向线程发出信号。为了更接近目标平台,我使用了 windows 线程,而不是 c++0x 线程。

主函数首先创建事件,然后创建负责捕获击键的线程。为了满足时间要求,我能想到的最懒惰的实现是让主线程停止一段时间,然后通知辅助线程退出。之后,我们清理处理程序并继续进行任何所需的计算。

在辅助线程中,我们首先创建一个低级全局键盘挂钩。回调是一个 lambda 函数,负责捕获实际的击键。我们还想调用CallNextHookEx,以便我们可以将消息提升到链上的下一个钩子,并且不会破坏任何其他程序的正常运行。在钩子初始化之后,我们使用 Windows API 提供的GetMessage 函数来使用任何全局消息。这一直重复,直到我们发出信号以停止线程。在退出线程之前,我们解开回调。

我们还在整个程序执行过程中输出某些调试消息。

预期行为

运行上面的代码,应该会输出类似下面的消息:

start capturing keystrokes
key was pressed 
key was pressed 
key was pressed 
key was pressed 
signal thread to terminate gracefully
thread is about to exit
from this point onwards, we should not capture any keystrokes
exit main thread

您的输出可能会有所不同,具体取决于捕获的击键次数。

实际行为

这是我得到的输出:

start capturing keystrokes
key was pressed 
key was pressed 
key was pressed 
key was pressed 
signal thread to terminate gracefully
from this point onwards, we should not capture any keystrokes
key was pressed 
key was pressed
key was pressed

第一眼看到输出就会发现:

  • 未调用 unhook 函数
  • 程序不断捕获击键,这可能表明我处理消息队列的方式有问题

我从消息队列中读取消息的方式有问题,但经过数小时的不同方法后,我找不到任何具体实现的解决方案。我处理终止信号的方式也可能有问题。

注意事项

  • 在 SO 中,我越接近找到答案,就是 this 问题。但是,该解决方案对我的帮助并没有我想要的那么大。
  • 提供的实现是一个最小的可重现示例,无需导入任何外部库即可编译。
  • 建议的解决方案是将捕获击键功能作为一个单独的子进程来实现,我们可以随时在其中启动和停止。但是,我对使用线程找到解决方案更感兴趣。我不确定这是否可能(可能)。
  • 以上代码不包含任何错误处理。这是为了防止可能的代码过度膨胀。

如果您有任何问题,请随时发表评论!提前感谢您抽出时间阅读此问题并可能发布答案(这将是惊人的!)。

【问题讨论】:

  • 问得好。我向你致敬。
  • 在你当前有注释的地方放一些调试输出 "// 在退出线程之前,删除已安装的钩子。"
  • @BenVoigt 您对哪种调试输出感兴趣?只是一个静态的或特定的东西。我还想指出,即使我没有进行任何错误处理,程序也可以正常运行。我只是提交它们以防止代码过度膨胀
  • 你在这里创建什么专用线程?
  • @RbMm 我想根据我的程序启动和结束捕获功能。这就是为什么我认为最好把它放在另一个线程中。我检查了你的答案和你介绍的不同观点。

标签: c++ windows multithreading winapi message-queue


【解决方案1】:

我认为这是你的问题:

  while (WaitForSingleObject(ghStopEvent, 1) == WAIT_TIMEOUT)
  {
    // Retrieve the current messaged from message queue.
    GetMessage(&msg, NULL, 0, 0);
    TranslateMessage(&msg);
    DispatchMessage(&msg);
  }

原因是当前您的循环可能会永远卡在GetMessage() 步骤上,并且再也不会查看手动重置事件。

解决方法是简单地将WaitForSingleObject + GetMessage 的组合替换为MsgWaitForMultipleObjects + PeekMessage

您犯此错误的原因是您不知道GetMessage 仅将已发布 消息返回到消息循环。如果找到已发送的消息,它会从GetMessage 内部调用处理程序,并继续查找已发布的消息。由于您还没有创建任何可以接收消息的窗口,并且您没有调用PostThreadMessage1,所以GetMessage 永远不会返回。

while (MsgWaitForMultipleObjects(1, &ghStopEvent, FALSE, INFINITE, QS_ALLINPUT) > WAIT_OBJECT_0) {
   // check if there's a posted message
   // sent messages will be processed internally by PeekMessage and return false
   if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
      TranslateMessage(&msg);
      DispatchMessage(&msg);
   }
}

1 您有逻辑可以发布WM_QUIT,但它的条件是在低级键盘挂钩中接收WM_DESTROY,并且WM_DESTROY 不是键盘消息。一些钩子类型可以看到WM_DESTROY,但WH_KEYBOARD_LL 看不到。

【讨论】:

  • 当您发布代码 sn-p 时,我正准备编辑我的答案。该解决方案似乎工作正常。我将在接下来的几个小时内进行更多测试,如果发生任何奇怪的事情,我会回复你。无论如何,我要非常感谢你。我现在正在处理这个问题 2 天,但我对 Windows API 还不满意。我接受这个解决方案。您添加的任何 cmets 和引用对我也很有帮助。
  • @GeorgeGkasdrogkas:我强烈推荐 Raymond Chen 的博客“The Old New Thing”。例如,这篇文章是高度相关的:devblogs.microsoft.com/oldnewthing/20050405-46/?p=35973,后续也是如此:devblogs.microsoft.com/oldnewthing/20050406-57/?p=35963 但是MsgWaitForMulitpleObjects 应该比WaitForSingleObject+WaitMessage+PeekMessage 更有效。
  • PeekMessage 仅当MsgWaitForMultipleObjects 返回WAIT_OBJECT_0 + nCount 时才存在感知调用 - 所以WAIT_OBJECT_0 + 1 在具体情况下
  • @RmMm:是的,我把条件写成“循环而不是停止事件”,但你也可以把它表达为“循环,而它是消息等待状态”跨度>
【解决方案2】:

我认为适合这种情况的方法是创建一个单独的 负责捕获过程的线程

如果另一个线程只是等待这个线程并且一直无事可做,则没有必要这样做

你可以使用这样的代码。

LRESULT CALLBACK LowLevelKeyboardProc(int code, WPARAM wParam, LPARAM lParam)
{
    if (HC_ACTION == code)
    {
        PKBDLLHOOKSTRUCT p = (PKBDLLHOOKSTRUCT)lParam;

        DbgPrint("%x %x %x %x\n", wParam, p->scanCode, p->vkCode, p->flags);
    }

    return CallNextHookEx(0, code, wParam, lParam);
}

void DoCapture(DWORD dwMilliseconds)
{
    if (HHOOK hhk = SetWindowsHookExW(WH_KEYBOARD_LL, LowLevelKeyboardProc, 0, 0))
    {
        ULONG time, endTime = GetTickCount() + dwMilliseconds;

        while ((time = GetTickCount()) < endTime)
        {
            MSG msg;
            switch (MsgWaitForMultipleObjectsEx(0, 0, endTime - time, QS_ALLINPUT, MWMO_INPUTAVAILABLE))
            {
            case WAIT_OBJECT_0:
                while (PeekMessageW(&msg, 0, 0, 0, PM_REMOVE))
                {
                    TranslateMessage(&msg);
                    DispatchMessageW(&msg);
                }
                break;

            case WAIT_FAILED:
                __debugbreak();
                goto __0;
                break;

            case WAIT_TIMEOUT:
                DbgPrint("WAIT_TIMEOUT\n");
                goto __0;
                break;
            }
        }
__0:
        UnhookWindowsHookEx(hhk);
    }
}

也在实际代码中 - 通常不需要使用单独的消息循环编写单独的 DoCapture。如果您的程序在此之前和之后运行消息循环 - 所有这些都可以在公共消息循环中执行,

【讨论】:

  • 感谢您的回答@RbMm。确实,在现实世界的场景中,大多数情况下整个应用程序都存在一个消息循环。我发布问题的方式是为了能够提供一个最小的可重现示例。
  • @GeorgeGkasdrogkas - 是的,在现实世界中(我假设您基于某些事件表单 UI 调用 SetWindowsHookExW)必须是单个公共消息循环。基于 MsgWaitForMultipleObjectsEx 。不需要创建单独的线程 - 这是 100%。和单独的消息循环
  • @GeorgeGkasdrogkas - 许多解决方案都是可能的。例如,您可以创建计时器,在调用 SetWindowsHookExW 并在 WM_TIMER 上调用 UnhookWindowsHookEx 并取消计时器.. 确实存在很多方法
猜你喜欢
  • 2012-05-14
  • 1970-01-01
  • 1970-01-01
  • 2018-03-18
  • 1970-01-01
  • 2013-01-07
  • 2017-06-14
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多