更新记录(仅首版,2026年08月22日 发布)
- 首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22176723)
以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。
Go Komura(2026)。《DllMain 与加载器锁——“DLL 初始化里什么都别做”的真正原因》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/dllmain-loader-lock/
- DOI(已登记存档)
- 10.5281/zenodo.22176723
- DOI(上次登记版本)
- 10.5281/zenodo.22176724
“加载自己写的 DLL 时,LoadLibrary 迟迟不返回”“只在特定的 PC 上或服务启动时挂起”。遇到这类问题,首先要检查的地方之一,就是 DLL 的初始化代码,也就是 DllMain 以及从它调用的处理。
DllMain 与应用中普通的初始化函数不同。它是 操作系统在持有加载器锁的状态下调用的函数,因此能做的处理受到很强的限制。微软之所以说“理想的 DllMain 是空的桩函数”,是因为这个限制不只作用于自己的 DLL,还会影响进程内其他的 DLL 和线程。1
本文面向在 Windows 上编写 DLL、插件和 C++/CLI 包装器的开发者,按 为什么会卡住 → 把初始化移到哪里 → 退出时怎么做 → 挂起怎么查 的顺序梳理。
1. 先说结论——缩小 DllMain,改变执行的时点
与其在 DllMain 里寻找安全的写法,不如减少在那里执行的工作,这才是基本原则。 是否保留,按下面的顺序判断。1
| 判断项 | 基本方针 | 详细阅读位置 |
|---|---|---|
| 何时进行初始化 | 编译时能确定的做成静态初始化,其余推迟到加载完成后的首次使用 | 第 5 章 |
| 从 DllMain 调用什么 | 避免 LoadLibrary / FreeLibrary、与其他线程同步以及使用 User、Shell、COM 等。间接调用也要确认 |
第 3 章 |
| 退出时做什么 | 区分只卸载 DLL 的情况和整个进程退出的情况 | 第 6 章 |
容易漏掉的是,全局和静态 C++ 对象的构造函数与析构函数也受同样的限制。在 C++/CLI 中,还要消除在加载器锁下执行 MSIL 的路径。只看 DllMain 本体是不是空的,无法作出判断。23
如果现在正在调查挂起,可以从第 7 章读起;如果要重新审视设计,按第 2 到第 6 章的顺序读,就能把每条禁令与处理办法一一对上。
2. 机制——DllMain 在加载器锁的内侧被调用
2.1 不只是加载时,线程开始和结束时也会被调用
DllMain 是在 DLL 进出进程或线程的时机,由操作系统的加载器调用的入口点。通知有下面 4 种。2
| 通知 | 时机 |
|---|---|
| DLL_PROCESS_ATTACH | DLL 被加载到进程中时 |
| DLL_THREAD_ATTACH | 进程内启动新线程时 |
| DLL_THREAD_DETACH | 线程正常结束时 |
| DLL_PROCESS_DETACH | DLL 卸载时,或进程退出时 |
线程启动时的通知,不只发给创建该线程的 DLL,也会送达进程中已加载的 DLL。DllMain 不是“自己的 DLL 被加载时只运行一次的代码”。在频繁创建线程的进程中,每来一次通知就会执行一次。2
如果不需要通知,可以选择在 DLL_PROCESS_ATTACH 中调用 DisableThreadLibraryCalls。不过它不能用于与静态 CRT 链接的 DLL,静态 TLS 也另有条件,因此放到 5.3 分开判断。4
2.2 被调用时锁已被持有,这是所有限制的出发点
操作系统的加载器为了保证 DLL 的加载、卸载和通知的一致性,用 每个进程一把的加载器锁 把处理串行化。重要的是,加载器在调用 DllMain 之前获取这把锁,并在 DllMain 执行期间一直持有。1
在这期间,同一进程的其他线程若要加载 DLL,或者推进线程开始、结束的通知,都会变成等待这把锁释放。这里不是可以只按自己 DLL 的需要插入长时间等待的地方。
flowchart TB
accTitle: DllMain 的处理为何会影响整个进程的 DLL 通知
accDescr: 加载器先获取进程共用的加载器锁再调用 DllMain,其他线程的 DLL 加载和线程通知都要等它释放,因此 DllMain 中的处理也会影响其他 DLL 和线程
loader["加载器获取共用锁"] --> dll["执行 DllMain"]
dll --> ret["DllMain 返回"]
ret --> unlock["释放加载器锁"]
other["其他线程的 DLL 加载与通知"] --> wait["等待同一把锁释放"]
unlock --> resume["等待中的处理得以继续"]
wait --> resume
图1:DllMain 执行期间共用锁一直被持有,因此在那里等待会挡住其他 DLL 和线程的推进。
后面的禁止事项,只要按 “这个处理会不会直接或间接地需要加载器锁或其他 DLL 的初始化” 来想,就能理解。
3. 为什么会卡住——用四条路径理解禁止事项
3.1 调用会加载其他 DLL 的处理
要避免从 DllMain 调用 LoadLibrary / FreeLibrary。LoadLibrary 会在加载顺序上制造循环依赖,招致初始化代码尚未执行的 DLL 被使用。在退出一侧,同样有使用已处理完、已释放的 DLL 的危险。2
并不是自己没写 LoadLibrary 就安全。User、Shell、COM 的函数中,有些会在内部加载其他系统组件,可能碰到尚未初始化或已释放的组件而造成访问违规。2
可以安全调用的基本范围,是在 DllMain 执行时一定已加载的 Kernel32.dll 中不加载其他 DLL 的那些函数。例如创建临界区、互斥体等同步对象,以及使用 TLS 都可以。不过官方明确写着,安全函数的穷尽列表并不存在。不要以为“是 Kernel32 所以怎么用都行”“既然能创建同步对象,那等待其他线程也行”。2
3.2 在 DllMain 里等待其他线程结束
典型的死锁发生在 DLL 卸载时,形态是 DllMain 要求工作线程结束,并等待它结束。
工作线程即使做完了自己的活,也必须经过线程结束时的 DLL_THREAD_DETACH 通知。而这个通知需要 正在等待的 DllMain 一侧持有的加载器锁。结果就是,DllMain 等工作线程,工作线程等 DllMain 返回。5
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 内的结束等待解不开。
这种形态不是“运气不好才会卡住”,而是互相等待在结构上必然成立。卸载时需要的清理,放在第 6 章与进程退出时的处理分开考虑。
3.3 自己的锁与加载器锁的获取顺序发生逆转
不只是线程的开始和结束,像 GetModuleHandle 这样的 API 在内部也需要加载器锁。下面两条路径叠加时,锁的获取顺序就会逆转。6
- DllMain 一侧是 先持有加载器锁,再去获取私有锁 G。
- 工作线程一侧是 先持有私有锁 G,再调用 API 去获取加载器锁。
flowchart TB
accTitle: 加载器锁与私有锁的顺序逆转
accDescr: DllMain 在持有加载器锁的状态下去获取私有锁,工作线程在持有私有锁的状态下为了 GetModuleHandle 等去获取加载器锁,获取顺序逆转因而死锁
d["DllMain:持有加载器锁"] --> dg["去获取私有锁 G"]
w["工作线程:持有私有锁 G"] --> wl["去获取加载器锁"]
dg -.-> dead["获取顺序逆转导致死锁"]
wl -.-> dead
wl -.-> api["GetModuleHandle 等在内部需要它"]
图3:一方按加载器锁→G 的顺序获取,另一方按 G→加载器锁的顺序获取,就会互相等待对方释放。
官方要求把加载器锁当作应用锁层次中的 最上层,也就是最先被获取的那把锁。进入 DllMain 时,这把锁已经获取完毕。不只看所调用函数的名字,还要确认 是在持有哪把锁的状态下调用的。6
3.4 仅仅创建线程,也仍留有启动等待和生存期问题
在 DllMain 中调用 CreateThread 同样不被推荐。新线程在 DLL_THREAD_ATTACH 通知处理完之前,无法开始执行线程函数。由于当前的 DllMain 正持有加载器锁,在这个 DllMain 内等待它启动或完成就会死锁。5
不等待也并非万事大吉。DllMain 返回之后,如果创建出来的线程还没跑起来 DLL 就被卸载,线程的起始地址会指向已释放的代码,崩溃的危险依然存在。5
flowchart TB
accTitle: DllMain 内创建线程残留的两个问题
accDescr: DllMain 内创建的线程要为启动通知等待加载器锁,因此 DllMain 一侧等待它启动或完成就会死锁,而不等待就返回时,若 DLL 在它开始执行前被卸载,代码的生存期就已结束而崩溃
create["在 DllMain 内创建线程"] --> pending["新线程等待启动通知"]
pending --> q{"在 DllMain 内等待启动或完成?"}
q -->|"是"| dead["持着锁互相等待"]
q -->|"否"| returns["从 DllMain 返回"]
returns --> race["在开始执行前卸载 DLL"]
race --> crash["起始地址上的代码已不存在"]
图4:不在 DllMain 内等待,与守住所创建线程所用 DLL 的生存期,是两件分别需要做的事。
4. DllMain 为空也依然危险的两个地方
4.1 全局和静态对象的动态初始化
在与 CRT(C/C++ 运行时)链接的 DLL 中,全局和静态 C++ 对象的构造函数与析构函数会经由 CRT 的入口点执行。它们实际上是 DllMain 的一部分,受同样的限制。2
即使把读取配置文件、启动日志机构、初始化 COM、启动线程放进构造函数,也 只是把调用位置藏了起来,执行时点仍在加载器锁的内侧。只要复杂处理中含有加载其他 DLL 或线程同步,危险就和直接写在 DllMain 本体里一样。
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
图5:不只是 DllMain 本体,从 CRT 调用的静态对象的初始化与退出处理也要纳入评审范围。
编译时就能确定的常量初始化,例如可以写成 constexpr 的那些,要与运行时的复杂初始化分开考虑。伴随函数调用的动态初始化则推迟,并让首次访问也发生在 DllMain 之外。
4.2 C++/CLI 的被调用方执行 MSIL
用 C++/CLI 包装原生 DLL 的构成,在用 C++/CLI 包装原生 DLL 的实务中有讨论。在这种构成里,需要留意在加载器锁下执行 MSIL(托管代码)的路径。如果执行 MSIL 导致需要初始化 CLR 或加载其他程序集,就可能死锁。3
对于 DllMain 直接 执行 MSIL 的代码,编译器会给出警告 C4747。但是,经由其他模块中函数的间接执行无法被检测到。仅以没有警告为依据,不能判断为安全。静态对象的动态初始化器也是确认对象。3
flowchart TB
accTitle: 加载器锁下的 MSIL 执行能否被检测
accDescr: DllMain 直接执行 MSIL 的代码可由编译器用警告 C4747 检测,但经由其他模块中函数的间接执行检测不到,因此必须靠评审调用树和坚持原生编译来防止
d2["来自 DllMain 的调用"] --> dir["直接执行 MSIL"]
d2 --> ind["经由其他模块执行"]
dir --> c47["可用警告 C4747 检测"]
ind --> nc["编译器检测不到"]
nc -.-> rv["靠评审和 #pragma unmanaged 防止"]
图6:除了 C4747 能检测的直接路径,经由其他模块的调用也要评审。
对策是把 DllMain 以及从它可达的函数 用 #pragma unmanaged 编译成原生代码,或者采用根本不带 DllMain 的构成。即便是后者,也不要漏掉静态初始化器之类的间接路径。3
5. 初始化的设计——做成静态、推迟、只留最小限度
5.1 在留给 DllMain 之前,先想能不能改变执行时点
官方的基本方针是,能做的初始化在编译时完成,其余尽可能推迟。只有必须作为加载时的失败尽早检测出来的处理,才作为例外最小限度地保留。1
例如,可能存在这样的要求:依赖的配置文件损坏时,要让 DLL 的加载本身失败。即便如此,也不要先推进其他初始化再失败,而要收敛成“试着做必要的处理,随即失败”的形式。这并不构成“加载时就可以做复杂初始化”的例外。1
flowchart TB
accTitle: DLL 初始化的设计指引
accDescr: 初始化先考虑能否做成编译时的静态初始化,不能的话以推迟到首次使用为基本方针,只把必须作为加载失败尽早检测的部分最小限度地留在 DllMain
q1{"编译时能确定吗?"} -->|"能"| s["做成静态初始化"]
q1 -->|"不能"| q2{"失败必须在加载时检测吗?"}
q2 -->|"不必"| lazy["推迟到首次使用(基本方针)"]
q2 -->|"必须"| min["只在 DllMain 做最小限度"]
lazy -.-> once["用 INIT_ONCE 或函数内 static 互斥"]
图7:先考虑静态初始化和延迟初始化,DllMain 里只留下必须尽早检测的最小限度处理。
5.2 延迟初始化要设计到“最初从哪里调用”
首次使用时的互斥,可以用 INIT_ONCE 的一次性初始化,或者 C++ 的函数内 static(magic static)。思路是把复杂的全局对象,改成首次访问时创建的指针,或者改成函数内 static。
不过,仅仅推迟并不等于走出了加载器锁。只有当首次访问发生在“DLL 加载完成之后被调用的普通 API”中,才能摆脱这个限制。这时就可以按能使用几乎全部 Windows API 的普通初始化处理来设计。1
反过来,如果从 DllMain 或静态初始化器中发起这次首次访问,初始化处理最终仍在加载器锁下执行。不要只把它抽成一个初始化函数,还要确认 谁在什么时候第一次调用它。
flowchart TB
accTitle: 延迟初始化走出加载器锁的条件
accDescr: 延迟初始化的首次访问若发生在加载完成后的普通 API 中就能在加载器锁之外完成初始化,若发生在 DllMain 或静态初始化器中则仍在同样的限制下执行
first{"首次访问来自哪里?"}
first -->|"加载完成后的普通 API"| outside["在加载器锁之外初始化"]
first -->|"DllMain 或静态初始化器"| inside["在同样的限制下初始化"]
outside --> once["用 INIT_ONCE 或函数内 static 互斥"]
inside --> move["把首次访问的时点也移走"]
图8:INIT_ONCE 和函数内 static 负责初始化的互斥,但并不保证调用发生在加载器锁之外。
5.3 DisableThreadLibraryCalls 用三个条件判断
对不依赖线程通知的 DLL,可以在 DLL_PROCESS_ATTACH 中调用 DisableThreadLibraryCalls,停掉 DLL_THREAD_ATTACH / DLL_THREAD_DETACH 通知。在频繁创建线程的进程中,可以减少通知的开销。4
应用之前,要确认 静态 CRT、静态 TLS,以及有没有使用通知的处理。与静态 CRT 链接的 DLL 中,CRT 自身需要线程通知,因此不得调用。由 thread_local 或 __declspec(thread) 形成的静态 TLS 生效时,调用本身会失败并返回 FALSE。4
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["不调用,照常处理通知"]
图9:区分静态 CRT 下不用与静态 TLS 下会失败这两种情况,通知不需要时也要检查返回值。
这是在使用动态链接 CRT 并满足这些条件的常见 DLL 上才考虑的优化。DisableThreadLibraryCalls 是用来减少通知的,不是让你能在 DllMain 里做复杂初始化的手段。
6. 退出处理——区分 DLL 卸载与进程退出
6.1 同样是 DLL_PROCESS_DETACH,之后留下的东西并不相同
DLL_PROCESS_DETACH 在只卸载 DLL 时和整个进程退出时都会被调用。通知名相同,但清理的前提并不相同。5
通过 FreeLibrary 卸载时,进程之后还会继续运行。 因此必须妥善处理线程的停止、打开的句柄、申请的资源、需要持久化的状态等收尾工作。不过正如 3.2 所见,为此在 DllMain 内等待线程自然结束就会死锁。5
从根本上说,应避免让可能被卸载的 DLL 自己持有线程,把线程的所有权靠向 EXE 一侧最为安全。对于让 DLL 持有线程的既有设计,要把下面的停止协议和它的限制放在一起考虑,不要拆开看。
6.2 DLL 持有工作线程时,官方记载的停止协议
微软的最佳实践把卸载时的停止步骤写成了一套协议:等待的不是 “线程自然结束”,而是“已进入一致状态的信号”。5
- DllMain 一侧用事件向工作线程发出结束信号。
- 工作线程把当前的活收拢到一致状态,发出完成信号后进入无限等待。
- DllMain 一侧确认一致状态后,用
TerminateThread结束该线程。
这个步骤看起来粗暴,但它是在“等待自然结束就会让结束通知与加载器锁相撞”这一限制之下写进文档的。把状态收拢到一致的处理,也以遵守与 DllMain 相同的限制为前提。如果那段处理进入了加载其他 DLL 或等待加载器锁的状态,就无法避免与等待信号的一方发生死锁。5
sequenceDiagram
accTitle: 卸载时等待一致完成而非自然结束的步骤
accDescr: DllMain 向工作线程发出停止信号,工作线程遵守与 DllMain 相同的限制做出一致状态,回发信号后进入无限等待,DllMain 确认一致状态后结束该线程
participant D as DllMain(卸载时)
participant W as 工作线程
D->>W: 用事件发出结束信号
W->>W: 遵守同样的限制进入一致状态
W->>D: 发出一致完成的信号
W->>W: 进入无限等待
D->>W: 确认一致后 TerminateThread
Note over D,W: 等的是一致完成的信号,而不是自然结束
图10:即使采用这个协议,也仍需要让制造一致状态的处理不去等待加载器锁。
这并不是说可以就这样切断任何正在运行的线程。首先要考虑能不能把线程放到 DLL 之外持有。
6.3 进程退出时,什么都不做直接返回才是理想
进程退出时 DLL_PROCESS_DETACH 被调用的时候,其他线程已经结束或被强制结束,地址空间的一致性指望不上。依赖的 DLL 和运行时的状态也靠不住,释放内存这类收尾反而变得危险。官方也表示,这种情况下理想的处理函数是空的。5
需要持久化的数据,要在应用本来的退出处理中先写出。 不要把 DLL_PROCESS_DETACH 当成最后什么都能收拾的地方。
flowchart TB
accTitle: DLL 卸载与进程退出的收尾前提不同
accDescr: 只卸载 DLL 时进程还会继续运行所以需要收尾资源但要避免在 DllMain 内等待自然结束,进程退出时不能指望其他线程和资源的一致性所以基本什么都不做直接返回,要保存的状态在应用的退出处理中先写出
reason{"DLL_PROCESS_DETACH 的原因是什么?"}
reason -->|"只卸载 DLL"| alive["进程之后还会运行"]
alive --> cleanup["正确收尾剩余的资源"]
cleanup -.-> nojoin["不在 DllMain 内等待自然结束"]
reason -->|"进程退出"| processEnd["其他线程与资源的状态不确定"]
processEnd --> empty["基本什么都不做直接返回"]
empty -.-> save["保存工作在应用的退出处理中先做"]
图11:不只判断“是否需要收尾”,还要分开判断进程是否继续存活、收尾所用的资源能否信任。
7. 挂起的调查——找出等待加载器锁的一方和持有它的一方
7.1 用转储确认互相等待的线程组合
获取卡住瞬间的转储,确认每条线程的栈。典型的指纹是这样一组:在 ntdll.dll 中以 Ldr 开头的加载器函数内等锁的线程,和 在 DllMain 或静态初始化器(dynamic initializer)中等待别的东西的线程。
停在 LoadLibrary 调用过程中的线程也是典型角色。只要能把等锁的一方与持锁等待其他东西的一方之间的关系串起来,基本就能判断是与加载器锁相关的死锁。
flowchart TB
accTitle: 从挂起的转储确认加载器锁的互相等待
accDescr: 在每条线程的栈中寻找 Ldr 系的等锁与 DllMain 或静态初始化器内的等待,确认两者互相等待的关系,看不到典型组合时也要调查其他类型的挂起
dump["获取挂起中的转储"] --> stacks["确认每条线程的栈"]
stacks --> ldr["在 Ldr 系函数中等锁"]
stacks --> init["在 DllMain 或静态初始化器内等待"]
ldr --> pair{"互相等待的关系能串起来吗?"}
init --> pair
pair -->|"能"| found["加载器锁的死锁"]
pair -->|"看不到"| more["也调查其他类型的挂起"]
图12:不要只凭一条栈下结论,要看等待加载器锁的一方与持有它并等待的一方这一组合。
“启动时偶尔出现”“只在特定机器上出现”“只在作为服务运行时出现”这类条件也是线索。互相等待只在 DLL 加载与线程开始、结束等重叠的时机才成立,因此表面化的方式会随环境差异和执行时机而变化。
7.2 用 Application Verifier 与被调用方评审来预防
启用 Application Verifier 并跑测试,可以在运行时检测出 DllMain 内的典型错误。1 在 C++/CLI 中不要忽视警告 C4747,警告检测不到的间接路径也要按 4.2 的视角确认。
评审时,要在从 DllMain 可达的处理中寻找有没有 间接调用 LoadLibrary 的函数。COM 初始化、部分 CRT 功能、延迟加载(delay-load)导入等都是确认对象。尤其是延迟加载的导入函数,首次调用在内部会变成 LoadLibrary 这一点容易被漏掉。即使函数名出现在 DllMain 之外,只要调用方是 DllMain,这个限制依然存在。
8. 小结——不看函数名,而看“何时、在哪把锁下运行”
DllMain 的限制,可以从 它是在持有进程共用的加载器锁的状态下被调用的 这一点来理解。不只是自己的 DllMain,CRT 的静态初始化与退出处理、C++/CLI 的间接调用也都在同一范围内。
重新审视时,先从初始化能不能做成静态的、其余能不能推迟到加载完成之后开始。要确认推迟之后的首次访问位置;如果不需要通知,再看静态 CRT、静态 TLS 的条件来考虑 DisableThreadLibraryCalls。
退出一侧,要区分只卸载 DLL 与整个进程退出。DLL 持有工作线程时,遵守发出停止信号、确认一致状态、结束线程的协议,可能的话把线程的所有权靠向 EXE 一侧。进程退出时的 DllMain 要尽量接近空,数据保存放到应用本来的退出处理中。
flowchart TB
accTitle: 重新审视 DllMain 及其被调用方的顺序
accDescr: 不只看 DllMain,还要把静态初始化和间接调用纳入范围来调查,把初始化移到静态或加载完成之后,按退出原因设计停止与收尾,再用 Application Verifier 和转储确认
scope["DllMain、静态初始化与被调用方"] --> timing["把初始化移到静态或加载完成后"]
timing --> first["确认推迟之后的首次访问"]
first --> shutdown["按退出原因设计收尾"]
shutdown --> verify["用 Verifier、警告和转储确认"]
图13:不只看初始化的处理内容,还要追到它的执行时点和退出时的生存期,就能把禁止事项当作一条统一的设计方针来对待。
遇到边界情况时要问的是:“这是可以在持有加载器锁的状态下执行的活吗”。不要把拿不准的初始化塞进 DllMain,而是改变它的执行时点——这正是安全的 DLL 设计的起点。
相关文章
- Windows DLL 名称解析的机制 - 搜索顺序与 SxS
- 用 C++/CLI 包装原生 DLL 的实务
- 多线程实务最佳实践 C++ 篇
- 虚假唤醒 —— 条件变量为何会“未被通知就醒来”,以及在 Windows 上正确等待的方法
- 用 WinDbg + SOS 读崩溃转储 —— 采集之后的实务解析入门
- COM STA/MTA 的基础知识 - 线程模型与避免挂起的思路
相关咨询领域
小村软件有限公司承接启动时与 DLL 加载时的挂起、死锁的原因调查(转储分析),DllMain 与静态初始化相关的设计评审,以及把 C++/CLI 包装器和插件 DLL 改造为安全初始化设计的工作。即使还处在“只在特定环境下启动卡住”这种难以复现的阶段,也欢迎咨询。
参考链接
-
Microsoft Learn, Dynamic-Link Library Best Practices. 关于 DllMain 在持有加载器锁期间被调用,因此可调用的函数受到重大限制;理想的 DllMain 是空的桩函数,初始化应尽可能推迟;推荐编译时的静态初始化;只把需要尽早检测的失败最小限度地处理掉;以及用 Application Verifier 检测 DllMain 内的典型错误。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, DllMain entry point. 关于入口点只应做简单的初始化与退出处理;不得调用 LoadLibrary / FreeLibrary 的理由(加载顺序的循环,以及在初始化前、退出后使用 DLL);Kernel32.dll 一定已加载,因此可以在不加载其他 DLL 的范围内调用;安全函数的穷尽列表并不存在;User、Shell、COM 函数会招致访问违规;DLL 通知被串行化,因此与其他线程、其他进程通信会招致死锁;与 CRT 链接时同样的限制也适用于静态对象的构造函数与析构函数。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Initialization of Mixed Assemblies. 关于不得在加载器锁下执行 MSIL;不得把 DllMain 及其调用树编译成 MSIL,应用 #pragma unmanaged 应对;DllMain 直接执行 MSIL 时会给出警告 C4747,但经由其他模块的间接执行检测不到;以及静态对象的动态初始化器也会引发同样的问题。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). 关于可以禁用 DLL_THREAD_ATTACH / DLL_THREAD_DETACH 通知以减少线程创建、销毁时的开销;不得从与静态 CRT 链接的 DLL 调用;以及静态 TLS(thread_local 或 __declspec(thread))生效时不会执行该优化。 ↩ ↩2 ↩3
-
Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. 关于在 DllMain 内等待线程结束就会死锁的结构(线程结束的 DLL_THREAD_DETACH 通知需要加载器锁);卸载时的线程停止协议(用事件发信号,确认一致状态后再结束);进程退出时的 DLL_PROCESS_DETACH 中其他线程已被强制结束、地址空间的一致性没有保证,因此理想的处理函数是空的;以及在 DllMain 中创建线程会让通知在初始化未完成的情况下滞留并引发问题。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
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 上正确的等待写法
条件变量的 wait 即使没有收到通知也可能返回(虚假唤醒)。本文从 Windows 的实现出发说明规范为何允许这一点,并用 Win32、C++ 和 C# 的代码给出以 while 循环和谓词编写的正确等待写法。
参数为什么会坏掉 ── Windows 命令行参数的规则
在 Windows 上并不存在参数的数组,传给 CreateProcess 的是一条字符串,切分由接收方完成。本文讲解 CommandLineToArgvW・CRT・.NET 的切分规则,以及 .NET 的 ArgumentList 与 C++ 中正确的组装方法。
父进程消失之后还剩下什么 —— 用 Job Object 圈养子进程
为什么强制结束 UI 之后,SDK 的辅助进程仍然残留,一直占着摄像头或 COM 端口?本文从测量应用的视角,讲解如何用 Job Object 把进程树变成一个单位,并借助 KillOnJobClose 与完成端口来设计子进程的寿命。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
常见问题
汇总了咨询这一主题时常见的问题。
- 在 DllMain 里真的什么都不能做吗?
- “什么都别做”不是夸张,而是官方的设计方针,微软自己也说理想的 DllMain 是接近空壳的桩函数。安全的只有 Kernel32.dll 中不加载其他 DLL 的那部分函数——DllMain 执行时 Kernel32.dll 一定已经加载。例如创建临界区或互斥体、使用 TLS 都可以。反过来,LoadLibrary/FreeLibrary、与其他线程同步,以及调用 User32、Shell、COM 等函数,会导致死锁或访问违规,因此被禁止。拿不准的初始化,正确做法是“不要在 DllMain 里做,推迟到第一次被使用时”。
- C++ 全局变量(静态对象)的构造函数也受 DllMain 的限制吗?
- 会受限制。当 DLL 与 CRT(C++ 运行时)链接时,全局和静态对象的构造函数与析构函数会经由 CRT 提供的入口点运行,实际上属于 DllMain 的一部分。也就是说,在构造函数里调用 LoadLibrary、启动其他线程并等待其完成、初始化 COM 等,都与直接写在 DllMain 里一样危险。对于带有复杂初始化的全局对象,请改成保存指针并在首次访问时创建,或者改用函数内 static,把执行时机移到 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 之外完成处理”的协议。