动态链接库最佳实践
相关 API:
创建DLL给开发者呈现了很多挑战。DLL没有系统强制(system-enforced)的版本(versioning)。当系统中存在多个版本的DLL时,容易被覆盖加上缺少版本模式,产生了依赖和API冲突。开发环境、加载器(loader)实现以及DLL依赖的复杂度已经创建了加载顺序和应用程序行为的脆弱(fragility)。近来,许多程序依赖DLL,且拥有许多复杂的依赖,以致程序必须重视才能正确执行。这篇文档提供了指南,帮助开发者构建更健壮、更便携和更易于扩展的DLL。
DllMain中不适当的同步可能会造成程序死锁,或者是在未经初始化的DLL中访问数据或代码的时候。在DllMain中调用某些函数会造成这种问题。
通常的最佳实践
DllMain被调用时,loader-lock会被锁住。因此,可以在DllMain被调用的函数被加上了重要的限制。同样地,DllMain旨在通过使用一小部分Windows API子集来执行执行最小的初始化任务。你不能在DllMain中调用任何函数直接或间接获取loader lock,否则将带来程序死锁或崩溃的可能。一个在DllMain实现代码中的错误可能危及整个进程和所有线程。
理想的DllMain应该是一个空实现(empty stub)。然而,考虑到许多程序的复杂性,这通常太苛刻了。对于DllMain,一个好的经验法则就是延迟尽可能多的初始化。懒初始化增加了程序的健壮性,因为当loader lock被锁住的时候,初始化没有执行。另外,懒初始化允许你安全地使用更多的Windows API。
一些初始化操作是不能被延迟的。比如,一个依赖于配置文件的DLL在文件格式不正确,或者包含垃圾信息时,应该加载失败。对于这种类型的初始化,Dll应该尝试执行这个操作,如果出现错误,尽快停止加载,而不是完成其他工作,造成资源浪费。
你不应该在DllMain中执行以下任务:
- 调用LoadLibrary或LoadLibraryEx(直接或间接)。这可能会造成死锁或崩溃。
- 调用GetStringTypeA,GetStringTypeEx或者GetStringTypeW(直接或间接)。这可能会造成死锁或崩溃。
- 与其他线程同步。这可能会造成死锁。
- 获取一个某段代码持有的同步对象,同时这段代码等待获取loader lock。这可能会造成死锁。
- 使用CoInitializeEx初始化COM线程。在某种条件下,函数会调用LoadLibraryEx。
- 调用注册表函数,这些函数在Advapi32.dll中实现。如果调用前,Advapi32.dll没有被初始化,那么你的DLL会访问未初始化的内存,并导致进程崩溃。
- 调用CreateProcess。创建一个进程可能会加载其他DLL。
- 调用ExitThread。当DLL detach的时候,退出线程可能会再次获取load locker,从而导致死锁或崩溃。
- 调用CreateThread。如果你不与其他线程同步,那么创建一个线程有效(work),但有风险。
- 创建一个命名管道或命名对象(旨在Windows 2000中)。在Windows 2000中,命名对象由终端服务DLL提供,如果这个DLL没有初始化,调用到这个DLL的函数会造成进程崩溃。
- 使用CRT的内存管理函数。如果CRT DLL没有初始化,那么这些调用会造成进程崩溃。
- 调用User32.dll或Gdi32.dll中的函数。一些函数会加载其他DLL,而这个DLL可能没有初始化。
- 使用托管代码(managed code)。
在DllMain中执行以下任务是安全的:
- 在编译时初始化静态数据结构体或成员。
- 创建同步对象。
- 分配内存并初始化动态数据结构(避免上面列出的函数)。
- 设置TLS。
- 打开,读取和写文件。
- 调用Kernel32.dll中的函数(除了上面列出的函数)。
- 把全局指针设置为NULL,推迟动态成员的初始化。在Windows Vista中,你可以使用一次性初始化函数来保证代码块在多线程环境中只执行1次。
由锁定顺序颠倒(Lock order inversion)造成的死锁
当你正在编写的代码中使用了多个同步对象,例如锁,锁定顺序就很有必要重视。当在某个时刻必须获取多个锁时,你必须定义一个显式的优先顺序,也叫做锁层次或锁的顺序。例如,如果在某段代码中,锁A在锁B之前获取,同时,在另一段代码中锁B在锁C之前获取,那么锁的顺序就是A,B,C。你所有的代码,都应该遵循这个顺序。当这个顺序没有被遵循时,就会出现锁定顺序颠倒——例如,如果锁B在锁A之前获取。锁定顺序颠倒可能会引起死锁,这很难调试。想要避免这类问题,所有线程必须以相同的顺序获取锁。有一个很重要的点需要注意,loader调用DllMain,且在调用前已经获取了loader lock,所以loader lock应该在锁层次中拥有最高优先级。还要注意,代码只应获取需要的锁来做恰当的同步,代码不必获取每一个锁层次中的锁。例如,如果一段代码只需要锁A和锁C就可以做同步,那么代码应该先获取锁A再获取锁C,它没有必要也去获取锁B。此外,DLL代码不能显式的获取loader lock。如果代码必须调用一个API,比如GetModuleFileName(这个函数间接获取了loader lock),且代码必须获取一个私有锁,那么代码中应该先调GetModuleFileName,再获取锁P,因此,保证锁的顺序被遵循。
图2展示了锁定顺序颠倒的例子。考虑一个DLL,它的主线程包含DllMain。这个库的loader先获取loader lock,再调DllMain。主线程创建同步对象A,B和G来顺序化对数据结构的访问,然后尝试获取锁G。一个工作线程已经成功获取了锁G,然后调用一个函数,例如GetModuleHandler,尝试获取loader lock L。因此,工作线程被锁L阻塞,主线程被锁F阻塞,引起死锁。
为了防止由锁定顺序颠倒而造成的死锁,所有线程在任何时候都应尝试以定义好的锁的顺序来获取同步对象。
同步的最佳实践
考虑这样一个DLL,它创建工作线程作为初始化的一部分。在DLL cleanup的时候,必须和其他所有工作线程同步,以确保数据结构状态一致,接着结束工作线程。今天,没有一种直接的方法可以解决在多线程环境下干净的同步和关闭DLL这个问题。这一小节描述当前的在DLL关闭时做线程同步的最佳实践。
进程结束时,DLLMain中的线程同步
- 在进程结束,DLLMain被调用的时候,所有进程的线程被强制清理,然后存在一种地址空间不一致的可能。在这个例子中,同步不是必须的。换句话说,理想的DLL_PROCESS_DETACH处理器应该为空。
- Windows Vista保证核心数据结构(环境变量,当前目录,进程堆等等)都在一致的状态。然而,其他数据结构可能被破坏,所以清理内存不安全。
- 需要保存的持久状态必须刷新(flush)到持久存储中。
DLL被卸载时,DLLMain中为DLL_THREAD_DETACH做的线程同步
- 当DLL被卸载时,地址空间没有被丢弃。因此,DLL被期望去执行一个干净的清理(shutdown)。这包括线程同步对象,打开的句柄,持久状态和分配的资源。
- 线程同步很难办,因为等待一个线程,然后再退出DllMain可能造成死锁。例如,DLL A持有一个loader lock。他发信号,让线程t退出,然后等待线程退出。线程T退出,然后loader尝试获取loader lock,从而调用DLL A的DllMain,带DLL_THREAD_DETACH做参数。这就造成死锁。为了最小化死锁的风险:
- DLL A在DllMain中获取DLL_THREAD_DETACH消息时,为线程T设置一个事件,通知它退出。
- 线程T结束当前任务,回到一致状态(consistent state),通知DLL A,然后无限等待。注意,一致检查的过程(routines)应该遵循同样的限制,让DllMain避免死锁。
- DLL A在知道t处于一致状态后,终结它。
如果一个DLL在所有线程被创建后被卸载,且这些线程都没有被执行,线程可能会崩溃(crash)。如果DLL在DllMain中创建线程,把这作为初始化的一部分,一些线程可能不能完成初始化,且他们的DLL_THREAD_ATTACH消息会一直等待被投递到DLL中。在这种情况下,如果DLL被卸载,它将终结所有线程。然后一些线程可能会被loader lock阻塞。他们的DLL_THREAD_ATTACH消息会在DLL已经卸载之后被处理,从而造成进程崩溃。
建议
以下是一些建议准则:
- 使用Application Verifier来捕获DllMain中最常见的错误。
- 如果在DllMain中使用私有锁,定义一个锁层次,并一致的使用它。loader lock必须在层次的底部。
- 验证没有调用依赖另一个可能还没完全加载的DLL。
- 在编译时执行简单的静态的初始化,而不是在DllMain中。
- 推迟DllMain中的任何可以延迟的调用。
- 推迟可以被延迟的初始化任务。某些错误条件必须要早些检测,以便程序可以优雅的处理这些错误。
不管怎样,早期检测和丢失健壮性(由前者引起)需要进行权衡。推迟初始化通常是最佳选择。