「无响应」的真正含义 ── Windows 如何判定应用已挂起,以及如何设计不挂起的应用

· · Windows, Windows 开发, 故障排查, 多线程, WinForms, WPF, Win32 API, UI 设计

「操作到一半应用变白并显示(无响应)。」「我们接到偶尔挂起的工单,但开发机上从未复现。」——对 Windows 业务应用,这种「无响应」是最常见的投诉之一。而出人意料地鲜为人知的是,贴上「无响应」显示的不是挂起的应用本身——是操作系统

Windows 如何知道应用「挂了」?那扇结霜发白的窗口是什么?面向在 Windows 上编写业务应用的开发者,以及接手挂起应用工单的 IT 人员,本文从消息循环的基础出发梳理「无响应」判定,并根据一手资料整理挂起的经典原因、不挂起的设计,以及调查挂起瞬间的步骤。

1. 先说结论

  • 「无响应」是操作系统的判定。当窗口(以及拥有它的 GUI 线程)不在等待输入、不在启动序列中,且已 5 秒未取出消息(PeekMessage)时,操作系统将其视为无响应。判定不是按进程。1
  • 发白的窗口是「幽灵窗口」。操作系统隐藏了原窗口,换上位置、大小和外观相同的赝品。你能做的只有移动、最小化或关闭;内容并没有在运行。附加了调试器时不会创建幽灵窗口。2
  • 挂起的原因几乎总归到一件事。本应泵送消息循环的 UI 线程被阻塞在重活或等待上。同步 I/O、网络调用、锁等待以及跨线程的 SendMessage 是经典。3
  • 设计原则是「不要在 UI 线程上等待或计算」。把重活移到工作线程(C# 中是 async/await + Task.Run),让 UI 线程专司绘制、进度和接受取消。4
  • DoEvents 和手动泵送消息循环是重入缺陷的温床。「无响应」显示消失了,但结构现在允许任意事件在工作中途打断。分离,而不是逃避,才是正确做法。
  • 调查从捕获挂起瞬间的状态开始。取转储并看 UI 线程的堆栈,几乎总能识别它在等什么。

2. 前提:Windows 应用由消息驱动

要理解「无响应」,首先要纳入:Windows GUI 应用是事件驱动的。GUI 应用不会自己去取输入;它接收操作系统投递的消息(鼠标、键盘、重绘请求、定时器等)并据此行动。3

每个创建窗口的线程都有一个消息队列,并运行这样的消息循环

MSG msg;
while (GetMessage(&msg, NULL, 0, 0) > 0) {
    TranslateMessage(&msg);
    DispatchMessage(&msg);   // the window procedure is called
}

GetMessage 从队列取出一条消息,DispatchMessage 调用该窗口的窗口过程(消息处理函数)。按钮点击处理、重绘,以及 WinForms 或 WPF 事件处理程序,归根到底都在这个循环的一次迭代里运行。5

消息循环的基本结构操作系统把鼠标、键盘和其他输入放入线程的消息队列;UI 线程的循环用 GetMessage 取出,用 DispatchMessage 调用窗口过程,处理结束后回到循环顶部OS(输入、重绘请求、定时器)线程消息队列用 GetMessage 取出DispatchMessage在窗口过程中处理

图 1: GUI 应用的心脏是消息循环;每个事件处理程序都作为这个循环的一次迭代运行。

这一结构有一个重要后果。若在窗口过程(事件处理程序)里做耗时工作,循环在此期间无法取出下一条消息。既不能响应点击,也不能响应重绘请求——这才是「挂起」的真正含义。

还值得纳入:消息由两条路径投递。PostMessage 把消息放到队列上并立即返回,循环按顺序取出并处理消息。SendMessage直接调用窗口过程,直到处理完成才返回调用方36 这一差异直接进入第 4 章的死锁讨论。

两条消息投递路径PostMessage 把消息放到队列上并立即返回;消息循环按顺序取出并处理。SendMessage 直接调用窗口过程,直到处理完成才返回调用方PostMessage放到队列上(立即返回)循环按顺序取出并处理SendMessage直接调用过程直到处理完成才返回

图 2: 尽管两者都「发送消息」,入队的 Post 与等待完成的 Send 性质完全不同。

3. 「无响应」如何判定 ── 5 秒规则与幽灵窗口

那么操作系统如何知道「这个应用挂了」?标准有官方文档。当窗口不在等待输入、不在启动序列中,且已 5 秒未调用 PeekMessage(取出消息)时,操作系统将其视为无响应。1 换句话说,操作系统像把脉一样观察「消息循环是否真的在转」,若 5 秒没有脉搏就判定窗口无响应(文档写明这个 5 秒值将来可能改变)。判定单位是窗口以及拥有它的 GUI 线程;在有几条 UI 线程的应用中,一条线程挂起并不意味着另一条线程上的窗口死了。转储里要看的线程是挂起窗口的所有者。

对被判定的顶层窗口会发生什么,也有文档。操作系统隐藏原窗口,换成具有相同 Z 序、位置、大小和外观的「幽灵窗口」。用户能对它做的只有移动、调整大小或(强制)关闭。里面的应用实际上没有在响应,因此其他操作都不工作。2

挂起窗口判定与幽灵窗口替换当 UI 线程被阻塞在重活上、消息取出停止 5 秒时,操作系统判定窗口无响应,隐藏原窗口,换上外观相同的幽灵窗口,只向用户提供移动、最小化和关闭UI 线程被阻塞在重活上消息取出停止过了 5 秒?换上幽灵窗口标题显示(无响应)结霜发白;只能移动和关闭

图 3: 「无响应」文字和白屏都属于操作系统换上的幽灵窗口,不属于挂起的应用。

出现在标题栏上的「(无响应)」字符串,以及 Aero 主题下结霜发白的外观,都属于这个幽灵窗口。由此得出两条实务后果。

  • 到「无响应」显示出来时,拥有该窗口的线程至少已 5 秒没有处理消息。不是「显示来得太早」——UI 线程确实被阻塞了。
  • 附加了调试器时不会创建幽灵窗口。2 当看起来「调试器下从不无响应,但发行版会」时,挂起本身可以相同,只是显示不同。

还有一个 API DisableProcessWindowsGhosting,可为整个进程禁用这种替换。7 它面向自助终端等不希望操作系统自行让窗口看起来可操作的特殊情况。调用它会停止「无响应」显示出现,但应用已挂起这一事实并不改变。要理解这不是一般应用用作「无响应」对策的东西。

4. 应用为何挂起 ── 阻塞 UI 线程的经典模式

原因归结起来是一点——「UI 线程回不到消息循环」——但实务中遇到的形态分成几类经典。

阻塞 UI 线程的经典原因分类同步 I/O 与网络调用、锁等待、跨线程 SendMessage,以及 COM STA 牵连这四族经典,都归结到同一点:UI 线程无法回到消息循环哪一类经典原因?I/O 还是锁?SendMessage 还是 COM?同步 I/O 和网络锁等待SendMessage跨线程COM STA 牵连UI 无法返回无响应检查

图 4: 可见症状相同,但阻塞线程的元凶分成四族,对策各不相同。

同步 I/O 和网络调用。这是最常见的。在按钮点击处理程序里同步做大文件读写、数据库查询、Web API 调用,或访问网络驱动器上的文件。开发机上几分之一秒就完成,所以注意不到;生产环境的网络延迟或文件服务器打嗝会变成几十秒的等待,于是接到「偶尔挂起」的工单。网络驱动器在连接断开时超时很长,会把症状急剧放大。

锁等待。UI 线程试图获取与工作线程共享的数据上的锁,结果等待持有该锁很久的工作线程。锁纪律在多线程实务系列中有详细说明。

跨线程的 SendMessageSendMessage 直到目标窗口的过程处理完毕才返回6 当你把它发到另一条线程上的窗口时,发送方会被等到那条线程处于能处理消息的状态。若目标线程本身在等什么,就形成双方互相等待的消息死锁3 特别是发到 HWND_BROADCAST,只要有一个窗口无响应就会把你拖进去。等不起的时候,考虑 SendMessageTimeout 或不等待回复的 PostMessage8

跨线程 SendMessage 造成的死锁若工作线程向 UI 线程窗口发送 SendMessage,而 UI 线程正阻塞等待工作线程的结果,双方都等对方完成,于是死锁工作线程UI 线程工作线程UI 线程无法处理消息(已阻塞)无法从 SendMessage 返回互相等待——死锁等待工作线程完成(已阻塞)SendMessage(处理完才返回)

图 5: 「UI 线程等工作者,工作者经 SendMessage 等 UI 线程」是经典死锁。

COM 套间牵连。对 STA 对象的调用作为窗口消息投递,因此当 UI 线程(STA)被阻塞时,来自其他线程的 COM 调用也会被连带阻塞。该结构在 COM STA/MTA 文章中说明。

「只是一瞬间」的堆积。即便是 50 ms 的同步调用,在循环里调用 100 次也是 5 秒。无响应阈值是 5 秒,但感知上的「迟钝」从大约 100 ms 开始。设计经验法则是「UI 线程只可被阻塞毫秒级」。

5. 不挂起的设计 ── 把重活移出 UI 线程

设计原则只有一条:把耗时工作移出 UI 线程。在 C#(WinForms/WPF)中,async/await 是最短的正确路径。

private async void RunButton_Click(object sender, EventArgs e)
{
    runButton.Enabled = false;
    try
    {
        // CPU-heavy work, or APIs that are only synchronous, go to a worker via Task.Run
        var result = await Task.Run(() => HeavyCalculation(input));

        // For I/O, use APIs that are natively async (they do not consume a thread either)
        var data = await httpClient.GetStringAsync(url);

        // After await you are back on the UI thread, so you can touch controls directly
        resultLabel.Text = result + data.Length.ToString();
    }
    catch (Exception ex)
    {
        // An exception leaking from an async void handler will take the app down. Catch it here
        MessageBox.Show($"The operation failed: {ex.Message}");
    }
    finally
    {
        runButton.Enabled = true;
    }
}

有三点。第一,await 在等待时,UI 线程已回到消息循环,因此不会变成无响应。第二,await 之后的延续回到 UI 线程,因此之后可以正常触碰控件(从工作线程直接触碰控件是禁止的;若需要,使用 Control.Invoke / Dispatcher.InvokeAsync)。4 第三,工作运行期间禁用按钮,并以其他方式从设计上消灭重入

原生 Win32 的图景相同:把工作交给工作线程,经 PostMessage 以自定义消息通知 UI 线程完成,并在窗口过程中更新 UI。PostMessage 只把消息放到队列上并立即返回,因此工作者一侧也不会被阻塞。6 对工作线程本身的等待,按条件变量文章中的纪律来写。

不挂起应用中的角色分工UI 线程只负责接受输入、显示进度和接受取消;工作线程运行重活,经 PostMessage 或 await 延续把完成返回给 UI 线程交出工作PostMessage / await 延续UI 线程:输入、进度、取消工作线程:重活UI 线程上没有同步 I/O 或长时间计算

图 6: 把 UI 线程当作「接待台」,始终把重活交给工作者,只收下完成通知。

要避免的是在重活块之间插入 Application.DoEvents()PeekMessage 循环,只为让显示活着。你躲开了无响应,但任意事件处理程序会在工作中途重入。按钮的第二次点击、处理期间关闭窗体、定时器触发——任何一种都可能破坏仍在处理中的数据,缺陷依赖时机、难以复现。把手动泵送消息循环留在模态进度对话框这类有限结构里,原则上用分离来解决。

DoEvents 造成的重入缺陷时间线在重活中间调用 DoEvents 会让已入队点击的事件处理程序打断并运行、改写仍在处理中的数据,然后恢复原来的工作,产生依赖时机的数据损坏UI 线程UI 线程按钮再点处理程序打断重活开始(数据正在处理)DoEvents(处理已入队消息)打断的工作改写数据原来的工作恢复(数据已不一致)

图 7: DoEvents 抹掉「无响应」,代价是把任意事件请进工作中间。

对长时间运行的工作,设计里也要包含进度显示和取消。用 IProgress<T> 把进度发给 UI,用 CancellationToken 传达中断,用户就能看到「正在工作」,不会去强制终止(那往往是数据损坏的原因)。

长时间工作的进度与取消流程工作线程经 IProgress 向 UI 线程发送进度;UI 上的取消动作经 CancellationToken 到达工作者;工作者在方便的边界停下并清理经 IProgress 的进度CancellationToken工作者:长时间工作UI:进度和停止按钮在边界停下并清理

图 8: 进度是「工作者 → UI」;取消是「UI → 工作者」。从一开始就把这条细的双向通道写进设计。

6. 调查挂起瞬间

在「偶尔挂起」的调查中,最有价值的是恰好挂起那一刻的线程状态。重启,证据就没了。

取转储。在任务管理器的详细信息选项卡上,右键目标进程 →「创建转储文件」。仅此就能得到带每条线程堆栈的完整转储。只要告诉接手工单的 IT 人员「挂起时先取这个再关」,调查成功率就会大变。关于建立采集机制,见崩溃转储采集文章

看 UI 线程的堆栈。在 WinDbg 中打开转储,看正在泵送消息循环的线程(通常是线程 0)的堆栈。同步 I/O 表现为 ReadFile 或网络 API,锁等待表现为 WaitFor… 族调用,跨线程的 SendMessage 表现为在 SendMessage 内部等待——原样如此。如何读,见 WinDbg 入门文章

现场看。用 Process Explorer 可以当场检查线程列表和堆栈。当持续迟钝时,采集 WPR 跟踪并随时间分析 UI 线程的等待(WPR/WPA 实战)。

调查无响应的基本步骤在挂起瞬间取转储,看 UI 线程的堆栈,识别是停在同步 I/O、锁等待还是跨线程 SendMessage,并连接到对应的设计修复挂起瞬间取转储(关闭之前)看 UI 线程的堆栈同步 I/O 或网络等待锁等待跨线程 SendMessage把该处分离到工作者

图 9: 调查的主角是「挂起瞬间的转储」;UI 线程的堆栈本身就是原因分类。

也可以把如何从症状做第一刀标准化。若总在特定操作上挂起,先怀疑该处理程序里的同步 I/O。若很少挂起且与操作无关,怀疑锁顺序或跨线程的 SendMessage 死锁,并在转储中对照两条线程的等待目标。若只在特定环境挂起,怀疑网络驱动器、代理或杀毒软件等环境因素造成的超时。

7. 小结

  • 「无响应」是操作系统判定应用已 5 秒未取出消息并换上幽灵窗口的机制。贴上显示的是操作系统,不是应用。
  • 挂起的原因是一点:「UI 线程无法回到消息循环」。同步 I/O、网络、锁等待和跨线程 SendMessage 是经典。
  • 对策是把重活移出 UI 线程。在 C# 中是 async/await + Task.Run;在 Win32 中是工作线程 + PostMessage。用禁用按钮等方式从设计上防止执行期间的重入。
  • DoEvents 逃避,换来的是重入缺陷。DisableProcessWindowsGhosting 只去掉显示。两者都不是根因修复。
  • 对调查,「挂起瞬间」的转储最重要。原因几乎总是原样写在 UI 线程的堆栈上。

从用户角度看「无响应」是「坏了」,但一旦知道机制,就能把它翻译成精确的句子:「UI 线程 5 秒没有回来」。从那一句话往回推,候选原因、修复和调查步骤都会自然落出。

相关文章

相关咨询领域

小村软件有限公司承接「偶尔挂起」或变成「无响应」的业务应用的根因调查(转储分析与跟踪分析)、把充满同步工作的遗留 UI 代码重构为 async/await 与工作线程分离,以及对不冻结的 UI 设计的评审。即便还没有复现步骤,也可以从如何采集证据的设计开始协助。

参考链接

  1. Microsoft Learn, IsHungAppWindow function (winuser.h). 关于判定标准:当应用「不在等待输入、不在启动序列中,且已超过内部超时 5 秒未调用 PeekMessage」时被视为无响应;关于这一 5 秒标准可能改变;以及关于对幽灵窗口该函数总是返回 TRUE。  2

  2. Microsoft Learn, GetMessage function (winuser.h). 关于系统在顶层窗口数秒不响应消息时将其视为无响应,并换成具有相同 Z 序、位置、大小和外观的幽灵窗口;关于用户只能移动、调整大小或关闭它;以及关于附加调试器时不创建幽灵窗口。  2 3

  3. Microsoft Learn, About Messages and Message Queues. 关于 Windows 应用是事件驱动的、窗口过程处理消息;关于入队消息与直接发送消息的区别;关于把无响应窗口换成幽灵窗口;以及关于线程互相发送消息造成死锁的章节。  2 3 4

  4. Microsoft Learn, How to make thread-safe calls to controls (Windows Forms). 关于 WinForms 控件从创建它们的线程以外的任何线程触碰都不安全;关于用 Invoke/BeginInvoke 做来自另一线程的更新;以及关于使用 async/await 或 BackgroundWorker 的安全异步模式。  2

  5. Microsoft Learn, Using Messages and Message Queues. 关于用 GetMessage、TranslateMessage 和 DispatchMessage 实现的典型消息循环,以及如何检查消息队列。 

  6. Microsoft Learn, SendMessage function (winuser.h). 关于 SendMessage 调用指定窗口的窗口过程,直到处理完成才返回;关于向另一条线程上的窗口发送会使发送方等到该线程处理消息;以及关于与不等待回复、把消息放到队列上的 PostMessage 的差异。  2 3

  7. Microsoft Learn, DisableProcessWindowsGhosting function (winuser.h). 关于能为调用方 GUI 进程禁用使无响应窗口可最小化、可移动、可关闭的幽灵窗口功能;以及关于禁用持续到进程生命周期结束。 

  8. Microsoft Learn, SendMessageTimeout function (winuser.h). 关于能带超时发送消息;以及关于窗口无响应(已被判定挂起)时不等待就返回的标志(SMTO_ABORTIFHUNG)。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

「无响应」在什么条件下出现?
当有窗口的应用不在等待输入、不在启动序列中,且已 5 秒未取出消息(PeekMessage)时,操作系统判定该窗口挂起。被挂起的顶层窗口被隐藏,换成位置、大小和外观相同的「幽灵窗口」。「(无响应)」标题栏文字和结霜发白的外观属于这个幽灵窗口,它只允许移动、最小化或关闭。换句话说,「无响应」不是应用自己显示的——是操作系统替应用贴上的画面。
有没有在工作进行期间不让「无响应」出现的设置?
调用 DisableProcessWindowsGhosting 会对该进程禁用换成幽灵窗口。不过那只是让挂起对用户不那么显眼——窗口仍不响应输入,从用户角度看是完全冻结、无法移动或关闭。真正的修复不是压制显示,而是把重活移到工作线程,使 UI 线程哪怕十分之一秒、更不用说五秒都不会被阻塞。还要注意,附加了调试器时操作系统不会创建幽灵窗口,因此调试期间可能看起来「无响应」从未发生。
用 DoEvents(手动泵送消息循环)来避开「无响应」可以接受吗?
不推荐。在重活中间转动 DoEvents 或 PeekMessage 循环会躲开挂起窗口判定,但任意事件处理程序都可以重入——按钮的第二次点击、关闭窗口、定时器等。另一个处理程序改写仍在处理中的数据,或触碰本应已关闭的窗体并抛出,会产生依赖时机、难以复现的重入缺陷——比「无响应」本身更糟。正确做法是用 Task.Run 等把工作本身移到工作线程,让 UI 线程只负责进度显示和接受取消。
如何从工作线程更新 UI(控件)?
WinForms 控件和 WPF 元素只能从创建它们的线程(通常是 UI 线程)触碰。从工作线程直接触碰会导致异常或未定义行为。在 C# 中,async/await 是最容易的路径:await 之后的延续会回到调用方的 UI 线程,因此可以在 await 之后正常更新控件。要显式切换时,WinForms 用 Control.Invoke/BeginInvoke,WPF 用 Dispatcher.InvokeAsync。在原生 Win32 中,成熟模式是工作线程用 PostMessage 向 UI 线程投递自定义完成消息,由窗口过程更新 UI。
如何调查应用为何显示「无响应」?
重要的是在挂起的「那一刻本身」捕获状态。先从任务管理器的详细信息选项卡用「创建转储文件」取完整转储,然后在 WinDbg 中看 UI 线程(运行消息循环的线程)的堆栈。是卡在同步 I/O、网络等待、锁等待,还是经 SendMessage 等待另一条线程,都会原样出现在堆栈上。要看活动进程,Process Explorer 的线程列表和堆栈视图有用;要随时间跟踪,采集 WPR 跟踪有效。也可参见本站的 WinDbg 入门文章、Process Explorer 实战,以及 WPR/WPA 实战。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表