「应用在启动时挂起,但只在特定环境。」「加载自己的 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 里跑那份工作。
flowchart TB
accTitle: DllMain 被调用的四个时机
accDescr: DLL 加载时运行 DLL_PROCESS_ATTACH;进程中每次线程启动和退出时对每个已加载 DLL 运行 DLL_THREAD_ATTACH 和 DETACH;卸载或进程退出时运行 DLL_PROCESS_DETACH;静态对象的构造函数也经 CRT 在此运行
load["DLL 加载"] --> pa["DLL_PROCESS_ATTACH"]
pa --> ta["DLL_THREAD_ATTACH(每次线程启动)"]
ta --> td["DLL_THREAD_DETACH(每次线程退出)"]
td --> pd["DLL_PROCESS_DETACH(卸载或退出时)"]
pa -.-> crt["静态对象的构造也在这里运行"]
图 1: DllMain 不仅在加载时被调用,每次线程启动和退出也会,静态对象的初始化也作为其中一部分运行。
3. 加载器锁 ── 串行化每一条通知的一把锁
为什么单单对 DllMain 的限制如此严厉?答案在加载器的结构里。
为保持 DLL 加载、卸载和各种通知这一系列操作一致,OS 加载器用每个进程一把加载器锁串行化工作。重要的是:DllMain 在持有这把加载器锁时被调用。1 只要还在 DllMain 里,该进程中每一次其他 DLL 加载,以及每一次线程启动通知,都在等这把锁释放。
从这个结构出发,禁令的理由一条接一条。
- 不得调用
LoadLibrary,因为它会造成加载器锁重入,或循环的加载顺序依赖。还可能导致在一个初始化尚未完成的 DLL 上调用函数。2 - 与其他线程同步是危险的,因为你等待的那条线程有需要加载器锁的时刻(启动和退出时的通知、对
GetModuleHandle族 API 的调用等)。你持有加载器锁并等待对方;对方等待加载器锁——经典的锁顺序倒置。6 - User、Shell 和 COM 函数是危险的,因为它们会在内部加载其他系统组件。你在组件初始化之前或拆除之后触碰它,就会得到访问违规。2
sequenceDiagram
accTitle: 为何在 DllMain 里等待线程会死锁
accDescr: 持有加载器锁的 DllMain 等待工作线程退出,但正要退出的工作线程为接收 DLL_THREAD_DETACH 而等待加载器锁释放,于是互相等待而死锁
participant L as 加载器(持有锁)
participant D as DllMain
participant W as 工作线程
L->>D: DLL_PROCESS_DETACH
D->>W: 请求退出并等待
W->>W: 完成工作,然后退出
Note over W: 退出通知需要这把锁
Note over D,W: DllMain 持有锁,W 在等待
图 2: 「DllMain 等待线程退出」是结构性死锁,因为线程退出本身需要加载器锁。
要点是,这不是「运气不好才会发生」的那种事;它在结构上必然成立。文档告诉你把加载器锁当作应用定义的锁层次的顶层(最先获取的那一把)。在 DllMain 里你已经持有那把顶层锁,因此从那里再去等待别的东西都是危险的——这样记很有用。6
flowchart TB
accTitle: 加载器锁与私有锁之间的锁顺序倒置
accDescr: 持有加载器锁的 DllMain 去获取一把私有锁,而持有该私有锁的工作线程为 GetModuleHandle 等去获取加载器锁,于是获取顺序倒置而死锁
d["DllMain:持有加载器锁"] --> dg["去获取私有锁 G"]
w["工作线程:持有私有锁 G"] --> wl["去获取加载器锁"]
dg -.-> dead["获取顺序倒置导致的死锁"]
wl -.-> dead
wl -.-> api["GetModuleHandle 等在内部需要它"]
图 3: 即便像 GetModuleHandle 这样无害的 API 也在内部需要加载器锁,因此与私有锁的顺序倒置可以成立。
另外,从 DllMain 内部调用 CreateThread 本身也不推荐。被创建的线程需要加载器锁来处理 DLL_THREAD_ATTACH 通知,因此在当前正在执行的 DllMain 返回并释放锁之前,它无法开始运行。因此在 DllMain 里等待那条线程启动或结束会立即死锁。还有生命周期问题——若 DllMain 返回后,DLL 在一条尚未开始运行的线程仍被留下时被卸载,该线程的起始地址仍指向已释放的代码,于是崩溃。3
4. C++ 开发者容易踩的两颗地雷
地雷 1:全局对象的动态初始化。如第 2 章所说,静态对象的构造函数在 DllMain 的限制下运行。读配置文件、拉起日志设施、初始化 COM、启动线程——一旦你在 DLL 里放一个构造函数做这类工作的全局对象,就是在执行「DllMain 里不得做的事」。编译期固定的常量初始化(能做成 constexpr 的)是安全的;涉及函数调用的初始化应当推迟。
flowchart TB
accTitle: 初始化全局对象变成地雷的路径
accDescr: DLL 加载时获取加载器锁,全局对象的构造函数经 CRT 入口点运行,因此这些构造函数里的 LoadLibrary、线程同步和 COM 初始化都是在执行 DllMain 禁令
load["DLL 加载(获取加载器锁)"] --> crt["CRT 入口点"]
crt --> ctor["全局对象的构造函数"]
ctor --> ng1["等价于 LoadLibrary 的工作"]
ctor --> ng2["启动线程并等待它结束"]
ctor --> ng3["使用 COM 或 User32"]
ng1 -.-> risk["这些全都落在 DllMain 禁令下"]
ng2 -.-> risk
ng3 -.-> risk
图 4: 即便「DllMain 是空的,所以安全」,一旦有带复杂初始化的全局对象,同样的危险就会复活。
地雷 2:C++/CLI(混合程序集)。用 C++/CLI 包装原生 DLL 的配置(包装器文章中的形态)里,有在加载器锁下运行 MSIL(托管代码)的危险。运行 MSIL 可能触发 CLR 初始化或加载另一个程序集。编译器对 DllMain 直接执行 MSIL 的代码发出警告 C4747,但它检测不到经另一模块中函数的间接执行。用 #pragma unmanaged 把 DllMain 及从它调用的函数编译为原生,或使用根本没有 DllMain 的配置。4
flowchart TB
accTitle: 加载器锁下的 MSIL 执行能否被检测
accDescr: DllMain 直接执行 MSIL 的代码可由编译器用警告 C4747 检测,但经另一模块中函数的间接执行不能,因此必须靠审查调用树并坚持原生编译来防止
d2["来自 DllMain 的调用"] --> dir["直接执行 MSIL"]
d2 --> ind["经另一模块执行"]
dir --> c47["可用警告 C4747 检测"]
ind --> nc["编译器检测不到"]
nc -.-> rv["用审查和 #pragma unmanaged 防止"]
图 5: C4747 只保护你免于直接执行。间接路径只能靠审查抓住。
5. 正确设计 ── 把「推迟」当作默认策略
官方最佳实践的建议很清楚。1
- 能在编译时(静态)完成的初始化就完成。先问动态初始化能否换成静态的。
- 其余推迟到首次使用。只要首次使用发生在 DLL 完成加载之后调用的普通 API 上,初始化就在加载器锁外运行,几乎可以使用整个 Windows API。首次访问上的互斥可以用
INIT_ONCE(一次性初始化)或 C++ magic statics(函数局部静态)。推迟不是万灵药——若那次首次访问本身来自DllMain或静态初始化器,初始化器仍在加载器锁下运行,你又回到同样的限制。 - 仅为必须尽早检测的失败破例。可能有「损坏的配置文件应让加载本身失败」的要求。即便如此,也只做到「尝试并立即失败」的最小程度。
- 在 DLL_PROCESS_ATTACH 中考虑
DisableThreadLibraryCalls。若 DLL 不使用线程通知,可以去掉通知成本本身(使用静态 CRT 或静态 TLS 时除外)。5 - 用 Application Verifier 检查。
DllMain内许多危险调用是 Application Verifier 会在运行时检测到的。1
flowchart TB
accTitle: DLL 初始化的设计指引
accDescr: 先考虑初始化能否做成编译期静态初始化;若不能,默认推迟到首次使用,只把必须作为加载失败尽早检测的最小部分留在 DllMain
q1{"能在编译时决定?"} -->|"能"| s["做成静态初始化"]
q1 -->|"不能"| q2{"必须在加载时检测到失败?"}
q2 -->|"否"| lazy["推迟到首次使用(默认)"]
q2 -->|"是"| min["只在 DllMain 做最小部分"]
lazy -.-> once["用 INIT_ONCE 或函数局部静态互斥"]
图 6: 判断顺序是「能否静态 → 能否推迟」,留在 DllMain 里的只是必须尽早检测的最小部分。
是否应用 DisableThreadLibraryCalls 可以用下面的分支机械决定。
flowchart TB
accTitle: 是否调用 DisableThreadLibraryCalls
accDescr: 不要从与静态 CRT 链接的 DLL 调用;若静态 TLS 生效则调用本身失败所以不调用;若两者都不适用且 DLL 不使用线程通知,则在 DLL_PROCESS_ATTACH 中调用并检查返回值,以削减通知成本
q1{"与静态 CRT 链接?"} -->|"是"| no2["不得调用"]
q1 -->|"否"| q2{"使用静态 TLS?"}
q2 -->|"是"| eff["调用反正会失败(FALSE)"]
q2 -->|"否"| q3{"需要线程通知?"}
q3 -->|"否"| yes["在 ATTACH 中调用(检查返回值)"]
q3 -->|"是"| keep["不调用;处理通知"]
图 7: 静态 CRT、静态 TLS 以及是否需要通知这三个条件,唯一决定你该不该调用。
对卸载时停止线程,官方文档给出了具体协议。不要在 DLL_PROCESS_DETACH(经 FreeLibrary 卸载时)「等待」工作线程退出,形态是 (1) 用事件发退出信号,(2) 线程一侧把工作收束到一致状态、回发信号并进入无限等待,(3) DllMain 一侧确认一致状态后用 TerminateThread 收束线程。3 看起来粗暴,但在「不得在 DllMain 里等待线程自然退出」这一约束下,它被记录为现实答案。
sequenceDiagram
accTitle: 卸载时停止线程的协议
accDescr: DllMain 用事件向工作线程发退出信号;工作线程把工作收束到一致状态、回发信号并进入无限等待;DllMain 确认一致状态后终止该线程
participant D as DllMain(DETACH 处理)
participant W as 工作线程
D->>W: 用事件发退出信号
W->>W: 把工作收束到一致状态
W->>D: 发出一致性完成信号并永远等待
D->>W: 用 TerminateThread 终止
Note over D,W: 不等待自然退出,因此不死锁
图 8: 用「等待一致性信号然后切断」代替「等待自然退出」,避免与加载器锁碰撞。
作为第一原则,最安全的设计是避免在可卸载的 DLL 里拥有线程,把线程所有权留在 EXE 一侧。
进程退出时的 DLL_PROCESS_DETACH 正好相反:什么都不做就返回是理想。到这时其他每条线程都已被强制终止,也不能依赖依赖 DLL 或运行时的状态。这里做复杂工作只会带来死锁和崩溃。必须持久化的数据应在应用自己的关闭路径里写出;不要依赖这条通知。3
6. 撞上时如何调查
加载器锁挂起有可识别的指纹。
看挂起转储中的堆栈。对冻结瞬间取转储,检查每条线程的堆栈。若发现一对:一条线程在 ntdll.dll 加载器函数(名称以 Ldr 开头的那一族)里等锁,另一条在 DllMain 或静态初始化器(dynamic initializer)里等别的东西——几乎可以确定。停在 LoadLibrary 调用中间的线程是另一个典型角色。
flowchart TB
accTitle: 加载器锁挂起的指纹
accDescr: 在挂起转储中,若同时发现在 ntdll 加载器函数里等锁的线程,以及在 DllMain 或静态初始化器里等别的东西的线程,就可以近乎确定地当作加载器锁死锁
dump["挂起转储"] --> t1["在 Ldr 族函数里等锁的线程"]
dump --> t2["在 DllMain 或静态初始化器里等待的线程"]
t1 --> pair{"两者都在?"}
t2 --> pair
pair -->|"是"| conf["几乎肯定是加载器锁死锁"]
pair -->|"否"| other["作为另一类挂起调查"]
图 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 的限制乍看像一份不合理的禁令清单。但一旦抓住「它在持有顶层锁——加载器锁——时被调用」这一点,每一条禁令都是同一原则的重述。把它当作原则记住,遇到文档里没有的边角情况时,你仍应能问出正确的问题:「这是我持有这把锁时被允许做的工作吗?」
相关文章
- Windows DLL 名称解析机制 - 搜索顺序与 SxS
- 从 C# 调用原生 DLL:C++/CLI 包装器 vs P/Invoke
- 多线程实务最佳实践 C++ 篇 ── 用 RAII 和 jthread 从结构上消除事故
- 虚假唤醒 ── 条件变量为何会「未被通知就醒来」,以及在 Windows 上正确等待的方法
- 用 WinDbg + SOS 解读崩溃转储 ── 采集之后的实务分析入门
- COM STA/MTA 基础 - 线程模型与避免 Hang 的思路
相关咨询领域
小村软件有限公司承接启动时或 DLL 加载时挂起与死锁的根因调查(转储分析)、围绕 DllMain 与静态初始化的设计评审,以及把 C++/CLI 包装器和插件 DLL 整治为安全初始化设计。即便还在「只在特定环境启动时挂起」这种难以复现的阶段,也可以咨询。
参考链接
-
Microsoft Learn, Dynamic-Link Library Best Practices. 关于 DllMain 在持有加载器锁时被调用,因此能调用的函数受到严重限制;理想的 DllMain 是空桩,初始化尽可能推迟;推荐编译期静态初始化;对必须尽早检测的失败只做最小部分;以及用 Application Verifier 检测典型的 DllMain 错误。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, DllMain entry point. 关于在入口点只做简单的初始化和终止;为何不得调用 LoadLibrary / FreeLibrary(循环加载顺序以及在初始化前或终止后使用 DLL);Kernel32.dll 保证已经加载,因此可以在不加载其他 DLL 的范围内调用;不存在安全函数的穷尽列表;User、Shell 和 COM 函数会导致访问违规;DLL 通知是串行化的,因此与其他线程或进程通信会造成死锁;以及链接 CRT 时同样的限制适用于静态对象的构造函数和析构函数。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. 关于在 DllMain 里等待线程退出就会死锁的结构(线程退出的 DLL_THREAD_DETACH 通知需要加载器锁);卸载时停止线程的协议(用事件发信号、确认一致状态,然后终止);进程退出时的 DLL_PROCESS_DETACH 上其他线程已被强制终止且地址空间一致性没有保证,因此理想的处理程序是空的;以及在 DllMain 中创建线程会在初始化未完成时把通知排队并造成问题。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Initialization of Mixed Assemblies. 关于不要在加载器锁下执行 MSIL;不要把 DllMain 及其调用树编译为 MSIL,并用 #pragma unmanaged 处理;当 DllMain 试图直接执行 MSIL 时会发出警告 C4747,但经另一模块的间接执行检测不到;以及静态对象的动态初始化器会造成同样的问题。 ↩ ↩2
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). 关于禁用 DLL_THREAD_ATTACH / DLL_THREAD_DETACH 通知以降低线程创建和销毁时的开销;不要从与静态 CRT 链接的 DLL 调用;以及静态 TLS(thread_local 或 __declspec(thread))生效时不执行该优化。 ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. 关于定义锁层次并始终按同一顺序获取;加载器在调用 DllMain 之前获取加载器锁,因此加载器锁应位于锁层次的顶层;观察 GetModuleFileName 等间接获取加载器锁的 API 与私有锁之间的获取顺序;以及锁顺序倒置导致死锁的具体例子。 ↩ ↩2
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Win32 线程池 API ── 用 CreateThreadpoolWork 实现不自建线程的并发
原生代码里是否到处调用 CreateThread?本文根据一手资料讲解 Vista 重新设计的 Win32 线程池 API——work、timer、wait、io 四种对象、清理组,以及回调中禁止做的事。
「无响应」的真正含义 ── Windows 如何判定应用已挂起,以及如何设计不挂起的应用
Windows 的「无响应」是操作系统判定窗口已 5 秒未取出消息并换成幽灵窗口的机制。本文说明该判定的内部、挂起的经典原因、把重活移出 UI 线程的设计,以及调查挂起的步骤。
虚假唤醒 ── 条件变量为何会「未被通知就醒来」,以及在 Windows 上正确等待的方法
条件变量的等待即使没有通知到达也可能返回(虚假唤醒)。本文从 Windows 实现说明规格为何允许这一点,并给出用 while 循环和谓词在 Win32、C++ 和 C# 中正确等待的写法。
命名管道实务 ── 从设计到安全的 Windows 标准 IPC
以实务视角讲解 Windows 标准进程间通信——命名管道。根据一手资料整理字节模式与消息模式的选择、同时处理多客户端的服务器设计、ACL 与模拟的安全性,以及 .NET 的命名管道流。
从睡眠恢复后损坏的应用 ── Windows 电源事件机制与扛得住恢复的业务应用
打开笔记本后业务应用的连接全断了——原因是设计从未考虑睡眠。本文根据一手资料整理 WM_POWERBROADCAST 通知流程、Modern Standby 行为、断开/重连设计、睡眠抑制与调查命令。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
常见问题
汇总了咨询这一主题时常见的问题。
- 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 外完成工作。