摘要
这篇文章记录了我对 DLL 卸载时死锁问题的进一步探索。多年前,我曾在看雪论坛上发表过一篇关于 DLL 卸载死锁的分析文章,但最近有网友反馈无法复现这个问题。通过深入调试,我发现问题根源在于不同版本的 VS 运行时库对引用计数的处理方式不同。本文详细展示了如何使用 Windbg 追踪 DLL 引用计数的变化,解释了为什么在 VS2022 环境下原问题不再出现,最终定位到不同版本的 _beginthreadex 和 _endthreadex 的内部实现差异是关键所在。
缘起
前一阵子,网友跟我说我之前写的一篇文章有问题,他那里不能重现。文章是很糟之前发在看雪上的一篇关于 dll 卸载时死锁的文章。链接为 https://bbs.kanxue.com/thread-255547-1.htm。我的第一感觉是是不是操作系统不一致导致的加载/卸载行为发生变化导致的。但是这位网友给出的分析很可信。于是我自己也快速用 vs2022 建了一个测试工程,发现没死锁。虽然网友已经给了他的结论,但还是很有必要整体梳理一下。
测试代码
这里再简单贴一下关键代码
1 | //WaitDllUnloadExe.cpp |
1 | //dllmain.cpp |
初步观察
运行新编译出来的程序,简单观察了一下程序行为,可以发现执行完 FreeLibrary(module); 后,DllUnload.dll 并没有被卸载,引用计数是 1。

继续执行,main() 函数结束后,如果还有其它线程在运行,这些线程会被自动杀掉。所以并没有出现卡死的现象。
那为什么引用计数不是 0 呢?按理说 LoadLibraryA() 和 FreeLibrary() 是成对儿出现的,引用计数应该归零才对。
追踪引用计数
从上篇文章中可知,在 win10 系统下,每个模块的信息存储在 Peb.Ldr.InLoadOrderModuleList 中,引用计数存储在 _LDR_DATA_TABLE_ENTRY 的 DdagNode->LoadCount 中。可以在 LoadCount 上设置一个内存写断点。但是等一下,如果模块信息还没添加到 InLoadOrderModuleList 中,该如何设置断点呢?很简单,从 InLoadOrderModuleList 的名字可知,链表里存储的模块信息是按加载顺序来的。最新加载的模块会放到链表的末尾的位置,也就是 BLink 的位置,可以先对此字段设置内存写断点,有新模块被加载时,会修改此字段的值。
监视添加模块
重新运行程序,在调用 LoadLibrary() 的那行代码处设置断点,中断后,对 Peb.Ldr.InLoadOrderModuleList 的 BLink 设置写断点,然后让程序重新运行起来。很快断点便命中了,
1 | Breakpoint 1 hit |
执行 ub rip L3 查看最近的三条汇编指令,可以发现正在把 rbx 的值写入 rcx 指向的地址,因此触发了断点。执行 r rbx 命令查看寄存器的值。可以发现 rbx 的值是 00000217843326f0,也就是说 ntdll!_LDR_DATA_TABLE_ENTRY 对象的地址是 00000217843326f0。
1 | 0:000> ub rip L3 |
执行以下命令查看 dll 名称及引用计数。可以发现正是我们关注的 dll,而且当前的引用计数是 1。
1 | 0:000> dt ntdll!_LDR_DATA_TABLE_ENTRY 00000217843326f0 -y BaseDllName DdagNode->LoadCount |
可以执行 k 命令查看调用栈,可以知道是 ntdll!LdrpInsertDataTableEntry() 执行的插入操作。
1 | 0:000> k |
监视引用计数修改
从上面的分析可知,DllUnload.dll 模块对应的 ntdll!_LDR_DATA_TABLE_ENTRY 的地址是 00000217843326f0,其 LoadCount 的地址是 poi(00000217843326f0 + 0x98) + 0x18 也就是 0000021784338e28。可以执行以下命令对此地址设置内存写断点。
1 | ba w4 00000217`84338e28 |
设置好内存写断点后,重新运行程序,很快就断下来了。执行 k 命令,可以发现引用计数是被谁修改的。调用栈如下:
1 | 0:000> g |
可以发现是 _beginthreadex() 内部会间接调用 KERNELBASE!GetModuleHandleExW() 进而调用 ntdll!LdrpIncrementModuleLoadCount() 增加了引用计数。再次执行 g 命令,让程序重新运行起来。很快断点又再次命中。因为我们的 DllMain() 中创建了两个线程。

查看 _beginthreadex() 和 _beginthread() 的源码可知,这两个函数内部都会调用这段代码 unique_thread_parameter parameter(create_thread_parameter(procedure, context)); 这段代码内部会调用 GetModuleHandleExW() 间接增加引用计数。

再次让程序运行,可以发现断点又触发了,引用计数变成了 2。这次是 _endthreadex() 调用触发的,入口函数是 quitDemoProc 对应的线程结束了。
1 | Breakpoint 2 hit |
再次让程序运行,断点再次被触发,这次是 FreeLibrary() 触发的。
1 | 0:010> g |
解释
原来是,vs2022 编译时使用的运行时库中的 _beginthreadex() 会增加引用计数,_endthreadex() 会减少引用计数。两个线程中的一个会及时退出,而另外一个没有退出,导致引用计数没有归零,进而导致模块没有被卸载。所以就没出现卡死的情况。
而 vs2010 编译时使用的运行时库中的 _endthreadex() 不会修改引用计数(具体参考 vs2010 目录下的 threadex.c 文件)。当调用 FreeLibrary() 时,会触发模块卸载行为,进而会遇到卡死的情况。
手动实验
我已经把相关测试工程上传到了这里 todo,感兴趣的小伙伴可以自行测试验证。
说明: 如果想重现卡死问题,只需要修改【平台工具集】属性的值为
Visual Studio 2010 (v100)
总结
VS2022对应的运行时库中,_beginthreadex()内部会调用GetModuleHandleExW()增加DLL的引用计数,而_endthreadex()则会减少引用计数。而VS2010对应的运行时库不会增加DLL的引用计数。- 内存断点是必须要掌握的一种断点,适用于监视已知地址的内存变化。
!dlls -c module命令可以查看module对应的引用计数。