我们开发的应用,在 Windows 平台上偶尔会界面卡死,不再响应任何鼠标事件。这样的现象不太容易出现,操作一天可能会出现一次。
在经过无数次重启应用,复现问题,断点调试,终于发现了一点端倪,并在各种排除法隔离一部分功能试验后,终于找到了罪魁祸首——一个几乎可以忽略的不合理参数。
特地在此记录一下,希望能够在某个时候帮助到某个同路人。
问题背景
首先,说明一下相关的软件背景。
我们使用的是一个第三方封装的 GUI 库,封装了 Windows 的窗口、窗口消息、GDI 等功能。这个库不是 Qt,而是一个相对小众的跨平台桌面应用开发库——wxWidgets。有兴趣的可以研究一下,但是我还是建议使用 Qt,因为 wxWidgets 与系统 GUI 功能绑定太深了,很难脱离 Windows 的那种原汁原味。所以,这里不讨论为什么选用 wxWidgets,这个问题与 wxWidgets 有关,但本质上还是 Windows 系统的特殊性导致的。
我们的应用是一个工具性的程序,有基于 OpenGL 的 3D 模型渲染相关的功能,在局部窗口(子窗口)展示 3D 模型,应用基本上是基于鼠标操作,用键盘配合实现部分功能的快捷操作。
至于这个界面卡死问题,测试人员在测试工作中,偶尔会发现,但是没有明显的复现规律。开发人员开发调试过程中,也会偶尔碰到同样的问题。
初步分析
针对问题做初步的分析,我们有一个结论:那就是,出现问题的时候,Windows 系统不给我们的应用派发鼠标相关的消息了,从而导致了这种假死现象。
怎么得出这个结论的呢?
首先,在问题现场,通过创建并分析转储文件,可以看到应用没有卡死在什么锁上或者陷入死循环。
什么是转储文件?实际上它就是应用进程的一个瞬时状态快照,包括线程栈、堆内存、模块地址等,文件扩展名一般是 .dmp。在 Linux 平台,称为 coredump 文件。在 Windows 平台,通过 CreateMiniDump API 可以为目标进程创建快照。更快的方式,是在任务管理器中,找到目标进程,在右键菜单中有一个“创建转储文件”的功能。
转储文件怎么用?用 Visual Studio直接打开转储文件,然后启动“本机运行”就可以像调试程序一样操作了。
其次,我们通过增加条件断点,发现出现问题时,收不到任何鼠标相关的窗口消息。因为还是能够收到其他类型的消息的,所以通过普通的断点无法判断(干扰太多)有没有收到鼠标相关的窗口消息,此时就需要条件断点这个调试利器了。
机会来临
某一天,突然发现了一种相对稳定的重现路径。在此之前,因为概率太低,基本上遇到一次,也是束手无策。
怎么发现重现路径的呢?因为我在开发一个功能,需要启动后,马上用鼠标去操作界面,测试功能的正确性。在重复的修改调试中,这种卡死问题几乎达到了相对启动次数的60%以上的出现概率。
当然,如果针对性地去复现,有时候概率可能不到20%。这个问题竟然与操作者的心态有关,足见其诡异莫测。
虽然复现概率时高时低,但是基本上满足深入分析的需要了。所以在断断续续的调试中完成手头的功能开发后,我就腾出时间来,针对这个问题做了一个彻底排查分析,这次再不能让它溜走了!
深入分析
一开始,我能够做的还只能是进一步确认复现路径,尝试提高复现的概率及稳定性,看看能不能找到更短的复现路径。
曾经有好几次,在打开我们的模型文档后,只要按一下键盘的 Ctrl 按键,问题就出现了。所以觉得问题可能出在程序对 Ctrl 按键的处理上,就去排查哪里处理的 Ctrl 按键,因为 3D 模块处理了按键,又怀疑 OpenGL 有影响。事后证明这些折腾起到没有什么作用,只是排除了一些嫌疑对象。
后面,我就转到从底层分析了。当然是分析 wxWidgets 处理窗口消息的实现代码,我们并不能分析 Windows 系统的实现,那完全是个黑盒。
所以在继续之前,有必要对 wxWidgets 的消息处理做一个大概的描述。如下图:
下面是一些典型的调用栈:
- 等待新的消息
win32u.dll!NtUserMsgWaitForMultipleObjectsEx() |
user32.dll!RealMsgWaitForMultipleObjectsEx() |
wxMSWEventLoopBase::GetNextMessageTimeout(struct tagMSG *,unsigned long) |
wxMSWEventLoopBase::GetNextMessage(struct tagMSG *) |
wxGUIEventLoop::Dispatch(void) |
wxEventLoopManual::DoRun(void) |
wxEventLoopBase::Run(void) |
wxAppConsoleBase::MainLoop(void) |
- 处理消息
wxWindow::HandleMouseEvent(unsigned int,int,int,unsigned int) |
wxWindow::MSWHandleMessage(__int64 *,unsigned int,unsigned __int64,__int64) |
wxWindow::MSWWindowProc(unsigned int,unsigned __int64,__int64) |
wxWndProc(struct HWND__ *,unsigned int,unsigned __int64,__int64) |
user32.dll!UserCallWinProcCheckWow() |
user32.dll!DispatchMessageWorker() |
user32.dll!IsDialogMessageW() |
wxWindow::MSWProcessMessage(struct tagMSG *) |
wxGUIEventLoop::PreProcessMessage(struct tagMSG *) |
wxGUIEventLoop::ProcessMessage(struct tagMSG *) |
wxGUIEventLoop::Dispatch(void) |
wxEventLoopManual::DoRun(void) |
wxEventLoopBase::Run(void) |
wxAppConsoleBase::MainLoop(void) |
因为我怀疑是处理鼠标消息的某个环节的不正常返回,导致后续系统不分派鼠标消息了,所以我在整个流程的很多提前 return 的地方,加上了条件断点。
在排除一部分 return 点的嫌疑后(这些点命中概率很高,而是命中了也不会出现卡死问题),剩下的部分又从来没有命中过,这下又没有头绪了。
偶然有一次,有一个之前从来没有命中过的断点中断了。说起来,也许是天意,正是这次唯一的命中,让我最终发现了问题的导火索。后来想复现时,这个断点也从来没有命中过。甚至在已经确认原因后,进行针对性的操作,也没有复现。
好在我没有忽略这唯一的机会,查看调用栈,并不是上面的那个正常的 Dispatch 循环。
(这里少一个问题栈)
怎么出现了 DoYieldFor?这个东西之前稍微研究过,那时就觉得它不靠谱,会有问题。因为我只在 wxWidgets 里面见到过这样的机制,像 Qt 等其他 GUI 库并没有这样的功能。
YieldFor 是这样工作的。当我们需要在 UI 线程执行比较长时间的任务,为了展示任务进度,需要不间断的刷新 UI,此时就需要使用 YieldFor 。YieldFor 只处理一类或者几类消息,不处理其他影响程序执行流程的消息,就不会产生与任务相关的类似中断的并发冲突。YieldFor 通常只处理 UI 刷新消息,也可能处理输入事件,这是为了能够让用户取消任务的执行(比如:点击 Cancel 按钮)。
我们在打开模型文档时,使用了 YieldFor 来刷新加载进度,所以卡死问题的复现路径中肯定是包含了对 DoYieldFor 的调用。发现了这个关联性,我就利用反证法,先不执行 YieldFor 了(进度不会刷新),看看还会不会继续出现卡死。果然,在尝试了数十次之后,没有一次复现问题。不由得暗暗窃喜,终于抓到狐狸尾巴了。下面的代码就是罪魁祸首:
wxEventLoopBase::GetActive()->YieldFor(wxEVT_CATEGORY_UI);
仔细研究 DoYieldFor 的实现,它先将消息 Peek (并没有从队列删除)出来考察一下,如果是可以处理的消息,执行一次 Dispatch;否则,从队列删除该消息,然后再放回到(PostThreadMessage)消息队列中,当然放在队尾了。
// put back unprocessed events in the queue
DWORD id = GetCurrentThreadId();
for (size_t i=0; i<m_arrMSG.GetCount(); i++)
{
PostThreadMessage(id, m_arrMSG[i].message,
m_arrMSG[i].wParam, m_arrMSG[i].lParam);
}
m_arrMSG.Clear();
(放回队列)
通过在放回处设置断点检查这里的消息,发现放回的消息基本上都是 MOUSE_MOVE 消息。我不知道这样为何会让 Windows 系统停止分发鼠标消息,可能是消息队列满了,也可能是发现了不正常的鼠标消息。
解决问题
虽然不知道 Windows 系统层面的原因,但是无论如何,我可以对这个问题做出修复了。我并不需要弃用 YieldFor(那样会导致进度不更新),只要将所有 YieldFor 的地方调整一个参数就行了。通过调整参数,增加 wxEVT_CATEGORY_USER_INPUT 这个 flag,让鼠标之类的消息不会被放回消息队列。
wxEventLoopBase::GetActive()->YieldFor(wxEVT_CATEGORY_UI | wxEVT_CATEGORY_USER_INPUT);
经过仔细的验证,可以确认,至少在这个复现路径上,问题已经解决。