The case of the UI thread that hung in a kernel call
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线程会表现为挂起。
像 SuspendThread
这样的函数主要供调试器使用,所以我们问他们是否在捕获内核转储时有调试器附加到进程。他们说没有。
那么是谁挂起了该线程,又是为什么?
客户随后意识到他们有一个监视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线程的堆栈跟踪,但它挂起在 RtlLookupFunctionEntry
中,该函数正在等待一个锁。
你们知道我打赌是谁持有该锁吗?
是UI线程。
它被挂起了。
UI线程可能正在尝试分发一个异常,这意味着它正在遍历堆栈以查找异常处理程序。但是在搜索过程中,它被watchdog线程挂起了。然后,watchdog线程尝试遍历UI线程的堆栈,但它还不能这样做,因为函数表被UI线程的堆栈遍历锁定。
这是一个关于先前讨论的实践考试:为什么永远不应该挂起线程。
具体来说,标题应该说“为什么永远不应该 在你自己的进程中 挂起线程。”在你自己的进程中挂起线程会带来风险,即你挂起的线程拥有程序其余部分需要的某些资源。 特别是,它可能拥有负责最终恢复该线程的代码所需要的资源。 由于它被挂起,它将永远没有机会释放这些资源,最终导致挂起的线程和负责恢复该线程的线程之间发生死锁。
如果你想挂起一个线程并从中捕获堆栈,你必须从另一个进程中执行此操作,这样你就不会与你挂起的线程发生死锁。¹
额外说明:在此内核堆栈中,你可以看到证据表明 SuspendThread
异步运行。 当watchdog线程调用 SuspendThread
来挂起UI线程时,UI线程位于内核中,正在更改内存保护。 该线程不会立即挂起,而是等待内核完成其工作,然后在返回到用户模式之前,内核会执行 CheckForKernelApcDelivery
以查看是否有任何请求正在等待。 它拾取挂起的请求,然后线程实际挂起。²
额外说明的额外说明:“如果内核延迟挂起持有任何用户模式锁的线程怎么办? 这难道不会避免这个问题吗?” 首先,内核怎么知道一个线程是否持有任何用户模式锁? 用户模式锁没有可靠的签名。 毕竟,你可以通过将其用作自旋锁,从而使用任何字节的内存来创建用户模式锁。 其次,即使内核以某种方式可以确定线程是否持有用户模式锁,你也不希望阻止线程挂起,因为这将允许程序使自己无法挂起! 只需调用 AcquireSRWLockShared(some_global_srwlock)
并且永远不要调用相应的 Release
函数。 恭喜,该线程以共享模式永久拥有全局锁,因此现在将免受挂起。
¹ 当然,这也要求执行挂起操作的代码不等待跨进程资源(如信号量、互斥锁或文件锁),因为这些资源可能由挂起的线程持有。
² 内核不会立即挂起线程,因为它可能拥有内部内核锁,并且在线程拥有内核锁(例如同步访问页表的锁)时挂起线程会导致内核本身死锁!