摘要
这篇文章分析了在 Win10 系统上 DLL 卸载时发生死锁的根本原因。通过调试发现,死锁是由于 Win10 引入了并行加载器机制,导致线程在退出时等待全局事件 LdrpLoadCompleteEvent,而该事件又因 DllMain() 在处理 DLL_PROCESS_DETACH 时调用 WaitForSingleObject() 无限等待工作线程结束而无法被触发。
缘起
我在上一篇文章 《调试实战 | 从无法复现到真相大白:DLL卸载死锁背后的版本陷阱》中弄清楚了为什么之前卡死的程序不卡死的原因—— crt 调整了 _beginthreadex() 的逻辑,在其内部会增加 dll 引用计数,导致 FreeLibrary() 的时候没有正常卸载 dll,进而导致卡死的现象未重现。调整为 vs2010 后,就可以重现了。虽然重现了卡死的现象,但是在 win10 系统上卡死时的调用栈跟 win7 系统不太一样。通过 !cs -l 命令并不能看任何结果。本着打破砂锅问到底的原则,便有了今天的这篇总结。
测试代码
这里再简单贴一下关键代码,如下:
1 | //WaitDllUnloadExe.cpp |
1 | //dllmain.cpp |
初步调查
待程序卡死后,使用 windbg 附加。使用 ~* kn 命令查看所有线程的调用栈,可以发现 0 号线程和 6 号线程是我们需要关注的线程,而且这两个线程都在等待。
1 | 0:000> ~*kn |
先看一下 0 号线程在等待什么?函数 ntdll!NtWaitForSingleObject() 的第一个参数是等待的句柄,在 64 位程序中函数的第一个参数一般是通过 rcx 传递的。一般寄存器的值是在调用过程中发生变化的,不能直接查看。但是 ntdll!NtWaitForSingleObject() 会直接进内核,用户态的寄存器会被保存起来,不会发生变化,因此可以直接通过 r 命令查看。
1 | 0:000> uf ntdll!NtWaitForSingleObject |
执行 r rcx 命令查看 rcx 寄存器的值,如下:
1 | 0:000> r rcx |
说明: 执行
r命令时需要确保当前线程是我们关注的线程,0:000冒号后面的000是windbg中的线程编号,不是操作系统中的线程ID。
我们可以进一步通过 !handle 命令验证 0x2b8 是否是合法的句柄值。在 windbg 中执行 !handle 0x2b8 f。
1 | 0:000> !handle 0x2b8 f |
可以发现 0x2b8 是线程句柄,对应的线程 ID 是 4474,进程 ID 是 5680。使用 ~~[4474]s 切换线程,可以发现这个线程正是 6 号线程。(可以通过底部命令行左侧的 0:006 确认)

这与我们的代码完全匹配,我们的代码在 DllMain() 中会无限等待创建的线程结束,对应的关键代码如下:
1 | DWORD waitResult = WaitForSingleObject(g_hThread, INFINITE); |
知道 0 号线程在等待什么,接下来使用同样的方法查看 6 号线程在等待什么。
1 | 0:006> r rcx |
可以发现 6 号线程在等待一个事件,句柄值是 0x38。通过调用栈可知,6 号线程正在退出,在退出过程中调用了 LdrpDrainWorkQueue() ,进而导致了等待。至此已经从调用栈上看不出更多的信息了。为什么 6 号线程会陷入等待呢?谁又会触发这个事件呢?
查看反汇编
在 windbg 中执行 ub 00007ffc76d50022 L50,查看 ntdll!LdrpDrainWorkQueue() 调用 ntdll!NtWaitForSingleObject() 的相关代码,输出结果如下(输出结果有省略)
1 | 0:006> ub 00007ffc`76d50022 L50 |
注意查看 //<---- 对应的汇编代码,ntdll!NtWaitForSingleObject() 的参数来源自 r14,而 r14 的值可能来源于两个地方,一个是 ntdll!LdrpWorkCompleteEvent,一个是 ntdll!LdrpLoadCompleteEvent。大概逻辑如下:
1 | r14 = ntdll!LdrpWorkCompleteEvent; |
再看一下调用 ntdll!LdrpDrainWorkQueue() 的反汇编代码,如下:
1 | 0:006> ub 00007ffc`76d074ed |
可以发现,ntdll!LdrShutdownThread() 调用 ntdll!LdrpDrainWorkQueue() 时的参数是 0。所以最终等待的句柄是 ntdll!LdrpLoadCompleteEvent。
使用 dd ntdll!LdrpLoadCompleteEvent L1 查看其值,果然是 0x38。
1 | 0:006> dd ntdll!LdrpLoadCompleteEvent L1 |
看来 6 号线程在等待 ntdll!LdrpLoadCompleteEvent。那谁会触发这个事件呢?
继续查看反汇编
如果能有办法找到 ntdll!LdrpLoadCompleteEvent 所有的引用,就可以进一步查看相关代码。是时候请出 IDA 了,因为 IDA 的静态分析能力简直太香了。用 64 位的 IDA 打开 ntdll.dll,找到所有引用 ntdll!LdrpLoadCompleteEvent 的地方,如下图:

可以发现,一共就三个地方:LdrpDropLastInProgressCount+38↑r、LdrpDrainWorkQueue+2D↑r 和LdrpCreateLoaderEvents+12↑o。
小提示: 还可以在
windbg中使用#命令查找所有用到LdrpLoadCompleteEvent的代码。#命令的大概语法是# [Pattern] [Address [ L Size ]]。可以在windbg中输入.hh #查看该命令的详细解释。
1
2
3
4
5
6
7
8
9
10
11
12 > 0:006> lmm ntdll
> Browse full module list
> start end module name
> 00007ffc`76cf0000 00007ffc`76ee8000 ntdll (pdb symbols) d:\mssyms\ntdll.pdb\180BF1B90AA75697D0EFEA5E5630AC7E1\ntdll.pdb
> 0:006> # "ntdll!LdrpLoadCompleteEvent" 00007ffc`76cf0000 L99999
> ntdll!LdrpDropLastInProgressCount+0x38:
> 00007ffc`76d4eeb4 488b0dc5d41000 mov rcx,qword ptr [ntdll!LdrpLoadCompleteEvent (00007ffc`76e5c380)]
> ntdll!LdrpDrainWorkQueue+0x2d:
> 00007ffc`76d4fef1 4c0f443587c41000 cmove r14,qword ptr [ntdll!LdrpLoadCompleteEvent (00007ffc`76e5c380)]
> ntdll!LdrpCreateLoaderEvents+0x12:
> 00007ffc`76d6eb22 488d0d57d80e00 lea rcx,[ntdll!LdrpLoadCompleteEvent (00007ffc`76e5c380)]
>
在 IDA 中查看另外两个函数的反汇编,F5 结果如下:
1 | __int64 LdrpCreateLoaderEvents() |
从伪代码可知,函数 LdrpCreateLoaderEvents() 会创建对应的事件,而函数 LdrpDropLastInProgressCount() 会触发事件。那谁会调用 LdrpDropLastInProgressCount() 呢?继续在 IDA 中查找引用,如下图:

从图中可知,有很多函数都引用了 LdrpDropLastInProgressCount(),静态分析有点太费事了,如果能动态调试就再好不过了。
继续调试
我们之前在 DllMain() 中是无限等待的,如果只等待几秒,程序是可以正常退出的,也就是说 DllMain() 执行后应该是有人调用了 SetEvent() 的。修改代码为只等待 5 秒,等待结束后在 ntdll!NtSetEvent() 函数上设置断点,应该可以看到是谁触发了事件。整个过程比较简单,就省略了,直接给出结果,如下图:

看来,LdrUnloadDll() 函数会先调用 DllMain(),DllMain() 执行完成后,LdrUnloadDll() 会继续调用 LdrpDropLastInProgressCount() ,进而触发事件。如果我们在 DllMain() 中一直等待,LdrUnloadDll() 就没机会调用 LdrpDropLastInProgressCount() ,也就不会触发事件,最终就会导致死锁。
函数 LdrUnloadDll() 对应的伪代码如下(来自 IDA 的 F5,有删减):
1 | __int64 __fastcall LdrUnloadDll(__int64 a1) |
函数 LdrUnloadDll() 会先调用 LdrpDrainWorkQueue(),然后调用 LdrpDecrementModuleLoadCountEx()(其内部会调用 DllMain()),最后调用 LdrpDropLastInProgressCount()。
死锁的原因找到了,但是为什么第一个线程可以顺利退出呢?难道第一个线程没有等待吗?
继续深挖
在继续深挖之前,非常有必要对相关代码有个了解,以下是 LdrShutdownThread() 和 LdrpDrainWorkQueue() 的伪代码。
LdrShutdownThread()的伪代码如下(来自IDA的F5,有删减):
1 | __int64 __fastcall LdrShutdownThread(struct _RTLP_FLS_CONTEXT *a1) |
根据以上伪代码可知,函数 LdrShutdownThread() 调用 LdrpDrainWorkQueue() 时传递的 tastType 参数的值是 0。
LdrpDrainWorkQueue()的伪代码如下(来自IDA的F5,有删减):
1 | struct _TEB *__fastcall LdrpDrainWorkQueue(int taskType) |
如果 LdrpWorkInProgress 的值是 0,则 LdrpWorkInProgress 会被设置成 1。在执行等待代码的上方会跳出循环,因此不会执行等待逻辑。这也就解释了为什么第一个线程退出的时候为什么可以正常退出。
说明: 仔细阅读这个变量的名字,
LdrpWorkInProgress表示正在处理的工作数量,如果没有工作,则是0,有工作则非零。
那 LdrpWorkInProgress 什么时候会被改变呢?
LdrpWorkInProgress
可以在 windbg 中使用 # "LdrpWorkInProgress" 00007ffc76cf0000 L99999 查看 LdrpWorkInProgress 的使用情况,如下:
1 | 0:007> # "LdrpWorkInProgress" 00007ffc`76cf0000 L99999 |
可以发现在 LdrpDrainWorkQueue()、LdrpProcessWork()、LdrpDropLastInProgressCount()、LdrpWorkCallback() 中会修改它的值。
结合 LdrUnloadDll()、LdrpDrainWorkQueue() 和 LdrpDropLastInProgressCount() 的代码可知,其在调用 DllMain() 之前会通过 LdrpDrainWorkQueue() 设置 LdrpWorkInProgress 标志,在调用完 DllMain() 之后,又会通过调用 LdrpDropLastInProgressCount() 把这个值设置为 0。
其实,LdrShutdownThread() 也有类似的逻辑,也是会先调用 LdrpDrainWorkQueue() ,然后执行一些业务代码,最后会调用 LdrpDropLastInProgressCount() 。我们再看观察其它几个会调用 LdrpDropLastInProgressCount() 的函数,注意观察以下代码中的 //<---- 标记。
相关函数
LdrpLoadDllInternal()的伪代码如下(来自IDA的F5,有删减):
1 | __int64 __fastcall LdrpLoadDllInternal(__int64 a1, int a2, unsigned int a3, int a4, __int64 a5, __int64 a6, __int64 *a7, int *a8) |
LdrpInitializeThread()的伪代码如下(来自IDA的F5,有删减):
1 | __int64 __fastcall LdrpInitializeThread(__int64 a1, __int64 a2, __int64 a3) |
LdrEnumerateLoadedModules()的伪代码如下(来自IDA的F5,有删减):
1 | __int64 __fastcall LdrEnumerateLoadedModules(int a1, void (__fastcall *a2)(__int64 *, __int64, char *), __int64 a3) |
从以上代码中可以发现一个通用的模式,基本上会分成以下三个部分:
1 | // part 1 |
有点类似于用全局变量 LdrpWorkInProgress 做了一个锁。part1 先检查这个全局变量,如果已经设置了标记就等待,否则就设置标记。然后在 part2 执行业务代码,最后在 part3 清除这个标记。如果在 part2 中卡住了,其它线程就要执行等待逻辑。
问题总结
至此,整个卡死的前因后果都非常清晰了。
第一个线程结束的时候,我们没有执行 FreeLibrary(),也就不会执行 LdrUnloadDll(),LdrpWorkInProgress 的值是 0,LdrShutdownThread() 内部调用 LdrpDrainWorkQueue() 的时候,由于 LdrpWorkInProgress 的值是 0,所以不会等待。
当第二个线程结束的时候,LdrUnloadDll() 正在 DllMain() 中等待其结束,而在此之前 LdrpWorkInProgress 已经被 LdrUnloadDll() 设置成 1 了。所以第二个线程对应的 LdrShutdownThread() 内部调用 LdrpDrainWorkQueue() 的时候,对应的 LdrpWorkInProgress 的值是 1,因此需要等待。而 DllMain() 由于在等待线程结束,但是线程永远不会结束,LdrUnloadDll() 也就没机会调用后面的 LdrpDropLastInProgressCount() 触发事件,于是死锁了。
TEB.SameTebFlags
关于 SameTebFlags 可能的标志位信息,我问了一下 AI,看着比较靠谱,未经证实,各位谨慎参考。
1 | // 标志位掩码(共 16 位) |
主要关注 LoadOwner 和 LoaderWorker 标志。
Parallel Loader
在调查整个问题的过程中,查询了各种资料,才对这个问题有了一个粗浅的认识。原来在 win10 之前,所有涉及模块的操作都是串行完成的,通过加载器锁进行保护,如果遇到死锁,基本上可以通过 !cs -l 查看出来。而在 win10 中引入了并行加载器,加载工作改由多个工作线程并行执行。强烈建议阅读参考资料中的前两篇文章,以便对并行加载有个更深入的了解。
总结
win10开启了并行加载,提高了加载速度,但是增大了调试难度。- 永远不要在
DllMain中做一些复杂操作,尤其是执行等待操作。 !handle handle flag命令可以查看句柄的信息,具体参考windbg帮助文档- 可以在
windbg中执行# "assembly_string" memory_range搜索相关指令 - 在
win10下查看加载器相关的逻辑可以关注ntdll!LdrpLoadCompleteEvent、LdrpWorkInProgress等全局变量