UI线程在内核调用中挂起的案例分析

客户寻求帮助,解决一个长期存在但发生频率较低的挂起问题,他们始终无法找出原因。根据他们掌握的情况,他们的UI线程调用了内核,而这个调用却无缘无故地挂起了。不幸的是,内核转储无法显示用户模式下的堆栈,因为堆栈已经被分页出去了。(这很合理,因为挂起的线程没有使用它的堆栈,所以一旦系统面临一些内存压力,该堆栈就会被分页出去。)

0: kd> !thread 0xffffd18b976ec080 7
THREAD ffffd18b976ec080 Cid 79a0.7f18 Teb: 0000003d7ca28000
  Win32Thread: ffffd18b89a8f170 WAIT: (Suspended) KernelMode Non-Alertable
SuspendCount 1
  ffffd18b976ec360 NotificationEvent
Not impersonating
DeviceMap         ffffad897944d640
Owning Process      ffffd18bcf9ec080    Image:     contoso.exe
Attached Process     N/A      Image:     N/A
Wait Start TickCount   14112735    Ticks: 1235580 (0:05:21:45.937)
Context Switch Count   1442664    IdealProcessor: 2       
UserTime         00:02:46.015
KernelTime        00:01:11.515
 nt!KiSwapContext+0x76
 nt!KiSwapThread+0x928
 nt!KiCommitThreadWait+0x370
 nt!KeWaitForSingleObject+0x7a4
 nt!KiSchedulerApc+0xec
 nt!KiDeliverApc+0x5f9
 nt!KiCheckForKernelApcDelivery+0x34
 nt!MiUnlockAndDereferenceVad+0x8d
 nt!MmProtectVirtualMemory+0x312
 nt!NtProtectVirtualMemory+0x1d9
 nt!KiSystemServiceCopyEnd+0x25 (TrapFrame @ ffff8707`a9bef3a0)
 ntdll!ZwProtectVirtualMemory+0x14
 [end of stack trace]

虽然我们无法看到代码在用户模式下正在做什么,但在现有信息中存在一些不寻常的地方。

请注意,出问题的线程是 Suspended 的。而且似乎已经被挂起超过五个小时了。

THREAD ffffd18b976ec080 Cid 79a0.7f18 Teb: 0000003d7ca28000
  Win32Thread: ffffd18b89a8f170 WAIT: (Suspended) KernelMode Non-Alertable
SuspendCount 1
  ffffd18b976ec360 NotificationEvent
Not impersonating
DeviceMap         ffffad897944d640
Owning Process      ffffd18bcf9ec080    Image:     contoso.exe
Attached Process     N/A      Image:     N/A
Wait Start TickCount   14112735    Ticks: 1235580 (0:05:21:45.937)

很自然,一个被挂起的UI线程会表现为挂起。

Suspend­Thread 这样的函数主要供调试器使用,所以我们问他们是否在捕获内核转储时有调试器附加到进程。他们说没有。

那么是谁挂起了该线程,又是为什么?

客户随后意识到他们有一个监视UI线程响应的watchdog线程,并且每隔一段时间,它会挂起UI线程,捕获堆栈跟踪,然后恢复UI线程。在转储文件中,他们能够观察到他们的watchdog线程正处于其堆栈跟踪捕获代码的中间。但是,为什么堆栈跟踪捕获需要五个小时?

watchdog线程的堆栈如下所示:

ntdll!ZwWaitForAlertByThreadId(void)+0x14
ntdll!RtlpAcquireSRWLockSharedContended+0x15a
ntdll!RtlpxLookupFunctionTable+0x180
ntdll!RtlLookupFunctionEntry+0x4d
contoso!GetStackTrace+0x72
contoso!GetStackTraceOfUIThread+0x127
...

好的,我们看到watchdog线程正在尝试获取UI线程的堆栈跟踪,但它挂起在 Rtl­Lookup­Function­Entry 中,该函数正在等待一个锁。

你们知道我打赌是谁持有该锁吗?

是UI线程。

它被挂起了。

UI线程可能正在尝试分发一个异常,这意味着它正在遍历堆栈以查找异常处理程序。但是在搜索过程中,它被watchdog线程挂起了。然后,watchdog线程尝试遍历UI线程的堆栈,但它还不能这样做,因为函数表被UI线程的堆栈遍历锁定。

这是一个关于先前讨论的实践考试:为什么永远不应该挂起线程

具体来说,标题应该说“为什么永远不应该 在你自己的进程中 挂起线程。”在你自己的进程中挂起线程会带来风险,即你挂起的线程拥有程序其余部分需要的某些资源。 特别是,它可能拥有负责最终恢复该线程的代码所需要的资源。 由于它被挂起,它将永远没有机会释放这些资源,最终导致挂起的线程和负责恢复该线程的线程之间发生死锁。

如果你想挂起一个线程并从中捕获堆栈,你必须从另一个进程中执行此操作,这样你就不会与你挂起的线程发生死锁。¹

额外说明:在此内核堆栈中,你可以看到证据表明 Suspend­Thread 异步运行。 当watchdog线程调用 Suspend­Thread 来挂起UI线程时,UI线程位于内核中,正在更改内存保护。 该线程不会立即挂起,而是等待内核完成其工作,然后在返回到用户模式之前,内核会执行 Check­For­Kernel­Apc­Delivery 以查看是否有任何请求正在等待。 它拾取挂起的请求,然后线程实际挂起。²

额外说明的额外说明:“如果内核延迟挂起持有任何用户模式锁的线程怎么办? 这难道不会避免这个问题吗?” 首先,内核怎么知道一个线程是否持有任何用户模式锁? 用户模式锁没有可靠的签名。 毕竟,你可以通过将其用作自旋锁,从而使用任何字节的内存来创建用户模式锁。 其次,即使内核以某种方式可以确定线程是否持有用户模式锁,你也不希望阻止线程挂起,因为这将允许程序使自己无法挂起! 只需调用 AcquireSRWLockShared(some_global_srwlock) 并且永远不要调用相应的 Release 函数。 恭喜,该线程以共享模式永久拥有全局锁,因此现在将免受挂起。

¹ 当然,这也要求执行挂起操作的代码不等待跨进程资源(如信号量、互斥锁或文件锁),因为这些资源可能由挂起的线程持有。

² 内核不会立即挂起线程,因为它可能拥有内部内核锁,并且在线程拥有内核锁(例如同步访问页表的锁)时挂起线程会导致内核本身死锁!