原文链接
创建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次。当你正在编写的代码中使用了多个同步对象,例如锁,锁定顺序就很有必要重视。当在某个时刻必须获取多个锁时,你必须定义一个显式的优先顺序,也叫做锁层次或锁的顺序。例如,如果在某段代码中,锁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关闭时做线程同步的最佳实践。
如果一个DLL在所有线程被创建后被卸载,且这些线程都没有被执行,线程可能会崩溃(crash)。如果DLL在DllMain中创建线程,把这作为初始化的一部分,一些线程可能不能完成初始化,且他们的DLL_THREAD_ATTACH消息会一直等待被投递到DLL中。在这种情况下,如果DLL被卸载,它将终结所有线程。然后一些线程可能会被loader lock阻塞。他们的DLL_THREAD_ATTACH消息会在DLL已经卸载之后被处理,从而造成进程崩溃。
以下是一些建议准则:
使用Application Verifier来捕获DllMain中最常见的错误。如果在DllMain中使用私有锁,定义一个锁层次,并一致的使用它。loader lock必须在层次的底部。验证没有调用依赖另一个可能还没完全加载的DLL。在编译时执行简单的静态的初始化,而不是在DllMain中。推迟DllMain中的任何可以延迟的调用。推迟可以被延迟的初始化任务。某些错误条件必须要早些检测,以便程序可以优雅的处理这些错误。不管怎样,早期检测和丢失健壮性(由前者引起)需要进行权衡。推迟初始化通常是最佳选择。
