深入理解CLR类加载机制
1 CLR加载器
CLR加载器负责装载和初始化程序集、模块、资源和类型。CLR加载器加载尽可能少的这些资源。不像Win32加载器,CLR加载器不会解析和自动加载子模块或程序集。相反,子模块只有当它们真正需要的时候,才进行加载。这不仅缩短了程序初始化时间,而且减少了运行程序消耗的资源。
在CLR,加载一般是基于类型且由JIT触发。当JIT编译器尝试将一个方法从公共中间语言编译成机器码,它需要使用声明的类型的类型定义和该类型的字段定义。此外,JIT编译器还需要使用由任何被JIT正在编译的方法的本地变量或参数使用的类型定义。装载一个类型,意味着装载包含类型定义的程序集和模块。
按需装载类型的策略,意味着程序中那些没有被使用的部分代码将从不被装载到内存。它也意味着一个运行中的应用程序将经常搜索新的被加载的新的程序集和模块,这些程序集和模块是在执行过程中随时间的推移包含了需要的类型的那些文件。如果这不是你需要的功能,你有两个选择。一个选择是简单的声明那些类型的隐藏字段,这些类型是你想要确保当你的类型被加载时需要一块加载的。另一个选择是显式的使用加载器。
加载器通常是根据你的行为隐式的执行功能。开发人员可以通过程序集加载器显式的使用加载器。程序集加载器通过在System.Reflection.Assembly类的LoadFrom静态方法暴露给开发人员的。这个方法接受一个CODEBASE字符串,它可以是一个文件系统路径或者一个标识在程序集清单包含的模块的URL。如果指定的文件不存在,则装载器将抛出一个System.FileNotFoundException一场。如果指定的文件存在但不是一个包含在程序集清单的CLR模块,装载器将抛出一个System.BadImageFormatException一场。最后,如果CODEBASE是一个使用一个非“file:”Scheme的URL,那么调用者必须具有WebPermission的访问权限,否则一个System.SecurityException异常将被抛出。此外,使用不是“file:”协议的URLs的程序集将在加载之前被加载到本地download缓存。
表2.2显示一个简单的C#程序,这个程序从file://C:/user/bin/xyzzy.dll加载一个程序集,然后创建一个包含AcmeCorp.LOB.Customer的类型的实例。调用者提供的只是程序集的物理路径。当一个程序以这种方式使用程序集加载器,那么CLR将忽略程序集由4部分组成的名字,包括版本编号。
表2.2 使用显式的CODEBASE装载一个程序集
using System; using System.Reflection; public class Utilities { public static Object LoadCustomerType() { Assembly a = Assembly.LoadFrom( "file://C:/usr/bin/xyzzy.dll"); return a.CreateInstance("AcmeCorp.LOB.Customer"); } }
虽然使用路径装载程序集有点意思,不过大多数程序集是利用程序集解析器使用名称加载。程序集解析器使用由4部分组成的名称来确定哪个文件被程序集加载器加载到内存。如图2.9所示,这个名字到路径的解析过程考虑了一序列的因素,包括宿主的应用程序的路径,版本策略和其它详细的配置。
图2.9程序集解析和装载
程序集解析器通过System.Reflection.Assembly类的静态方法Load来暴露给开发人员。如表2.3所示,这个方法接受一个由4部分组成的名字(可以是一个字符串,或者是一个AssemblyName引用),且它从表面上看和LoadFrom方法相似,他们都由程序集加载器暴露的。实际上,二者的相似是肤浅的,因为Load方法将首先使用程序集解析器使用一序列相当复杂的操作查找一个合适的文件。这些操作的第一个是使用一个版本策略来精确的确定期待被装载的程序集的版本。
表2.3 使用程序集解析器装载一个程序集
using System; using System.Reflection; public class Utilities { public static Object LoadCustomerType() { Assembly a = Assembly.Load( "xyzzy, Version=1.2.3.4, " + "Culture=neutral, PublicKeyToken=9a33f27632997fcc"); return a.CreateInstance("AcmeCorp.LOB.Customer"); } }
程序集解析器由应用任何有效的版本策略开始解析。版本策略用来使程序集解析器将请求的程序集重新指向另一个版本。一个版本策略可以映射给定程序集的一个或多个版本到另一个版本。然而,一个版本策略不能将解析器重定向到一个名字不同的程序集。注意到版本策略仅用于那些完全由4个部分指定的程序集是很重要的。如果程序集名称仅指定一部分(如公钥、版本或文化丢失),那么将不应用版本策略。同时,如果直接调用Assembly.LoadFrom来绕开程序集解析器,那么也不会应用版本策略,因为你只是指定一个物理路径而不是一个程序集名称。
版本策略通过配置文件指定。这包括一个机器端配置文件和一个应用程序相关的配置文件。机器端配置文件名字总是为machine.config,它的位置在%SystemRoot%\Microsoft .NET \Framework\V1.0.nnnn\CONFIG文件夹。应用程序集相关的配置文件总是在程序的APPBASE文件夹。对于基于CLR的.EXE程序,APPBASE是装载的主执行程序的路径的URI。对于ASP.NET引用,APPBASE是Web应用程序的虚拟路径的跟路径。基于CLR的.EXE程序的配置文件的名字总是为可执行文件名称加上“.config”后缀。比如,如果运行的CLR程序是C:\myapp\app.exe,其对应的配置文件将是C:\myapp\app.exe.config。对于ASP.NET应用程序,配置文件总是为web.config。
配置文件是基于XML格式,且总是有一个configuration根节点。配置文件由程序集解析器、远程调用基础设施和ASP.NET使用。图2.10显示了用于配置程序集解析器的节点的基本结构。所有相关的节点都是在基于urn:schemas-microsoft-com:asm.v1名称空间的assemblyBinding节点。它还有控制探测路径和发布商版本模式的设置。此外,dependentAssembly节点用于指定每一个依赖的程序集的版本和位置。
图2.10 程序集解析器配置节点
表2.4显示了一个简单的配置文件,它包含了一个程序集的两个版本策略。第一个策略将版本1.2.3.4的程序集Acme.HealthCare重定向到1.3.0.0。第二个策略将1.0.0.0到1.2.3.399版本重定向到1.2.3.7。
表2.4 设置版本策略
<?xml version="1.0" ?> <configuration xmlns:asm="urn:schemas-microsoft-com:asm.v1"> <runtime> <asm:assemblyBinding> <!-- one dependentAssembly per unique assembly name --> <asm:dependentAssembly> <asm:assemblyIdentity name="Acme.HealthCare" publicKeyToken="38218fe715288aac" /> <!-- one bindingRedirect per redirection --> <asm:bindingRedirect oldVersion="1.2.3.4" newVersion="1.3.0.0" /> <asm:bindingRedirect oldVersion="1-1.2.3.399" newVersion="1.2.3.7" /> </asm:dependentAssembly> </asm:assemblyBinding> </runtime> </configuration>
版本策略可以从三个级别来指定:每一个应用,每一个组建和每一台机器。每一个基本都有机会来处理版本编号,它使用一个级别的结果作为相邻基本的输入进行处理。如图2.11所示。需要注意的是如果应用程序和机器的配置文件都有指定程序集的一个版本策略,那么应用程序的策略将先执行,然后产生的版本编号将在程序端的策略执行,最终产生实际的版本编号用于定位程序集。在这个例子,如果机器端配置文件重定向Acme.HealthCare的1.3.0.0版本到2.0.0.0版本,那么当请求1.2.3.4版本时程序集解析器将使用2.0.0.0版本,因为应用程序的版本策略映射1.2.3.4版本到1.3.0.0版本。
图2.11 版本策略
除了应用程序相关和机器端的配置设置外,一个给定的程序集还有一个发布商策略。一个发布商策略是组件开发者用于指定组件的哪一版本与另一兼容的描述。
发布商策略作为配置文件存储在机器端的全局程序集缓存。这些文件的结构与应用程序和机器端配置文件的结构完全相同。然而,为了在用于的机器安装,发布商策略配置文件必须作为一个自定义资源包装成一个程序集DLL。假设foo.config文件包含发布商配置策略,以下命令将调用程序集连机器AL.exe并为AcmeCorp.Code 2.0版本创建一个合适的发布商策略程序集。
al.exe /link:foo.config
/out:policy.2.0.AcmeCorp.Code.dll
/keyf:pubpriv.snk
/v:2.0.0.0
发布商策略文件遵循policy.major.minor.assmname.dll格式。由于该命名约定,一个给定的任一major.minor版本的程序集仅可以有一个发布商策略文件。在这个例子,所有对主版本2.0的AcmeCorp.Code的请求将通过策略文件路由链接到policy.2.0.AcmeCorp.Code.dll。如果在GAC不存在该程序集,那么就没有发布商策略。如图2.11所示,发布商策略在应用程序相关版本策略之后使用,但比机器端版本策略之前。
考虑到版本化的组件固有的脆弱性,CLR允许开发人员在基于应用程序端配置关闭发布商版本策略。为了达到这个目的,开发人员必须使用配置文件的publisherPolicy节点。表2.5显示了在简单配置文件的这样的节点。当这个节点有apply=”no”属性时,应用程序的发布商策略将被忽略。当这个属性被设置为apply=”yes”,或者根本没有指定时,发布商策略将如描述的被使用。正如图2.10所示,publisherPolicy节点可以在应用程序端或一个基于程序集的程序集来启动或禁止发布商策略。
表2.5 设置应用程序为安全模式
<?xml version="1.0" ?> <configuration xmlns:rt="urn:schemas-microsoft-com:asm.v1"> <runtime> <rt:assemblyBinding> <rt:publisherPolicy apply="no" /> </rt:assemblyBinding> </runtime> </configuration>
2 将名称解析为位置
当程序集解析器觉得了装载哪一个版本的程序集之后,它必须定位一个合适的文件来传递给底层的程序集加载器。CLR首先从DEVPATH操作系统环境变量指定的文件夹查找。这个环境变量一般在开发机器中没有被设置。相反的,它仅给程序员使用,并用于允许从共享文件目录加载延迟签名的程序集。此外,DEVPATH环境变量仅在以下XML配置文件节点存在machine.config时才被考虑。
<configuration> <runtime> <developmentMode developerInstallation="true" /> </runtime> </configuration>
因为DEVPATH环境变量并不用于部署,以下小节将忽略其存在。
图2.12显示了程序集解析器为了查找合适程序集文件的整个过程。在正常的部署场景中,程序集解析器用于查找一个程序集的第一位置是GAC。GAC是一个机器端的代码缓存,该缓存包含了机器端使用的已经被安装的程序集。GAC允许管理员来为所有应用程序安装在每个机器一次程序集。为了避免系统崩溃,GAC仅接受那些具有有效签名和公钥的程序集。此外,GAC的项目仅能被管理员删除,这阻止了非管理员用户来删除和移动关键系统级别组件。
图2.12 程序集解析
为了避免歧义,程序集解析器仅当请求的程序集包含公钥时查询GAC。这阻止了普通名字如utilities的请求来被错误的实现满足。公钥可以作为程序集引用来显式的提供,或者Assembly.Load参数提供,或者通过配置文件qualifyAssembly配置节点隐式提供。
GAC由系统级组件(FUSION.DLL)控制,它在%WINNT%\Assembly文件夹中提供缓存。FUSION.DLL为你管理了这个目录的层次并提供了基于由4部分组成的名字访问存储文件的公共,如表2.4。虽然我们可以遍历隐含的文件夹,但是FUSION用于缓存DLL的结构是确保随着CLR演变进行变更的实现。相反,你必须使用GACUTIL.exe工具或一些其它基于FUSION API的工具与GAC交互。一个这样的工具是SHFUSION.DLL,一个Window浏览器Shell扩展,它提供了与GAC交互的友好界面。
表2.4 全局程序集缓存
Name |
Version |
Culture |
Public Key Token |
Mangled Path |
yourcode |
1.0.1.3 |
de |
89abcde... |
t3s\e4\yourcode.dll |
yourcode |
1.0.1.3 |
en |
89abcde... |
a1x\bb\yourcode.dll |
yourcode |
1.0.1.8 |
en |
89abcde... |
vv\a0\yourcode.dll |
libzero |
1.1.0.0 |
en |
89abcde... |
ig\u\libzero.dll |
如果程序集解析器在GAC不能找到请求的程序集,那么程序集解析器将尝试使用一个CODEBASE指令来访问程序集。一个CODEBASE指令简单的映射一个程序集名称到一个文件名称或指定了包含在程序集的模块位置的URL。与版本策略相似,CODEBASE指令在应用程序和机器端配置文件中。表2.6显示2个CODEBASE指令的配置文件。第一个指令映射版本为1.2.3.4的Acme.HealthCare程序集到C:\acmestuff\Acme.HealthCare.dll。第二个指令映射了版本为1.3.0.0的该程序集到http://www.acme.com/bin/Acme.HealthCare.dll。
假设一个CODEBASE指令提供了,程序集解析器将简单的加载对应的程序集文件,且程序集的加载处理就如一个程序集用一个显式的CODEBASE使用Assembly.LoadFrom加载一样。然而,如果没有提供CODEBASE指令,程序集解析器必须启动为查找一个匹配请求的程序集的潜在的昂贵的处理过程。
<?xml version="1.0" ?> <configuration xmlns:asm="urn:schemas-microsoft-com:asm.v1"> <runtime> <asm:assemblyBinding> <!-- one dependentAssembly per unique assembly name --> <asm:dependentAssembly> <asm:assemblyIdentity name="Acme.HealthCare" publicKeyToken="38218fe715288aac" /> <!-- one codeBase per version --> <asm:codeBase version="1.2.3.4" href="file://C:/acmestuff/Acme.HealthCare.DLL"/> <asm:codeBase version="1.3.0.0" href="http://www.acme.com/Acme.HealthCare.DLL"/> </asm:dependentAssembly> </asm:assemblyBinding> </runtime> </configuration>
如果程序集解析器无法使用GAC或一个CODEBASE指令搜索一个程序集,它通过相对与应用程序根路径相对的一序列路径执行搜索。这个搜索被称为探测。探测仅在APPBASE目录或其子目录进行搜索(APPBASE目录是包含应用程序配置文件的目录)。比如,给定如图2.13的目录结构,只有m,common,shared和q有资格被探测。它意味着,程序集解析器仅探测显式指定在配置文件的目录。表2.7显示了一个配置文件例子,它设置了相对目录shared和common。所有APPBASE子目录中没有在配置文件配置将被探测过程排除。
图2.13 APPBASE和相对搜索路径
表2.7 设置相对搜索路径
<?xml version="1.0" ?> <configuration xmlns:asm="urn:schemas-microsoft-com:asm.v1"> <runtime> <asm:assemblyBinding> <asm:probing privatePath="shared;common" /> </asm:assemblyBinding> </runtime> </configuration>
当探测一个程序集时,程序集解析器基于程序集的简单名称、将按照刚才所述的相对搜索路径和请求的程序集的Culture构建CODEBASE URLs。图2.14演示了用于解析一个没有指定Culture程序集引用的CODEBASE URLs的例子。在这个例子,程序集的简单名称是yourcode且相对搜索路径是shared和common目录。程序集解析器首先在APPBASE目录搜索yourcode.dll文件。如果没有这个文件,程序集解析器然后假设程序集是在一个相同名称的目录且在yourcode文件夹查找相同名称的文件。如果文件还未找到,则探测过程将在相对路径的每一个项目重复,直到yourcode.dll文件找到。如果文件找到,则探测停止。否则,探测过程继续重复,不过这次会在相同路径查找yourcode.exe文件。假设一个文件找到,程序集解析器会验证文件匹配程序集引用指定的程序集名称的所有属性,然后装载程序集。如果程序集名称的一个属性没有与程序集引用属性全部匹配,那么Assembly.Load调用失败。否则,程序集被加载并被使用。
图2.14 文化(Culture)中立探测
如果程序集引用包含一个文化标识,那么探测将稍微复杂。如图2.15,前面的算法将通过查找与请求的文化匹配的子目录进行扩展。一般来讲,应用程序应该是搜索路径尽可能小以避免过多的加载时间的延迟。
图2.15 依赖文化的探测
3 版本危害
前面关于程序集解析器如何确定装载哪一个版本的程序集主要是集中在CLR使用的机制。那没有讨论的地方是一个开发人员应该使用什么策略来确定什么时候、如何和为什么将程序集版本化。考虑到在本次写作描述的平台没有上架,因此有点困难来描述基于难得的经验所获得的有效的最佳实践。然而,通过洞悉CLR的知识并推断一序列指导也是合理的。
注意到程序集是版本化的单元是很重要的。尝试改变程序集的文件而没有更改版本编号很可能导致不可预料的问题。为此,该节剩下的部分将研究一下版本化,版本化仅考虑程序集作为一个整体而不是程序集的每个文件。
什么时候改变版本编号是一个有意思的问题。显然,如果一个类型的公开契约发生更改,类型的程序集必须更改一个新的版本编号。否则,依赖一个版本的类型签名的程序,当装载了一个不同签名的类型将,产生一个运行时一场。这意味着如果你添加一个public或protected的公开类型的成员,你必须更改这个类型程序集的版本。如果你更改了公共类型一个public或protected成员(比如添加一个方法参数、更改字段的类型),你也需要一个新的程序集版本。这是绝对的原则。违背这些原则将导致不可预料的后果。
需要回答的更难的问题是与不会影响程序集类型的公开签名的修饰有关的。比如,更改一个标记为private或internal的成员在只关心签名匹配情况下被考虑为不会产生破坏行为的更改。因为在你程序集外,没有代码可以依赖private或internal成员,签名不匹配在运行时不是问题因为它不会发生。不过,类型不匹配仅是冰山一角。
在每一个程序集的构建时更改版本编号是一个合理的理由,即使没有公开可视的签名被改变。一下事实将支持这种方法,那就是即使是一个看起来对一个方法无害的改变也可能对使用程序集的程序有难以琢磨但具有涟漪效应的影响。如果开发人员为程序集的每一个构建,使用一个唯一的版本编号,使用一个指定的构建的版本测试的代码在部署时不会有异常。
针对程序集的每次构建有一个唯一的版本编号的争论是,那些没有针对新版本程序集重新编译的程序不会具有“安全”的修复。这个论点并不合理,如果不考虑发布商策略文件。为每次编译使用唯一版本编号的开发人员擅长于提供发布商策略文件,这些文件描述了程序集向后兼容的哪些版本的程序集。默认的,这给了低版本的使用者自动更新到新版本程序集。当程序集开发人员以为是错误时,每一个应用程序可以使用在配置文件中的publisherPolicy节点来禁用自动升级,从而大体上应用程序处于安全模式。
如前讨论,CLR程序集解析器支持通过CODEBASE指令、私有探测路径和GAC支持一个程序集多个版本并行安装。这允许一个程序集的多个版本在文件系统共存。然而,如果有不止一个版本的这些程序集被独立的一些程序或单一程序在任一时刻加载到内存,事情会变得稍微无法预料。并行执行比并行安装更加难以处理。
在内存中同时有多个版本的主要问题是,对于运行时,那些程序集包含的类型是截然不同的。也就是说,如果一个程序集包含一个名为Customer的类型,那么当一个程序的两个版本被加载,在内存中有两个不同的类型,每一个有自己的唯一标识。这有有些很严重的副作用。其中之一,每一个类型有任意静态字段的拷贝。如果一个需要跟踪一些共享状态的类型与已经被加载的多个版本的类型互相独立,它显然不可以使用利用一个静态字段的解决方案。相反,开发人员需要时刻记住版本来重写代码且将状态存储在与版本无关的一个位置。一种方法是存储共享状态到运行时提供的位置,如ASP.NET Application对象。另一种方法是定义一个分开的类,这个类仅包含一个共享状态的静态字段。开发人员可以把这种类型部署到单独的程序集,这个程序集与版本无关,这样可以确保对于一个应用程序仅有一份静态字段拷贝。
当版本化的类型作为方法的参数传递时,与并行执行有关的另一个问题将产生。如果方法的调用者和被调用者在加载哪一个程序集有不同的观点时,调用者掉传递一个被调用者不认识的类型的参数。开发人员可以通过总是为所有公共方法定义无版本化的类型类解决这个问题。更重要的是,这些共享类型必须被部署到单独的程序集,这些程序集没有进行多版本化。
附:程序集的元数据有3个不同的属性,以允许开发人员来指定在同一时刻是否允许程序集的多个版本被加载。如果这些属性不存在,那么程序集被假设为在所有场景可以并行执行(多版本并行)。Nonsidebysideappdomain属性指定了每一个应用域只能加载这个程序集的一个版本。Nonsidebysideprocess属性指定了每一个进程只能加载这个程序集的一个版本。Nonsidebysidemachine属性指定了在每个机器只能一次性加载这个程序集的一个版本。
4 更加深入CLR
OSGi.NET插件框架(下载地址:http://www.iopenworks.com/Products/SDKDownload)的构建要求对CLR理解较为深入,对CLR的深入理解有助于我们更好的理解OSGi.NET的插件加载机制。关于CLR的知识,我们是从《Essential.NET,Volume I》这本书学习到的,并将重要的部分翻译成中文供框架设计人员阅读。如果你想更加深入理解CLR,你可以查看这本书,最好看英文版的。