IT技术博客大学习 共学习 共进步

SetWindowText 引起的死锁

云风的 BLOG 2026-06-03 09:03:24 累计浏览 5 次
本机暂存

最近发现我在写的小游戏在启动时有很小的概率黑屏。我使用的是 ltask 多线程框架,在黑屏时感觉 ltask 并没有停止工作,似乎只是管理窗口的部分(线程/服务)卡死了。

窗口管理使用的是 sokol_app 做的多平台封装,这只是一个很浅的封装层,但已经够用。我觉得美中不足的是,sokol_app 的 frame 回调函数是放在 WinProc 中,由 Windows 的消息循环被动调度,而不是放在外层的主动 GetMessage 循环中。

即,在 Windows 程序中,线程通常会在最外面写一个这样的 while 循环:

for (;;) {
    while (PeekMessageW(&msg, NULL, 0, 0, PM_REMOVE)) {
        if (msg.message == WM_QUIT)
            return;
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }

    // 在这里,我们可以做一些额外的工作,比如渲染游戏画面、处理游戏逻辑。
}

但我们也可以选择在窗口的 WinProc 中,通过响应 WM_TIMER 等消息的方式来做这些工作:

LRESULT CALLBACK wndproc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
    switch (uMsg) {
        case WM_TIMER :
            // 在这里,可以定时做一些工作。
        break;
    }
    return DefWindowProcW(hWnd, uMsg, wParam, lParam);
}

// 外面的消息处理循环则可以使用 GetMessage 而不是 PeekMessage

while(GetMessage(&msg, NULL, 0, 0) {
    TranslateMessage(&msg);
    DispatchMessage(&msg);
}

无可厚非,后一种方法显得更正规一点:让 Windows 自身调度所有任务,系统如果做的正确,和系统的窗口系统本身契合的更好一点。这个模式是 Window 的历史设计造成的。把窗口系统的工作流程放在用户线程内,用户的程序其它部分配合它,换取交互的流畅度。

但是,一旦采用多线程设计,就变得有点不同了。窗口只是多线程任务的一部分,需要一个更高阶的框架来调度任务,例如 ltask 干的那些。通过在 WinProc 中处理对应消息,在没有消息进入的时候,线程会堵塞在 GetMessage 函数中。这对 ltask 这样的调度器来说非常的不友好。通常一个任务调度器需要的行为是:每个任务要么完成,要么让出,而不是阻塞。Windows 的 GetMessage/DispatchMessage 也是这样的循环,只不过是单线程的。

ltask 处理这样的模块,也不是完全没有办法。这得益于 ltask 的任务都运行在 lua 虚拟机上,和 C 层有一定的隔离。对于 C 代码来说,stack 是绑定在线程上的,所以无法在一个线程运行一半,然后在另一个线程继续工作(因为 stack 不同);但 Lua 的 stack 在 heap 上,迁移完全没有问题。

我曾经做过类似的尝试 ,但最终又从 ltask 主干上撤销了这个特性。倒不是实现的不对,而是配合它使用的 C 代码如果重入问题解决不好,隐藏的 bug 很难发现。这需要 C 部分最好在设计时就考虑过并行/重入问题。sokol 显然不是这样设计的。


为了让 sokol 可以在 ltask 下工作,我做了不少工作。sokol_gfx 的图形 api 部分倒是简单,我只需要保证在同一个服务中调用就可以了;比较麻烦的是 sokol_app 中处理窗口的部分。直接让 frame 回调函数运行在 ltask 的一个服务中非常困难。原因上面已述:这个回调函数结束后线程会挂起在 Windows 的消息处理循环中,而没有将控制权归还 ltask 。虽然可以通过 ltask 那个实验特性解决这个问题,但 sokol 并没有为多线程设计,很可能隐藏多线程 bug ,一旦出现难以调试。

我试过几个方案后,最终采用了最简单粗暴的方法:利用锁来同步任务。也就是在 frame callback 开始时抛出一个消息,并阻塞在一个锁上。这个消息会开启另一个 ltask 掌握的线程中对应的 render 服务;而在 render 服务渲染完当前帧,解开这个锁,frame callback 就会顺利返回。

在绝大多数场景中,这个方案工作的很好。但我最近偶尔发现在启动程序时,会有很小的概率,锁并没有解开。

一开始我并不为意,觉得或许是一些同步代码没有写好,因为有更想做的特性要开发,这种偶发死锁 bug 出现概率很低,且只出现在启动阶段,想着有空稍微复查一下启动代码就能解决。

这两天感觉的确“有空”了,花了一晚上,终于定位了问题。

问题出在游戏启动阶段改变窗口的标题上。固然,可以在窗口创建时就把标题设置好。但标题需要根据多语言环境设置不同的文本,处理多语言文本的这块逻辑不算简单,我不想放在启动的最初阶段(创建窗口之前),所以窗口创建时使用了一段默认文本,之后才修改它。

sokol_app 的 api 只是间接调用了 SetWindowTextW() 。显然不是 sokol_app 的封装问题。我查阅了 msdn ,发现 SetWindowTextW 只是给 WinProc 发送了一个 WM_SETTEXT 消息。也就是说,等价于调用 SendMessageW()

如果在 WinProc 所在线程中调用它当然没有问题,只是引起了 WinProc 重入:调用方在 frame callback 内,而 frame callback 处于 WinProc 的 WM_TIMER 的消息处理环节。这时调用 SetWindowTextW 等于递归再运行一次 WinProc 本身,但消息变成了 WM_SETTEXT ,新的调用返回后窗口的标题栏就被改变了。

可是,我现在在另外一个线程调用 SetWindowTextW 行为有所不同。这时 WM_SETTEXT 被投递到窗口消息处理线程,它需要排队等待 WinProc 再次被处理,也就是外层循环的下一次 DispatchMessage 调用。但是,这个时候当下的 DispatchMessage 还阻塞在 frame callback 的锁上面无法返回。这就是死锁产生的原因:

  1. DispatchMessage 调用 WinProc 处理 WM_TIMER 消息,它调用了 sokol 的 frame callback 。我的程序在 frame callback 中发出消息唤醒真正的处理流程,并等待在锁上。
  2. 真正的处理流程运行在另外线程,它调用了 SetWindowTextW ,其通过 SendMessageW 投递 WM_SETTEXT 到窗口线程的消息队列,等待返回。
  3. 窗口线程需要等当前的 WM_TIMER 处理完毕才 DispatchMessage 才可以结束,后续的 GetMessage 才可以拿到 WM_SETTEXT 消息处理它。

了解了死锁的原因后,最直接的解决方案是在窗口线程调用 SetWindowTextW 。因为这样会直接运行设置文本的逻辑,消息不需要进入消息队列,当然就没有锁的问题。但这个方案不适合现在的 ltask 框架。目前窗口线程不在 ltask 的管辖之下,也就无法在 lua 服务中调用 SetWindowTextW ,也无法直接通过 ltask 内部的消息把这个任务传递过去。

比如容易想到的是:“改变窗口标题”这个行为并不需要等待结果。那么是不是可以改用 PostWindowTextW 发送 WM_SETTEXT 就可以不阻塞调用方了呢?

答案是不行,原因在这里有解释 。因为这条消息发送了一个字符串,这里存在这个字符串生命期管理的问题,为了减少使用错误,Windows 禁止用 PostMessage 发送这样有生命期管理问题的系统消息。只有 SendMessage 可以在结果返回后正确释放消息文本所占用的内存。

所以,我们可以用独立线程通过 SendMessage 投递这个消息,并等待其返回后做完后续(生命期管理)工作。在 C 中创建新线程非常麻烦,但在 ltask 中却非常容易。只需要用一个独立的服务调用 SetWindowTextW 就够了。frame 的处理流程所在的服务/线程向它投递一个 ltask 消息,通知这个独立服务改变窗口标题,就不会阻塞 frame 流程。

建议继续学习

  1. 一种常见的并发编程场景的处理 (累计阅读 23,520)
  2. 大并发下的高性能编程 – 改进的(用户态)自旋锁 (累计阅读 8,963)
  3. 多线程队列的算法优化 (累计阅读 7,661)
  4. 程序中的“多线程” (累计阅读 7,220)
  5. 从load data引发的死锁说起 (累计阅读 6,080)
  6. php多线程扩展 (累计阅读 5,401)
  7. 多核与异步并行 (累计阅读 5,101)
  8. web开发框架的选择(bottle or flask)及为autumn增加多线程支持 (累计阅读 5,001)
  9. Ameba , 一个简单的 lua 多线程实现 (累计阅读 4,920)
  10. 并行编程中的“锁”难题 (累计阅读 4,763)