DllMain 与加载器锁 ── 「DLL 初始化里什么都别做」的真正原因

· · Windows, DLL, Windows 开发, C++, 故障排查, 多线程, Win32 API

「应用在启动时挂起,但只在特定环境。」「加载自己的 DLL 时,LoadLibrary 有时永不返回。」「只在服务启动的时机死锁。」——把这类调查追得够远,多半会到达同一个地方。DLL 的初始化代码——也就是 DllMain

微软文档对 DllMain 的警告语气异常强硬。不要调用 LoadLibrary。不要与其他线程同步。不要调用 User、Shell 或 COM 函数。理想的 DllMain一个空桩——为什么措辞这么强?原因集中在一个内部机制上:加载器锁。面向在 Windows 上编写 DLL、插件和 C++/CLI 包装器的开发者,本文根据一手资料说明加载器锁如何工作、使死锁成立的结构、安全设计与调查步骤。

1. 先说结论

  • DllMain 在持有加载器锁时被调用,这是每个进程恰好一把的共享锁。因此从 DllMain 调用试图(直接或间接)获取加载器锁的工作,会造成死锁,或因触碰尚未初始化的 DLL 而崩溃。1
  • 禁止调用 LoadLibrary / FreeLibrary它会造成循环的加载顺序依赖,并可能导致初始化代码在一个自身初始化尚未运行的 DLL 上运行。2
  • 与其他线程同步也被禁止。DLL 通知是串行化的,因此在 DllMain 里等待线程启动或退出,会让那条线程自己停在等待加载器锁上,于是死锁。23
  • 能安全调用的,实务上只有 Kernel32.dll 的一个子集。官方文档还直白写了「安全函数的完整列表并不存在」。User、Shell 和 COM 函数会加载其他组件并导致访问违规。2
  • 在与 CRT 链接的 DLL 中,同样的限制适用于全局对象的构造函数和析构函数。它们作为 DllMain 的实际一部分运行。2
  • 正确设计是「推迟」。能在编译时(静态)完成的初始化就那样做;不能的推迟到首次使用。这是官方最佳实践。1
  • C++/CLI 混合 DLL 尤其危险。为避免在加载器锁下运行 MSIL,DllMain 及其调用树必须编译为原生。4

2. DllMain 何时以及如何被调用

DllMain 是 OS 加载器在 DLL 进入或离开进程或线程时调用的入口点。有四种通知。

通知 时机
DLL_PROCESS_ATTACH DLL 被加载进进程时
DLL_THREAD_ATTACH 进程中启动新线程时
DLL_THREAD_DETACH 线程正常退出时
DLL_PROCESS_DETACH DLL 被卸载,或进程退出时

有两个事实容易漏掉。第一,每创建一条线程,每个已加载 DLL 的 DllMain 都会以 DLL_THREAD_ATTACH 被调用。换句话说,DllMain 不是「我的 DLL 被加载时跑一次的东西」;它是随着进程的线程活动持续被调用的代码。若不需要,可以在 DLL_PROCESS_ATTACH 里调用 DisableThreadLibraryCalls 来停止(不要从与静态 CRT 链接的 DLL 调用)。5

第二,在与 CRT(C/C++ 运行时)链接的 DLL 中,全局和静态 C++ 对象的构造函数与析构函数会经由 CRT 的入口点,作为 DllMain 的一部分运行2 即便你以为「我们的 DllMain 是空的,所以安全」,带有复杂初始化的全局对象也等于在 DllMain 里跑那份工作。

DllMain 被调用的四个时机DLL 加载时运行 DLL_PROCESS_ATTACH;进程中每次线程启动和退出时对每个已加载 DLL 运行 DLL_THREAD_ATTACH 和 DETACH;卸载或进程退出时运行 DLL_PROCESS_DETACH;静态对象的构造函数也经 CRT 在此运行DLL 加载DLL_PROCESS_ATTACHDLL_THREAD_ATTACH(每次线程启动)DLL_THREAD_DETACH(每次线程退出)DLL_PROCESS_DETACH(卸载或退出时)静态对象的构造也在这里运行

图 1: DllMain 不仅在加载时被调用,每次线程启动和退出也会,静态对象的初始化也作为其中一部分运行。

3. 加载器锁 ── 串行化每一条通知的一把锁

为什么单单对 DllMain 的限制如此严厉?答案在加载器的结构里。

为保持 DLL 加载、卸载和各种通知这一系列操作一致,OS 加载器用每个进程一把加载器锁串行化工作。重要的是:DllMain 在持有这把加载器锁时被调用1 只要还在 DllMain 里,该进程中每一次其他 DLL 加载,以及每一次线程启动通知,都在等这把锁释放。

从这个结构出发,禁令的理由一条接一条。

  • 不得调用 LoadLibrary,因为它会造成加载器锁重入,或循环的加载顺序依赖。还可能导致在一个初始化尚未完成的 DLL 上调用函数。2
  • 与其他线程同步是危险的,因为你等待的那条线程有需要加载器锁的时刻(启动和退出时的通知、对 GetModuleHandle 族 API 的调用等)。你持有加载器锁并等待对方;对方等待加载器锁——经典的锁顺序倒置。6
  • User、Shell 和 COM 函数是危险的,因为它们会在内部加载其他系统组件。你在组件初始化之前或拆除之后触碰它,就会得到访问违规。2
为何在 DllMain 里等待线程会死锁持有加载器锁的 DllMain 等待工作线程退出,但正要退出的工作线程为接收 DLL_THREAD_DETACH 而等待加载器锁释放,于是互相等待而死锁工作线程DllMain加载器(持有锁)工作线程DllMain加载器(持有锁)退出通知需要这把锁DllMain 持有锁,W 在等待DLL_PROCESS_DETACH请求退出并等待完成工作,然后退出

图 2: 「DllMain 等待线程退出」是结构性死锁,因为线程退出本身需要加载器锁。

要点是,这不是「运气不好才会发生」的那种事;它在结构上必然成立。文档告诉你把加载器锁当作应用定义的锁层次的顶层(最先获取的那一把)。在 DllMain 里你已经持有那把顶层锁,因此从那里再去等待别的东西都是危险的——这样记很有用。6

加载器锁与私有锁之间的锁顺序倒置持有加载器锁的 DllMain 去获取一把私有锁,而持有该私有锁的工作线程为 GetModuleHandle 等去获取加载器锁,于是获取顺序倒置而死锁DllMain:持有加载器锁去获取私有锁 G工作线程:持有私有锁 G去获取加载器锁获取顺序倒置导致的死锁GetModuleHandle 等在内部需要它

图 3: 即便像 GetModuleHandle 这样无害的 API 也在内部需要加载器锁,因此与私有锁的顺序倒置可以成立。

另外,从 DllMain 内部调用 CreateThread 本身也不推荐。被创建的线程需要加载器锁来处理 DLL_THREAD_ATTACH 通知,因此在当前正在执行的 DllMain 返回并释放锁之前,它无法开始运行。因此在 DllMain 里等待那条线程启动或结束会立即死锁。还有生命周期问题——若 DllMain 返回后,DLL 在一条尚未开始运行的线程仍被留下时被卸载,该线程的起始地址仍指向已释放的代码,于是崩溃。3

4. C++ 开发者容易踩的两颗地雷

地雷 1:全局对象的动态初始化。如第 2 章所说,静态对象的构造函数在 DllMain 的限制下运行。读配置文件、拉起日志设施、初始化 COM、启动线程——一旦你在 DLL 里放一个构造函数做这类工作的全局对象,就是在执行「DllMain 里不得做的事」。编译期固定的常量初始化(能做成 constexpr 的)是安全的;涉及函数调用的初始化应当推迟。

初始化全局对象变成地雷的路径DLL 加载时获取加载器锁,全局对象的构造函数经 CRT 入口点运行,因此这些构造函数里的 LoadLibrary、线程同步和 COM 初始化都是在执行 DllMain 禁令DLL 加载(获取加载器锁)CRT 入口点全局对象的构造函数等价于 LoadLibrary 的工作启动线程并等待它结束使用 COM 或 User32这些全都落在 DllMain 禁令下

图 4: 即便「DllMain 是空的,所以安全」,一旦有带复杂初始化的全局对象,同样的危险就会复活。

地雷 2:C++/CLI(混合程序集)。用 C++/CLI 包装原生 DLL 的配置(包装器文章中的形态)里,有在加载器锁下运行 MSIL(托管代码)的危险。运行 MSIL 可能触发 CLR 初始化或加载另一个程序集。编译器对 DllMain 直接执行 MSIL 的代码发出警告 C4747,但它检测不到经另一模块中函数的间接执行。用 #pragma unmanagedDllMain 及从它调用的函数编译为原生,或使用根本没有 DllMain 的配置。4

加载器锁下的 MSIL 执行能否被检测DllMain 直接执行 MSIL 的代码可由编译器用警告 C4747 检测,但经另一模块中函数的间接执行不能,因此必须靠审查调用树并坚持原生编译来防止来自 DllMain 的调用直接执行 MSIL经另一模块执行可用警告 C4747 检测编译器检测不到用审查和 #pragma unmanaged 防止

图 5: C4747 只保护你免于直接执行。间接路径只能靠审查抓住。

5. 正确设计 ── 把「推迟」当作默认策略

官方最佳实践的建议很清楚。1

  1. 能在编译时(静态)完成的初始化就完成。先问动态初始化能否换成静态的。
  2. 其余推迟到首次使用。只要首次使用发生在 DLL 完成加载之后调用的普通 API 上,初始化就在加载器锁外运行,几乎可以使用整个 Windows API。首次访问上的互斥可以用 INIT_ONCE(一次性初始化)或 C++ magic statics(函数局部静态)。推迟不是万灵药——若那次首次访问本身来自 DllMain 或静态初始化器,初始化器仍在加载器锁下运行,你又回到同样的限制。
  3. 仅为必须尽早检测的失败破例。可能有「损坏的配置文件应让加载本身失败」的要求。即便如此,也只做到「尝试并立即失败」的最小程度。
  4. 在 DLL_PROCESS_ATTACH 中考虑 DisableThreadLibraryCalls若 DLL 不使用线程通知,可以去掉通知成本本身(使用静态 CRT 或静态 TLS 时除外)。5
  5. 用 Application Verifier 检查。DllMain 内许多危险调用是 Application Verifier 会在运行时检测到的。1
DLL 初始化的设计指引先考虑初始化能否做成编译期静态初始化;若不能,默认推迟到首次使用,只把必须作为加载失败尽早检测的最小部分留在 DllMain不能能在编译时决定?做成静态初始化必须在加载时检测到失败?推迟到首次使用(默认)只在 DllMain 做最小部分用 INIT_ONCE 或函数局部静态互斥

图 6: 判断顺序是「能否静态 → 能否推迟」,留在 DllMain 里的只是必须尽早检测的最小部分。

是否应用 DisableThreadLibraryCalls 可以用下面的分支机械决定。

是否调用 DisableThreadLibraryCalls不要从与静态 CRT 链接的 DLL 调用;若静态 TLS 生效则调用本身失败所以不调用;若两者都不适用且 DLL 不使用线程通知,则在 DLL_PROCESS_ATTACH 中调用并检查返回值,以削减通知成本与静态 CRT 链接?不得调用使用静态 TLS?调用反正会失败(FALSE)需要线程通知?在 ATTACH 中调用(检查返回值)不调用;处理通知

图 7: 静态 CRT、静态 TLS 以及是否需要通知这三个条件,唯一决定你该不该调用。

卸载时停止线程,官方文档给出了具体协议。不要在 DLL_PROCESS_DETACH(经 FreeLibrary 卸载时)「等待」工作线程退出,形态是 (1) 用事件发退出信号,(2) 线程一侧把工作收束到一致状态、回发信号并进入无限等待,(3) DllMain 一侧确认一致状态后用 TerminateThread 收束线程。3 看起来粗暴,但在「不得在 DllMain 里等待线程自然退出」这一约束下,它被记录为现实答案。

卸载时停止线程的协议DllMain 用事件向工作线程发退出信号;工作线程把工作收束到一致状态、回发信号并进入无限等待;DllMain 确认一致状态后终止该线程工作线程DllMain(DETACH 处理)工作线程DllMain(DETACH 处理)不等待自然退出,因此不死锁用事件发退出信号把工作收束到一致状态发出一致性完成信号并永远等待用 TerminateThread 终止

图 8: 用「等待一致性信号然后切断」代替「等待自然退出」,避免与加载器锁碰撞。

作为第一原则,最安全的设计是避免在可卸载的 DLL 里拥有线程,把线程所有权留在 EXE 一侧。

进程退出时的 DLL_PROCESS_DETACH 正好相反:什么都不做就返回是理想。到这时其他每条线程都已被强制终止,也不能依赖依赖 DLL 或运行时的状态。这里做复杂工作只会带来死锁和崩溃。必须持久化的数据应在应用自己的关闭路径里写出;不要依赖这条通知。3

6. 撞上时如何调查

加载器锁挂起有可识别的指纹。

看挂起转储中的堆栈。对冻结瞬间取转储,检查每条线程的堆栈。若发现一对:一条线程在 ntdll.dll 加载器函数(名称以 Ldr 开头的那一族)里等锁,另一条在 DllMain 或静态初始化器(dynamic initializer)里等别的东西——几乎可以确定。停在 LoadLibrary 调用中间的线程是另一个典型角色。

加载器锁挂起的指纹在挂起转储中,若同时发现在 ntdll 加载器函数里等锁的线程,以及在 DllMain 或静态初始化器里等别的东西的线程,就可以近乎确定地当作加载器锁死锁挂起转储在 Ldr 族函数里等锁的线程在 DllMain 或静态初始化器里等待的线程两者都在?几乎肯定是加载器锁死锁作为另一类挂起调查

图 9: 加载器锁挂起有可识别的指纹:「在 Ldr 里等待 + 在 DllMain 里等待」。

怀疑「依赖时机」的性格。加载器锁死锁只在 DLL 加载与线程启动或退出重合的那一刻成立。「偶尔在启动时」「只在某台机器上」「只作为服务运行时」这类复现条件,是这类问题的迹象。

做预防性检查。启用 Application Verifier 并跑测试,就能在运行时检测 DllMain 内的危险调用。1 对 C++/CLI,不要忽略警告 C4747;在从 DllMain 可达的函数审查中,把「间接调用 LoadLibrary 的函数」(COM 初始化、某些 CRT 功能、延迟加载导入等)加进审查清单,就能在出货前抓住事故。延迟加载导入的第一次调用在内部变成 LoadLibrary,是容易漏掉的一点。

7. 小结

  • DllMain 在持有加载器锁(每个进程一把,串行化每一条 DLL 通知的锁)时被调用。每一条限制都由此而来。
  • 禁令的核心是「不要调用 LoadLibrary / FreeLibrary」「不要与其他线程同步」「不要调用依赖 Kernel32 以外 DLL 的函数」。经 CRT 运行的静态对象构造函数和析构函数落在同样的限制下。
  • 基本设计方针是推迟。能做成静态的初始化就做成静态;其余推迟到首次使用。使用 DisableThreadLibraryCalls 和 Application Verifier。
  • 卸载时停止线程遵循官方协议(发信号 → 确认一致性 → 终止)。进程退出时的 DLL_PROCESS_DETACH 理想为空。
  • 在 C++/CLI 中,在加载器锁下运行 MSIL 是另一颗地雷。坚持对 DllMain 调用树做原生编译。

DllMain 的限制乍看像一份不合理的禁令清单。但一旦抓住「它在持有顶层锁——加载器锁——时被调用」这一点,每一条禁令都是同一原则的重述。把它当作原则记住,遇到文档里没有的边角情况时,你仍应能问出正确的问题:「这是我持有这把锁时被允许做的工作吗?」

相关文章

相关咨询领域

小村软件有限公司承接启动时或 DLL 加载时挂起与死锁的根因调查(转储分析)、围绕 DllMain 与静态初始化的设计评审,以及把 C++/CLI 包装器和插件 DLL 整治为安全初始化设计。即便还在「只在特定环境启动时挂起」这种难以复现的阶段,也可以咨询。

参考链接

  1. Microsoft Learn, Dynamic-Link Library Best Practices. 关于 DllMain 在持有加载器锁时被调用,因此能调用的函数受到严重限制;理想的 DllMain 是空桩,初始化尽可能推迟;推荐编译期静态初始化;对必须尽早检测的失败只做最小部分;以及用 Application Verifier 检测典型的 DllMain 错误。  2 3 4 5 6

  2. Microsoft Learn, DllMain entry point. 关于在入口点只做简单的初始化和终止;为何不得调用 LoadLibrary / FreeLibrary(循环加载顺序以及在初始化前或终止后使用 DLL);Kernel32.dll 保证已经加载,因此可以在不加载其他 DLL 的范围内调用;不存在安全函数的穷尽列表;User、Shell 和 COM 函数会导致访问违规;DLL 通知是串行化的,因此与其他线程或进程通信会造成死锁;以及链接 CRT 时同样的限制适用于静态对象的构造函数和析构函数。  2 3 4 5 6 7

  3. Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. 关于在 DllMain 里等待线程退出就会死锁的结构(线程退出的 DLL_THREAD_DETACH 通知需要加载器锁);卸载时停止线程的协议(用事件发信号、确认一致状态,然后终止);进程退出时的 DLL_PROCESS_DETACH 上其他线程已被强制终止且地址空间一致性没有保证,因此理想的处理程序是空的;以及在 DllMain 中创建线程会在初始化未完成时把通知排队并造成问题。  2 3 4

  4. Microsoft Learn, Initialization of Mixed Assemblies. 关于不要在加载器锁下执行 MSIL;不要把 DllMain 及其调用树编译为 MSIL,并用 #pragma unmanaged 处理;当 DllMain 试图直接执行 MSIL 时会发出警告 C4747,但经另一模块的间接执行检测不到;以及静态对象的动态初始化器会造成同样的问题。  2

  5. Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). 关于禁用 DLL_THREAD_ATTACH / DLL_THREAD_DETACH 通知以降低线程创建和销毁时的开销;不要从与静态 CRT 链接的 DLL 调用;以及静态 TLS(thread_local 或 __declspec(thread))生效时不执行该优化。  2

  6. Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. 关于定义锁层次并始终按同一顺序获取;加载器在调用 DllMain 之前获取加载器锁,因此加载器锁应位于锁层次的顶层;观察 GetModuleFileName 等间接获取加载器锁的 API 与私有锁之间的获取顺序;以及锁顺序倒置导致死锁的具体例子。  2

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

DllMain 里真的什么都不能做吗?
「什么都别做」不是夸张,而是官方设计立场,微软自己也说理想的 DllMain 是近乎空的桩。安全的是 Kernel32.dll 函数的一个子集——到 DllMain 运行时 Kernel32 保证已加载——且范围是不加载其他 DLL。创建临界区或互斥体、使用 TLS,是可以做的例子。反过来,LoadLibrary/FreeLibrary、与其他线程同步,以及调用 User32、Shell、COM 等函数,会因死锁和访问违规而被禁止。拿不准的初始化不要在 DllMain 里做;推迟到第一次使用时。
C++ 全局对象(静态对象)的构造函数也受 DllMain 限制吗?
是的。当 DLL 与 CRT(C++ 运行时)链接时,全局和静态对象的构造函数与析构函数会经由 CRT 提供的入口点,作为 DllMain 的实际一部分运行。这意味着从构造函数调用 LoadLibrary、启动另一条线程并等待它结束、初始化 COM 等,都带有与在 DllMain 里做这些事相同的危险。对带有非平凡初始化的全局对象,保留指针并在首次访问时构造,或使用函数局部静态,使工作在 DllMain 之外运行。
应该调用 DisableThreadLibraryCalls 吗?
有条件地,应该。若 DLL 不需要 DLL_THREAD_ATTACH/DETACH 通知,在 DLL_PROCESS_ATTACH 中调用 DisableThreadLibraryCalls 会停止按线程创建和按线程退出的通知,降低频繁创建线程的进程中的开销。有两个例外。不要从与静态 CRT 链接的 DLL 调用它(静态 CRT 需要线程通知)。若经由 thread_local 或 __declspec(thread) 的静态 TLS 生效,调用本身会失败并返回 FALSE,因此要养成检查返回值的习惯。在使用动态链接 CRT 的典型 DLL 上,确认没有东西依赖线程通知后再用。
为什么 C++/CLI(混合托管)DLL 会在启动时挂起?
典型原因是在持有加载器锁时试图运行 MSIL(托管代码)。在 C++/CLI 混合程序集中,若 DllMain、从它调用的函数,或全局对象的动态初始化器被编译为 MSIL,就可能在加载器锁下要求 CLR 初始化或加载另一个程序集,从而死锁。当 DllMain 本身试图直接执行 MSIL 时,编译器会发出警告 C4747,但它检测不到经另一模块的间接执行。缓解办法是用 #pragma unmanaged 把 DllMain 及其调用树编译为原生——或者根本不要有 DllMain。
可以在 DLL_PROCESS_DETACH 里清理资源吗?
答案在「进程退出」和「经 FreeLibrary 卸载」之间不同。进程退出时的 DLL_PROCESS_DETACH 上,其他线程已被终止,地址空间是否仍一致也没有保证,因此释放内存之类的清理实际上是危险的;官方指导是「理想的处理程序是空的」。必须持久化的数据在应用自己的关闭路径里写出,这里基本上什么都不做就返回。经 FreeLibrary 卸载时,进程还在继续,因此确实需要完整清理——停止线程、关闭句柄等。在 DllMain 里等待线程退出会死锁,所以必须遵循官方协议:发信号、等到一致状态,并在 DllMain 外完成工作。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表