阻止 Windows 应用程序中的挂起

受影响的平台

客户端 - Windows 7
服务器 - Windows Server 2008 R2

说明

挂起 - 用户透视

用户喜欢响应式应用程序。 单击菜单时,他们希望应用程序能够立即做出反应,即使应用程序当前正在打印其工作。 当他们在自己喜欢的字处理器中保存较长的文档时,他们希望在磁盘仍在旋转时继续键入。 当应用程序不及时响应其输入时,用户会很快变得不耐烦。

程序员可能认识到应用程序无法立即响应用户输入的许多合理原因。 应用程序可能忙于重新计算某些数据,或者只是在等待其磁盘 I/O 完成。 但是,从用户调查中,我们知道用户在几秒钟的无响应后会感到恼火和沮丧。 5 秒后,他们将尝试终止挂起的应用程序。 除了崩溃,应用程序挂起是使用 Win32 应用程序时用户中断的最常见原因。

应用程序挂起存在许多不同的根本原因,并非所有原因都显示在无响应 UI 中。 但是,无响应 UI 是最常见的挂起体验之一,此方案目前在检测和恢复方面都获得了最多的操作系统支持。 Windows 会自动检测、收集调试信息,并可以选择终止或重启挂起的应用程序。 否则,用户可能必须重新启动计算机才能恢复挂起的应用程序。

挂起 - 操作系统透视图

当应用程序 (或更准确地时,线程) 在桌面上创建一个窗口,它会与桌面窗口管理器签订隐式协定, (DWM) 及时处理窗口消息。 DWM 将消息 (键盘/鼠标输入和来自其他窗口的消息以及自身) 发布到特定于线程的消息队列中。 线程通过其消息队列检索和调度这些消息。 如果线程不通过调用 GetMessage () 为队列提供服务,则不会处理消息,并且窗口会挂起:它既不能重绘,也不能接受用户的输入。 操作系统通过将计时器附加到消息队列中的挂起消息来检测此状态。 如果 5 秒内尚未检索到消息,DWM 将声明窗口挂起。 可以通过 IsHungAppWindow () API 查询此特定窗口状态。

检测只是第一步。 此时,用户甚至无法终止应用程序 - 单击“X (关闭) ”按钮将导致WM_CLOSE消息,该消息将像任何其他消息一样卡在消息队列中。 桌面窗口管理器可以协助无缝隐藏,然后将挂起的窗口替换为显示原始窗口上一个工作区 (位图的“幽灵”副本,并将“未响应”添加到标题栏) 。 只要原始窗口的线程不检索消息,DWM 会同时管理这两个窗口,但允许用户仅与虚影副本交互。 使用此虚影窗口,用户只能移动、最小化和(最重要的是)关闭无响应的应用程序,但不能更改其内部状态。

整个幽灵体验如下所示:

显示“记事本未响应”对话框的屏幕截图。

桌面窗口管理器执行最后一项操作:它与 Windows 错误报告 集成,使用户不仅可以关闭应用程序,还可以选择重启应用程序,还可以将有价值的调试数据发送回 Microsoft。 可以通过在 Winqual 网站上注册来获取自己的应用程序的此挂起数据。

Windows 7 为此体验添加了一项新功能。 操作系统分析挂起的应用程序,在某些情况下,为用户提供取消阻止操作并使应用程序再次响应的选项。 当前实现支持取消阻止套接字调用;将来的版本中,用户可取消更多操作。

若要将应用程序与挂起恢复体验集成并充分利用可用数据,请执行以下步骤:

  • 确保应用程序注册重启和恢复,使用户尽可能轻松挂起。 正确注册的应用程序可以自动重启,大部分未保存的数据保持不变。 这适用于应用程序挂起和崩溃。
  • 从 Winqual 网站获取挂起和崩溃应用程序的频率信息以及调试数据。 即使在 Beta 版期间,也可以使用此信息来改进代码。 有关简要概述,请参阅“Windows 错误报告简介”。
  • 可以通过调用 DisableProcessWindowsGhosting () 来禁用应用程序中的重影功能。 但是,这可以防止普通用户关闭和重启挂起的应用程序,并且通常以重新启动结束。

挂起 - 开发人员视角

操作系统将应用程序挂起定义为至少 5 秒未处理消息的 UI 线程。 明显的 bug 会导致某些挂起,例如,一个线程等待一个从未发出信号的事件,以及两个线程,每个线程持有一个锁并尝试获取其他线程。 无需花费太多精力即可修复这些 bug。 然而,许多挂起并不那么清晰。 是的,UI 线程不会检索消息 ,但它同样忙于执行其他“重要”工作,最终将返回处理消息。

但是,用户将此视为 bug。 设计应符合用户的期望。 如果应用程序的设计导致应用程序无响应,则必须更改设计。 最后,这一点很重要,无法像代码 bug 一样修复无响应;它需要在设计阶段进行前期工作。 尝试改造应用程序的现有代码库以使 UI 更具响应性,通常成本太高。 以下设计指南可能会有所帮助。

  • 将 UI 响应能力作为顶级要求;用户应始终感觉自己可以控制应用程序
  • 确保用户可以取消需要超过一秒才能完成的操作和/或可以在后台完成的操作;如有必要,请提供适当的进度 UI

显示“复制项目”对话框的屏幕截图。

  • 将长时间运行或阻止的操作作为后台任务排队 (这需要一种经过深思熟虑的消息传送机制,以在工作完成时通知 UI 线程)
  • 使 UI 线程的代码保持简单;删除尽可能多的阻止 API 调用
  • 仅当窗口和对话框准备就绪且完全可操作时才显示它们。 如果对话框需要显示资源过于密集而无法计算的信息,请首先显示一些常规信息,并在更多数据可用时动态更新它。 一个很好的示例是 Windows 资源管理器中的文件夹属性对话框。 它需要显示文件夹的总大小,以及无法从文件系统获取的信息。 对话框立即显示,“大小”字段将从工作线程更新:

显示 Windows 属性的“常规”页的屏幕截图,其中“大小”、“磁盘上的大小”和“Contains”文本圈了圆圈。

遗憾的是,没有简单的方法来设计和编写响应式应用程序。 Windows 不提供简单的异步框架,以便轻松计划阻止或长时间运行的操作。 以下部分介绍防止挂起的一些最佳做法,并重点介绍一些常见缺陷。

最佳实践

保持 UI 线程简单

UI 线程的主要责任是检索和调度消息。 任何其他类型的工作都会带来挂起此线程拥有的窗口的风险。

应做事项:

  • 将导致长时间运行操作的资源密集型算法或无限制算法移动到工作线程
  • 识别尽可能多的阻塞函数调用,并尝试将它们移动到工作线程;调用其他 DLL 的任何函数都应可疑
  • 请额外努力从工作线程中删除所有文件 I/O 和网络 API 调用。 如果不是分钟,这些函数可能会阻塞数秒。 如果需要在 UI 线程中执行任何类型的 I/O,请考虑使用异步 I/O
  • 请注意,UI 线程还会为进程托管的所有单线程单元 (STA) COM 服务器提供服务;如果进行阻止调用,这些 COM 服务器将无响应,直到再次为消息队列提供服务

请勿:

  • 等待任何内核对象 ((如事件或互斥体) )超过很短的时间;如果必须等待,请考虑使用 MsgWaitForMultipleObjects () ,这将在新消息到达时取消阻止
  • 使用 AttachThreadInput () 函数与其他线程共享线程的窗口消息队列。 它不仅非常难以正确同步对队列的访问,而且还会阻止 Windows 操作系统正确检测挂起的窗口
  • 在任何工作线程上使用 TerminateThread () 。 以这种方式终止线程将不允许它释放锁或发出信号事件,并且很容易导致孤立的同步对象
  • 从 UI 线程调用任何“未知”代码。 如果应用程序具有扩展性模型,则尤其如此;无法保证第三方代码遵循响应指南
  • 进行任何类型的阻止广播呼叫;SendMessage (HWND_BROADCAST) 让你任由当前运行的每个不写应用程序摆布

实现异步模式

从 UI 线程中删除长时间运行或阻止的操作需要实现一个异步框架,该框架允许将这些操作卸载到工作线程。

应做事项:

  • 在 UI 线程中使用异步窗口消息 API,特别是将 SendMessage 替换为其非阻止对等之一:PostMessage、SendNotifyMessage 或 SendMessageCallback
  • 使用后台线程执行长时间运行或阻塞的任务。 使用新的线程池 API 实现工作线程
  • 为长时间运行的后台任务提供取消支持。 对于阻止 I/O 操作,请使用 I/O 取消,但只能作为最后的手段;取消“正确”操作并不容易
  • 使用 IAsyncResult 模式或使用事件实现托管代码的异步设计

明智地使用锁

应用程序或 DLL 需要锁来同步对其内部数据结构的访问。 使用多个锁可提高并行度,并提高应用程序的响应能力。 但是,使用多个锁也会增加以不同顺序获取这些锁并导致线程死锁的可能性。 如果两个线程各持有一个锁,然后尝试获取另一个线程的锁,则它们的操作将形成一个循环等待,阻止这些线程的任何向前进度。 只能通过确保应用程序中的所有线程始终以相同的顺序获取所有锁来避免此死锁。 但是,按“正确”顺序获取锁并不总是容易的。 可以组合软件组件,但无法获取锁。 如果代码调用其他组件,该组件的锁现在将成为隐式锁顺序的一部分 - 即使你无法查看这些锁。

由于锁定操作比关键节、互斥和其他传统锁的常规函数要多得多,因此情况变得更加困难。 任何跨越线程边界的阻塞调用都具有可能导致死锁的同步属性。 调用线程执行具有“获取”语义的操作,在目标线程“释放”该调用之前无法解除阻止。 许多 User32 函数 (例如 SendMessage) ,以及许多阻止 COM 调用都属于此类别。

更糟的是,操作系统具有自己的内部特定于进程的锁,该锁有时会在执行代码时保留。 此锁是在 DLL 加载到进程中时获取的,因此称为“加载程序锁”。 DllMain 函数始终在加载程序锁下执行;如果在 DllMain (中获取任何锁,并且不应) ,则需要使加载程序锁成为锁定顺序的一部分。 调用某些 Win32 API 也可能代表你获取加载程序锁 - LoadLibraryEx、GetModuleHandle 等函数,尤其是 CoCreateInstance。

若要将所有这一切联系在一起,请查看下面的示例代码。 此函数获取多个同步对象并隐式定义锁定顺序,这在粗略检查中不一定很明显。 在函数条目上,代码获取关键节,在函数退出之前不会释放它,从而使它成为锁层次结构中的顶层节点。 然后,代码调用 Win32 函数 LoadIcon () ,该函数在封面下可能会调用操作系统加载程序来加载此二进制文件。 此操作将获取加载程序锁,该锁现在也将成为此锁层次结构的一部分, (确保 DllMain 函数不会) 获取g_cs锁。 接下来,代码调用 SendMessage () ,这是一个阻止的跨线程操作,除非 UI 线程响应,否则不会返回 。 同样,请确保 UI 线程永远不会获取g_cs。

bool foo::bar (char* buffer)  
{  
      EnterCriticalSection(&g_cs);  
      // Get 'new data' icon  
      this.m_Icon = LoadIcon(hInst, MAKEINTRESOURCE(5));  
      // Let UI thread know to update icon SendMessage(hWnd,WM_COMMAND,IDM_ICON,NULL);  
      this.m_Params = GetParams(buffer);  
      LeaveCriticalSection(&g_cs);
      return true;  
}  

从此代码来看,我们似乎很清楚,我们隐式地g_cs锁层次结构中的顶级锁,即使我们只想同步对类成员变量的访问。

应做事项:

  • 设计并遵循锁层次结构。 添加所有必要的锁。 同步基元远不止 Mutex 和 CriticalSections;它们都需要包含在内。 如果在 DllMain () 中使用任何锁,请在层次结构中包含加载程序锁
  • 就锁定协议与依赖项达成一致。 应用程序调用或可能调用应用程序的任何代码都需要共享相同的锁层次结构
  • 锁定数据结构而不是函数。 将锁获取移开函数入口点,并使用锁仅保护数据访问。 如果代码在锁下操作较少,则死锁的可能性就越小
  • 分析错误处理代码中的锁获取和释放。 通常,如果在尝试从错误条件中恢复时忘记了锁层次结构
  • 将嵌套锁替换为引用计数器 - 它们不能死锁。 列表和表中单独锁定的元素是很好的候选项
  • 等待 DLL 中的线程句柄时要小心。 始终假定可以在加载程序锁下调用代码。 最好对资源进行引用计数,让工作线程 (执行自己的清理,然后使用 FreeLibraryAndExitThread 完全终止)
  • 如果要诊断自己的死锁,请使用等待链遍历 API

请勿:

  • 在 DllMain () 函数中执行非常简单的初始化工作以外的任何操作。 有关更多详细信息,请参阅 DllMain 回调函数。 特别是不要调用 LoadLibraryEx 或 CoCreateInstance
  • 编写自己的锁定基元。 自定义同步代码可以轻松地将细微的 bug 引入代码库。 改用丰富的操作系统同步对象选择
  • 在全局变量的构造函数和析构函数中执行任何工作,它们都在加载程序锁下执行

注意异常

异常允许将正常程序流和错误处理分开。 由于这种分离,在异常发生之前很难知道程序的精确状态,异常处理程序可能会错过还原有效状态的关键步骤。 对于需要在处理程序中释放以防止将来发生死锁的锁获取尤其如此。

下面的示例代码说明了此问题。 对“buffer”变量的无限制访问偶尔会导致访问冲突 (AV) 。 此 AV 由本机异常处理程序捕获,但它无法轻松确定在发生异常时是否已获取关键部分, (AV 甚至可能发生在 EnterCriticalSection 代码) 的某个位置。

 BOOL bar (char* buffer)  
{  
   BOOL rc = FALSE;  
   __try {  
      EnterCriticalSection(&cs);  
      while (*buffer++ != '&') ;  
      rc = GetParams(buffer);  
      LeaveCriticalSection(&cs);  
   } __except (EXCEPTION_EXECUTE_HANDLER)  
   {  
      return FALSE;  
   } 
   return rc;  
}  

应做事项:

  • 尽可能删除__try/__except;请勿使用 SetUnhandledExceptionFilter
  • 如果使用 C++ 异常,请将锁包装在类似于自定义auto_ptr模板中。 应在析构函数中释放锁。 对于本机异常,请释放 __finally 语句中的锁
  • 小心在本机异常处理程序中执行的代码;异常可能已泄漏许多锁,因此处理程序不应获取任何

请勿:

  • 处理本机异常(如果 Win32 API 不必要或不需要)。 如果在发生灾难性故障后使用本机异常处理程序进行报告或数据恢复,请考虑改用默认操作系统机制Windows 错误报告
  • 将 C++ 异常用于任何类型的 UI (user32) 代码;回调中引发的异常将遍历操作系统提供的 C 代码层。 该代码不知道 C++ 展开语义