动态链接库最佳做法
**更新时间:**
- 2006 年 5 月 17 日
重要的 API
创建 DLL 为开发人员带来了许多挑战。 DLL 没有系统强制执行的版本控制。 当系统上存在多个版本的 DLL 时,由于很容易被覆盖,且缺少版本控制架构,会导致产生依赖项和 API 冲突。 开发环境中的复杂性、加载器实现和 DLL 依赖项造成了加载顺序和应用程序行为方面的脆弱性。 最后,许多应用程序依赖于 DLL,并且具有复杂的依赖项集,应用程序必须遵循它们才能正常运行。 本文档为 DLL 开发人员提供了指南,帮助构建更可靠、可移植和可扩展的 DLL。
DllMain 中的不当同步可能会导致应用程序在未初始化的 DLL 中死锁或访问数据或代码。 从 DllMain 中调用某些函数会导致此类问题。
常规最佳做法
DllMain 在加载器锁被持有时调用。 因此,对可以在 DllMain 中调用的函数施加了重大限制。 因此,DllMain 旨在通过使用 Microsoft® Windows® API 的一小部分来执行最小的初始化任务。 不能调用 DllMain 中直接或间接尝试获取加载器锁的任何函数。 否则,将引入应用程序死锁或崩溃的可能性。 DllMain 实现中的错误可能会危及整个进程及其所有线程。
理想的 DllMain 只是一个空存根。 但是,鉴于许多应用程序的复杂性,这通常过于严格。 DllMain 的一个很好的经验法则是尽可能地推迟初始化。 延迟初始化会增加应用程序的稳定性,因为加载器锁被持有时不会执行此类初始化。 此外,延迟初始化使你能够安全地使用更多 Windows API 功能。
某些初始化任务无法推迟。 例如,如果文件格式不正确或包含垃圾,则依赖于配置文件的 DLL 应无法加载。 对于这种类型的初始化,DLL 应会尝试操作并快速失败,而不是通过完成其他工作来浪费资源。
不应从 DllMain 中执行以下任务:
- 调用 LoadLibrary 或 LoadLibraryEx(直接或间接)。 这可能会导致死锁或崩溃。
- 调用 GetStringTypeA、GetStringTypeEx 或 GetStringTypeW(直接或间接)。 这可能会导致死锁或崩溃。
- 与其他线程同步。 这可能会导致死锁。
- 获取由等待获取加载器锁的代码拥有的同步对象。 这可能会导致死锁。
- 使用 CoInitializeEx 初始化 COM 线程。 在某些情况下,此函数可以调用 LoadLibraryEx。
- 调用注册表函数。
- 调用 CreateProcess。 创建进程时可能会加载另一个 DLL。
- 调用 ExitThread。 在 DLL 分离期间退出线程可能会导致加载器锁再次被获取,从而导致死锁或崩溃。
- 调用 CreateThread。 如果不与其他线程同步,则创建线程可以正常工作,但存在风险。
- 调用 ShGetFolderPathW。 调用 shell/已知文件夹 API 可能会导致线程同步,因此可能会导致死锁。
- 创建命名管道或其他命名对象(仅限 Windows 2000)。 在 Windows 2000 中,命名对象由终端服务 DLL 提供。 如果未初始化此 DLL,则对 DLL 的调用可能会导致进程崩溃。
- 使用动态 C 运行时 (CRT) 中的内存管理功能。 如果未初始化 CRT DLL,则对这些函数的调用可能会导致进程崩溃。
- 调用 User32.dll 或 Gdi32.dll 中的函数。 某些函数会加载另一个 DLL,该 DLL 可能无法初始化。
- 使用托管代码。
可以在 DllMain 中安全地执行以下任务:
- 在编译时初始化静态数据结构和成员。
- 创建和初始化同步对象。
- 分配内存并初始化动态数据结构(避免上面列出的函数)。
- 设置线程本地存储 (TLS)。
- 打开、读取和写入文件。
- 调用 Kernel32.dll 中的函数(上面列出的函数除外)。
- 将全局指针设置为 NULL,从而推迟动态成员的初始化。 在 Microsoft Windows Vista™ 中,可以使用一次性初始化函数来确保在多线程环境中只执行一次代码块。
锁顺序反转导致的死锁
实现使用多个同步对象(如锁)的代码时,必须遵循锁顺序。 如果需要一次获取多个锁,则必须定义一个称为锁层次结构或锁顺序的显式优先级。 例如,如果在代码中的某个位置在锁 B 之前获取了锁 A,并在代码中的其他位置在锁 C 之前获取了锁 B,则锁顺序为 A、B、C,并且应在整个代码中遵循此顺序。 锁顺序反转发生在未遵循锁顺序时,例如,如果在锁 A 之前获取了锁 B。锁顺序反转可能会导致难以调试的死锁。 为了避免此类问题,所有线程必须按相同的顺序获取锁。
请务必注意,加载器使用已获取的加载器锁调用 DllMain,因此加载器锁在锁层次结构中应具有最高优先级。 另请注意,代码只需要获取正确同步所需的锁,它不必获取层次结构中定义的每个锁。 例如,如果代码的某个部分只需锁 A 和 C 就能进行正确同步,则代码应在获取锁 C 之前获取锁 A,且代码不需要也获取锁 B。此外,DLL 代码无法显式获取加载器锁。 如果代码必须调用可以间接获取加载器锁的 API(例如 GetModuleFileName),并且代码还必须获取专用锁,则代码应在获取锁 P 之前调用 GetModuleFileName,从而确保遵守加载顺序。
图 2 是说明锁顺序反转的示例。 假设有一个主线程包含 DllMain 的 DLL。 库加载器会获取加载器锁 L,然后调用 DllMain。 主线程会创建同步对象 A、B 和 G 以序列化对其数据结构的访问,然后尝试获取锁 G。已成功获取锁 G 的工作线程随后调用尝试获取加载器锁 L 的函数,例如 GetModuleHandle。因此,工作线程在 L 上被阻止,主线程在 G 上被阻止,从而导致死锁。
要防止由锁顺序反转导致的死锁,所有线程都应始终尝试按定义的加载顺序获取同步对象。
同步最佳做法
假设有一个 DLL 会在初始化中创建工作线程。 DLL 清理后,必须与所有工作线程同步,以确保数据结构处于一致状态,然后终止工作线程。 目前,无法完全直接解决在多线程环境中干净地同步和关闭 DLL 的问题。 本部分介绍在 DLL 关闭期间线程同步的当前最佳做法。
进程退出期间 DllMain 中的线程同步
- 在进程退出时调用 DllMain 时,所有进程的线程都已被强行清理,并且地址空间有可能不一致。 在这种情况下,不需要同步。 换句话说,理想的 DLL_PROCESS_DETACH 处理程序为空。
- Windows Vista 会确保核心数据结构(环境变量、当前目录、进程堆等)处于一致状态。 但是,其他数据结构可能会损坏,因此清理内存并不安全。
- 需要保存的持久状态必须刷新到永久存储。
DLL 卸载期间 DLL_THREAD_DETACH 的 DllMain 中的线程同步
- 卸载 DLL 时,不会丢弃地址空间。 因此,DLL 应执行干净关闭。 这包括线程同步、打开的句柄、持久状态和分配的资源。
- 线程同步很棘手,因为等待线程在 DllMain 中退出可能会导致死锁。 例如,DLL A 持有加载器锁。 它指示线程 T 退出,并等待线程退出。 线程 T 退出,加载器尝试获取加载器锁,以使用 DLL_THREAD_DETACH 调用 DLL A 的 DllMain。 这会导致死锁。 要将死锁的风险降到最低:
如果 DLL 在其所有线程创建后且它们开始执行前被卸载,则这些线程可能会崩溃。 如果 DLL 在其 DllMain 中作为初始化的一部分创建了线程,则某些线程可能尚未完成初始化,并且其 DLL_THREAD_ATTACH 消息仍在等待被传递到 DLL。 在这种情况下,如果卸载 DLL,它将开始终止线程。 但是,某些线程可能会被挡在加载器锁后面。 它们的 DLL_THREAD_ATTACH 消息会在取消映射 DLL 后处理,从而导致进程崩溃。
建议
建议遵循以下准则:
- 使用应用程序验证工具捕获 DllMain 中最常见的错误。
- 如果在 DllMain 中使用专用锁,请定义锁层次结构并一致地使用它。 加载器锁必须位于此层次结构的底部。
- 验证是否没有任何调用依赖于另一个可能尚未完全加载的 DLL。
- 在编译时静态执行简单初始化,而不是在 DllMain 中执行。
- 推迟 DllMain 中任何可以稍后再进行的调用。
- 推迟可以稍后再进行的初始化任务。 必须尽早检测到某些错误条件,以便应用程序可以正常处理错误。 但是,这种早期检测与可靠性丢失之间存在权衡,前者可能会导致后者。 推迟初始化通常是最好的。