关于线程从已卸载的第三方DLL中执行的问题
摘要
微软Raymond Chen的一则技术调试故事,讲述了Windows资源管理器崩溃由从已卸载第三方DLL中执行的线程引起,展示了开发者如何调查此类问题。
<p>Explorer团队正在调查一个以较高频率出现的崩溃,发现其形式是一个线程从已卸载的第三方DLL中执行。</p>
<pre>0:173> k
RetAddr Call Site
00000000`557c5820 <Unloaded_LibUtils_CloudNs_3.dll>+0x265fe
00000000`00000008 <Unloaded_LibUtils_CloudNs_3.dll>+0x2b5820
00000000`0000000e 0x8
00000000`00000008 0xe
00000000`557c8c18 0x8
ffffffff`fffffffe <Unloaded_LibUtils_CloudNs_3.dll>+0x2b8c18
00000000`00000000 0xffffffff`fffffffe
</pre>
<p>堆栈上几乎没有什么信息。</p>
<pre>0:173> dps @rsp
00000000`1248f920 00000000`557c5820 <Unloaded_LibUtils_CloudNs_3.dll>+0x2b5820
00000000`1248f928 00000000`00000008
00000000`1248f930 00000000`0000000e
00000000`1248f938 00000000`00000008
00000000`1248f940 00000000`557c8c18 <Unloaded_LibUtils_CloudNs_3.dll>+0x2b8c18
00000000`1248f948 ffffffff`fffffffe
00000000`1248f950 00000000`00000000
00000000`1248f958 00000000`00000000
00000000`1248f960 00000000`00000000
00000000`1248f968 00000000`00000000
00000000`1248f970 00000000`00000000
00000000`1248f978 00000000`00000000
00000000`1248f980 00000000`00000000
00000000`1248f988 00007ff9`a2117344 kernel32!BaseThreadInitThunk+0x14
00000000`1248f990 00000000`00000000
00000000`1248f998 00000000`00000000
</pre>
<p>这只是一个完全在<tt>LibDB.CloudNs.3.dll</tt>内部运行的工作线程。它的堆栈不深,因此我怀疑它处于空闲状态,正在等待工作。</p>
<p>对于这类调查,崩溃线程本身通常没有太多直接线索。这个线程只是受害者。你需要做进一步的研究,找出是谁过早卸载了DLL。</p>
<p>一些探查发现了另一个涉及这个已卸载DLL的堆栈:</p>
<pre>0:159> k
RetAddr Call Site
00007ff9`9fdbbea0 ntdll!ZwWaitForMultipleObjects+0x14
00007ff9`9fdbbd9e KERNELBASE!WaitForMultipleObjectsEx+0xf0
00000000`554d65fe KERNELBASE!WaitForMultipleObjects+0xe
00000000`55765820 <Unloaded_LibDB_CloudNs_3.dll>+0x965fe
00000000`00000003 <Unloaded_LibUtils_JsonNs_3.dll>+0x255820
00000000`00000004 0x3
00000000`00000008 0x4
00000000`55768c18 0x8
ffffffff`fffffffe <Unloaded_LibUtils_CloudNs_3.dll>+0x258c18
00000000`00000000 0xffffffff`fffffffe
</pre>
<p>最近卸载的DLL有:</p>
<pre>00007ff9`6d7c0000 00007ff9`6d80a000 FabrikamContextMenu.dll
00007ff9`115e0000 00007ff9`1172f000 LitWareSync.dll
00007ff9`643d0000 00007ff9`64681000 CcNamespace.dll
00000000`55440000 00000000`5550b000 LibDB_CloudNs_3.dll
00000000`55860000 00000000`55998000 LibNet_CloudNs_3.dll
00000000`557f0000 00000000`5585b000 LibJson_CloudNs_3.dll
00000000`55510000 00000000`557e7000 LibUtils_CloudNs_3.dll
00000000`561a0000 00000000`56238000 MSVCP100.dll
00000000`56240000 00000000`56312000 MSVCR100.dll
00007ff9`85130000 00007ff9`85167000 EhStorShell.dll
00007ff9`3cac0000 00007ff9`3cb61000 wpdshext.dll
00007ff9`78a00000 00007ff9`78a26000 EhStorAPI.dll
00007ff9`686f0000 00007ff9`68754000 PlayToDevice.dll
00007ff9`67110000 00007ff9`6718d000 provsvc.dll
</pre>
<p>所以被卸载的<tt>LibDB.CloudNs.3.dll</tt>只是整个<tt>Lib*.CloudNs.3.dll</tt>动态库生态系统的一部分,它们是一起被卸载的。</p>
<p>这次操作的主谋似乎是<tt>CcNamespace.dll</tt>,它看起来像是Contoso命名空间扩展,用于在<span style="text-decoration: line-through;"><i>我的电脑</i></span><i>此电脑</i>下添加一个“Contoso”节点,让你查看所有存储在Contoso云服务中的Contoso内容。其他所有DLL都是主<tt>CcNamespace.dll</tt>用来完成任务的辅助程序。</p>
<p>主<tt>CcNamespace.dll</tt>是由Explorer作为Shell扩展加载的,当<tt>CcNamespace.dll</tt>中的对象没有活动引用时,其<code>DllCanUnloadNow</code>函数返回<code>S_OK</code>。不幸的是,当它说“当然,卸载我是安全的”时,这个关键DLL卸载了它所有的附属,却没有意识到其中一个附属(实用工具库)已经启动了某些工作线程。</p>
<p>你可能认为修复方法是更新实用工具库的<code>DllCanUnloadNow</code>,使其在有忙碌的后台线程时返回<code>S_FALSE</code>。¹ 但这并不管用,因为实用工具库本身很可能根本不是COM DLL。它只是<tt>CcNamespace.dll</tt>使用的传统DLL,而<tt>CcNamespace.dll</tt>才是COM DLL。</p>
<p><tt>CcNamespace.dll</tt>中的<code>DllCanUnloadNow</code>可以警告<tt>LibUtils.CloudNs.3.dll</tt>应当开始关闭,但这样会陷入困境,因为<code>DLL_PROCESS_DETACH</code>无法等待工作线程退出。</p>
<p>我认为可行的方法是让工作线程在启动时增加DLL引用计数,并使用<code>FreeLibraryAndExitThread</code>退出工作线程。或者,可以将其工作线程设为线程池线程,并使用<code>FreeLibraryWhenCallbackReturns</code>请求系统在完成后减少DLL引用计数。</p>
<p>这可能是实用工具库本来就应该做的事情。我怀疑客户端甚至不知道实用工具库中有这个工作线程。它只是实用工具库的一个实现细节,在主DLL不知情的情况下创建的。</p>
<p>幸运的是,应用程序兼容性团队在其库中有一份Contoso Cloud的副本,因此即使我们无法重现该崩溃,我们仍然能够确认<tt>CcNamespace.dll</tt>确实是Shell扩展DLL,它的卸载触发了所有依赖DLL的卸载。</p>
<p>我们正准备联系Contoso并给出我们的结论和改进建议,但发现这毫无意义,因为Contoso多年前就已停止该命名空间扩展。他们用另一种方式将其云内容集成到Windows中;唯一还在使用这个命名空间扩展的是那些仍在使用旧版本的人,要么是因为不愿付费升级,要么是因为喜欢旧方式而主动避免升级。</p>
<p>这些客户正在使用一个已经停止支持的产品。Contoso不再关心那些老客户。Windows将不得不在没有Contoso帮助的情况下修复这个问题。</p>
<p>Explorer团队为Contoso Cloud命名空间扩展添加了一个应用程序兼容性标志,表示“当你加载这个Shell扩展时,使用<code>GET_MODULE_HANDLE_EX_FLAG_PIN</code>标志调用<code>GetModuleHandleEx</code>,使DLL永不卸载。”这样,即使DLL说“当然,卸载我吧,完全安全,请相信我”,并且COM执行了<code>FreeLibrary</code>,DLL实际上也不会被卸载。</p>
<p>¹ 即使你能设法让<code>DllCanUnloadNow</code>返回<code>S_FALSE</code>,如果COM正在被反初始化,也无济于事。在这种情况下,<a title="CoUninitilize会询问DLL是否可以立即卸载,但答案已成定局" href="https://devblogs.microsoft.com/oldnewthing/20131106-00/?p=2723"> <code>CoUninitilize</code>会询问DLL是否可以立即卸载,但答案已成定局</a>:如果COM正在关闭,COM将卸载所有它加载的DLL。它询问你是否同意,并不是因为在意你的回答,而是为了给你一个机会“</p>
查看缓存全文
缓存时间: 2026/07/02 17:21
# 线程从一个已卸载的第三方 DLL 中执行的问题 - 往事新说
来源:https://devblogs.microsoft.com/oldnewthing/20260702-00?p=112500
Explorer 团队在调查一个发生频率较高的崩溃问题时发现,崩溃表现为线程从一个已卸载的第三方 DLL 中执行。
```
0:173> k
RetAddr Call Site
00000000`557c5820 +0x265fe
00000000`00000008 +0x2b5820
00000000`0000000e 0x8
00000000`00000008 0xe
00000000`557c8c18 0x8
ffffffff`fffffffe +0x2b8c18
00000000`00000000 0xffffffff`fffffffe
```
调用栈几乎没什么内容。
```
0:173> dps @rsp
00000000`1248f920 00000000`557c5820 +0x2b5820
00000000`1248f928 00000000`00000008
00000000`1248f930 00000000`0000000e
00000000`1248f938 00000000`00000008
00000000`1248f940 00000000`557c8c18 +0x2b8c18
00000000`1248f948 ffffffff`fffffffe
00000000`1248f950 00000000`00000000
00000000`1248f958 00000000`00000000
00000000`1248f960 00000000`00000000
00000000`1248f968 00000000`00000000
00000000`1248f970 00000000`00000000
00000000`1248f978 00000000`00000000
00000000`1248f980 00000000`00000000
00000000`1248f988 00007ff9`a2117344 kernel32!BaseThreadInitThunk+0x14
00000000`1248f990 00000000`00000000
00000000`1248f998 00000000`00000000
```
这只是一个完全在 `LibDB.CloudNs.3.dll` 内部执行的工作线程。它的栈很浅,所以我怀疑它处于空闲状态,正在等待工作。对于这类调查,通常在崩溃线程中直接能看到的信息不多。该线程只是受害者。你需要进一步研究,找出是谁过早地卸载了该 DLL。
进一步检查发现另一个堆栈也涉及这个已卸载的 DLL:
```
0:159> k
RetAddr Call Site
00007ff9`9fdbbea0 ntdll!ZwWaitForMultipleObjects+0x14
00007ff9`9fdbbd9e KERNELBASE!WaitForMultipleObjectsEx+0xf0
00000000`554d65fe KERNELBASE!WaitForMultipleObjects+0xe
00000000`55765820 +0x965fe
00000000`00000003 +0x255820
00000000`00000004 0x3
00000000`00000008 0x4
00000000`55768c18 0x8
ffffffff`fffffffe +0x258c18
00000000`00000000 0xffffffff`fffffffe
```
最近卸载的 DLL 列表如下:
```
00007ff9`6d7c0000 00007ff9`6d80a000 FabrikamContextMenu.dll
00007ff9`115e0000 00007ff9`1172f000 LitWareSync.dll
00007ff9`643d0000 00007ff9`64681000 CcNamespace.dll
00000000`55440000 00000000`5550b000 LibDB_CloudNs_3.dll
00000000`55860000 00000000`55998000 LibNet_CloudNs_3.dll
00000000`557f0000 00000000`5585b000 LibJson_CloudNs_3.dll
00000000`55510000 00000000`557e7000 LibUtils_CloudNs_3.dll
00000000`561a0000 00000000`56238000 MSVCP100.dll
00000000`56240000 00000000`56312000 MSVCR100.dll
00007ff9`85130000 00007ff9`85167000 EhStorShell.dll
00007ff9`3cac0000 00007ff9`3cb61000 wpdshext.dll
00007ff9`78a00000 00007ff9`78a26000 EhStorAPI.dll
00007ff9`686f0000 00007ff9`68754000 PlayToDevice.dll
00007ff9`67110000 00007ff9`6718d000 provsvc.dll
```
因此,被卸载的 `LibDB.CloudNs.3.dll` 只是整个 `Lib*.CloudNs.3.dll` 动态库生态系统的一部分,它们是一起被卸载的。这个操作的主谋似乎是 `CcNamespace.dll`,它看起来像是一个 Contoso 命名空间扩展,会在“我的电脑”/“此电脑”下添加一个“Contoso”节点,让你能查看所有存储在 Contoso 云服务中的 Contoso 内容。其他所有 DLL 都是主 `CcNamespace.dll` 用来完成任务的辅助库。
主 `CcNamespace.dll` 是由 Explorer 作为 shell 扩展加载的,当 `CcNamespace.dll` 中没有对对象的活跃引用时,其 `DllCanUnloadNow` 函数返回 `S_OK`。不幸的是,当它说“当然,卸载我是安全的”时,这个关键 DLL 就卸载了它所有的附属 DLL,却没有意识到其中一个附属(工具库)已经启动了一些工作线程。
你可能会想,解决办法是更新工具库的 `DllCanUnloadNow`,让它如果有繁忙的后台线程就返回 `S_FALSE`。¹ 但这行不通,因为工具库很可能根本不是 COM DLL。它只是一个传统 DLL,供 `CcNamespace.dll` 使用,而 `CcNamespace.dll` 才是 COM DLL。`CcNamespace.dll` 中的 `DllCanUnloadNow` 可以警告 `LibUtils.CloudNs.3.dll` 应该开始收尾,但你基本上陷入了一个棘手的境地,因为 `DLL_PROCESS_ATTACH` 不能等待工作线程退出。
我认为正确的做法是:工作线程在启动时增加 DLL 的引用计数,并使用 `FreeLibraryAndExitThread` 来退出工作线程。或者,它可以把工作线程设为线程池线程,并使用 `FreeLibraryWhenCallbackReturns` 来请求系统在回调完成时减少 DLL 的引用计数。这很可能是工具库本来就应该做的事情。我怀疑工作线程甚至不为工具库的客户端所知。它只是工具库的一个实现细节,在主 DLL 不知情的情况下创建。
幸运的是,应用程序兼容性团队在他们的库中保存了一份 Contoso Cloud 的副本,所以即使我们无法重现崩溃,也仍然能确认 `CcNamespace.dll` 确实是那个 shell 扩展 DLL,它的卸载触发了所有依赖 DLL 的卸载。我们正准备联系 Contoso,给出我们的结论和改进建议,但发现这毫无意义,因为 Contoso 多年前就已经停止维护那个命名空间扩展了。他们用另一种方式将云内容集成到 Windows 中;还在使用这个命名空间扩展的人,要么是因为不想付费升级,要么是因为怀念旧方式而主动避免升级。那些客户使用的是已经停止支持的产品。Contoso 已经不再关心那些老客户了。Windows 必须在不借助 Contoso 帮助的情况下修复这个问题。
Explorer 团队为 Contoso Cloud 命名空间扩展添加了一个应用程序兼容性标志,内容是:“当加载这个 shell 扩展时,使用 `GET_MODULE_HANDLE_EX_FLAG_PIN` 标志调用 `GetModuleHandleEx`,这样 DLL 就永远不会被卸载。”这样,即使 DLL 说“放心,卸载我吧,绝对安全,相信我”,COM 执行 `FreeLibrary` 时,DLL 实际上也不会被卸载。
¹ 即使你设法让 `DllCanUnloadNow` 返回 `S_FALSE`,当 COM 正在被反初始化时也没有用。在这种情况下,`CoUninitialize` 会询问 DLL 是否现在可以被卸载,但答案是显而易见的(https://devblogs.microsoft.com/oldnewthing/20131106-00/?p=2723):如果 COM 正在关闭,COM 将卸载它加载的所有 DLL。它问你是否同意,并不是因为它关心你的回答,而是为了给你一个在 `DllMain` 之外进行清理的机会。
### 类别
### 主题
## 作者
Raymond Chen
Raymond 已经参与 Windows 的发展超过 30 年。2003 年,他创办了名为“The Old New Thing”的网站,其受欢迎程度远超他最疯狂的想象,这一发展至今仍让他感到不安。该网站衍生出一本书,巧合的是书名也是《The Old New Thing》(Addison Wesley,2007)。他偶尔会出现在 Windows Dev Docs 的 Twitter 账号上,讲述一些不传达有用信息的故事。
相似文章
一个DLL未正式卸载却从内存消失的案例,第一部分
本文调查了一个崩溃转储文件:进程关闭期间,shell32.dll中发生栈溢出,导致重复的异常处理循环。分析追踪了递归异常处理,并确定了涉及DLL卸载的根本原因。
一个未正式卸载却从内存中消失的DLL案例,第二部分
Ray Chen的一篇技术博文,探究一个内存损坏bug:单个字节0x01破坏HMODULE句柄,导致DLL被错误释放,进而在进程终止时崩溃。
我们如何确定 CcNamespace.dll 是一组过早卸载的 DLL 中的主谋?
Raymond Chen 解释了他是如何通过分析调试器的已卸载 DLL 列表,并注意命名模式和顺序,从而确定 CcNamespace.dll 是一串过早卸载的 DLL 中的关键 DLL。
Windows DLL加载器锁:Rust线程如何使JVM挂起
QuestDB工程师调试一个偶发的Windows挂起问题,该问题由涉及Windows DLL加载器锁、Rust线程本地存储销毁、JNI分离和JVM垃圾收集安全点机制的死锁引起。
用户更改键盘布局时程序挂起的问题
一个调试故事,讲述了当用户更改键盘布局(例如使用 Win+Space 快捷键)时,Windows 程序挂起的原因,是由于一个后台线程创建了窗口但没有泵送消息。修复方法是要么泵送消息,要么销毁窗口。