【BotR】CLR类型加载器设计
.NET运行时之书(Book of the Runtime,简称BotR)是一系列描述.NET运行时的文档,2007年左右在微软内部创建,最初目的是为了帮助其新员工快速上手.NET运行时;随着.NET开源,BotR也被公开了出来,如果想深入理解CLR,这系列文章不可错过。
BotR系列目录:
[1] CLR类型加载器设计(Type Loader Design)
[2] CLR类型系统概述(Type System Overview)
类型加载器设计(Type Loader Design)
原文:https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/type-loader.md
作者: Ladi Prosek - 2007
翻译:几秋 (https://www.cnblogs.com/netry/)
介绍
在一个基于类的(class-based)的面向对象系统中,类型(type)是一个模板,描述了实例将包含的数据和提供的功能。如果不首先定义对象的类型,就不可能创建对象1。如果两个对象是同一个类型的实例,就可以说它们是同一个类型;事实上(即使)两个对象定义了完全相同的成员,它们可能也没有任何关联。
上面一段可以用来描述一个典型的C++系统。CLR必不可少的一个附加功能是完整的运行时类型信息的可用性。为了“管理”托管代码并提供类型安全的环境,运行时必须在任何时候都要能知道任意对象的类型。这种类型信息必须是不用大量计算就可以得到,因为类型标识查询被认为是相当频繁的(例如,任何类型转换都涉及到查询对象的类型标识,以验证转换是安全并且可以执行的)。
此性能要求排除了所有的字典查找方法,我们只剩下以下架构
图一 抽象宏观的对象设计
除了实际的实例数据之外,每个对象还包含一个类型id的指针, 指向表示该类型的结构。这个概念和C++虚表(v-table)指针相似,但是这个结构(我们现在称为类型,后文会更精确地定义它),它包含的不仅仅是一个虚表,对于实例,它必须包含关于层次结构的信息(即继承关系),以便能够回答“is-a”的包含问题。
1C# 3.0引入的匿名类型,允许你不用显示引用一个类型就可以定义对象 - 只需直接列出其字段即可,不要让这愚弄你,实际上编译器在幕后为你创建了一个类型。
1.1 相关阅读
[1] Martin Abadi, Luca Cardelli, A Theory of Objects, ISBN 978-0387947754
[2] Design and Implementation of Generics for the .NET Common Language Runtime
[3] ECMA Standard for the Common Language Infrastructure (CLI)
1.2 设计目标
类型加载器(type loader)有时也称为类加载器(class loader,常见于各种Java八股文),这种说法严格来说是不正确的,因为类(class)只是类型(type)的子集 - 即引用类型,类型加载器也会加载值类型,它的最终目的是构建表示要求它加载的类型的数据结构。以下是加载器应具有的属性:
- 快速类型查找 (通过[module, token] 或者 [assembly, name] 查找).
- 优化的内存布局已实现良好的工作集大小、缓存命中率和 JIT编译后的代码性能。
- 类型安全 - 不加载格式错误的类型,并抛出一个 TypeLoadException 异常。
- 并发性 - 在多线程环境中具有良好的可扩展性。
2 类型加载器架构
加载器的入口点(entry-points,可以理解为公开的方法)数量相对较少。尽管每个入口点的签名略有不同,但是它们都有相同的语义。它们采用以元数据 token或者name字符串为形式的类型/成员名称,token的作用域(模块或者程序集),以及一些附加信息如标志;然后以句柄(handle)的形式返回已加载的实体。
在JIT过程中,通常会有调用很多次类型加载器。思考下面的代码:
object CreateClass()
{
return new MyClass();
}
在它IL代码里,MyClass被一个元数据token所引用。为了生成一个对 JIT_New
(它是真正完成实例化的函数) 帮助方法的调用指令,JIT会要求类型加载器去加载这个类型并返回一个句柄。然后这个句柄将作为一个立即数(immediate value)直接嵌入到JIT编译后的代码中。类型和成员通常是在JIT过程中被解析和加载的,而不是在运行时阶段,它还解释了像这样的代码有时容易引起混淆的行为:
object CreateClass()
{
try {
return new MyClass();
} catch (TypeLoadException) {
return null;
}
}
如果MyClass
加载失败,例如,因为它应该在另一个程序集中定义,但是在最新的版本中却被意外删除了,然后此代码仍将抛出TypeLoadException
。这里异常不会被捕获的原因是这段代码根本没有执行!这个异常发生在JIT的过程中,只能在调用了CreateClass
并使它JIT完成的方法里被捕获。此外,由于内联(inlining)的存在,触发JIT的时机有时并不是那么明显,因此用户不应该期待和依赖于这种不确定的行为。
关键数据结构
CLR中最通用的类型名称是TypeHandle
,它是一个的抽象实体,封装了指向一个MethodTable
(表示“普通的”类型像System.Object
或者 List<string>
)或者一个TypeDesc
(表示 byref、指针、函数指针、数组,以及泛型变量)的指针。它构成了一个类型的标识,因为当且仅当两个句柄表示同一类型时,它们才是相等的。为了节省空间, TypeHandle
通过设置指针的第二低位为 1(即 (ptr|2))来表示它指向的TypeDesc
,而不是用额外的标志。TypeDesc
是“抽象的”,并且有如下的继承体系:
图2 TypeDesc体系
TypeDesc
抽象类型描述符。具体的描述符类型由标志确定。
TypeVarTypeDesc
表示一个类型变量,即 List<T>
或者Array.Sort<T>
中的T
(参见下文关于泛型的部分)。类型变量不会在多个类型或者方法间共享,因此每个变量有且只有一个所有者。
FnPtrTypeDesc
表示一个函数指针,实质上是一个引用返回类型和参数的变长type handle列表,这个描述符不太常见,因为C#不支持函数指针。然而托管C++会使用它们。
ParamTypeDesc
这个描述符表示一个byref和指针类型,byref是ref
和 out
这两个C#关键字应用到方法参数3的结果,而指针类型是非托管的指针,指向unsafe C#和托管C++中使用的数据。
ArrayTypeDesc
表示数组类型. 派生自ParamTypeDesc
,因为数组也由单个参数(其元素的类型)参数化。这与参数数量可变的泛型实例化相反。
MethodTable
这是目前为止运行时的最重要的数据结构,它表示所有不属于上述类别的类型(它包括基本类型,开放(open)或闭合(closed)的泛型类型)。它包含了所有关于类型需要快速查找的信息,像它的父类型,实现的接口和虚表。
EEClass
为了提高工作集和缓存利用率,MethodTable
的数据被分为“热”和“冷”两种结构。MethodTable
本身只存储程序在稳定状态(steady state)下所需的“热”数据;而EEClass
存储通常只在类型加载、JIT 编译或者反射中需要的“冷”数据。每个MethodTable
指向一个 EEClass
.
此外,EEClass
是被泛型共享的,多个泛型MethodTable
可以指向同一个EEClass
。这种共享对可以存储在 EEClass
上的数据增加了额外的约束。
MethodDesc
顾名思义,此结构用来描述方法。它实际上有几种变体,它们有相应的 MethodDesc
子类型,但是大多数都超出了本文的讨论范围。这里只需说其中一个叫做InstantiatedMethodDesc
的子类型,它在泛型中扮演了重要角色。更多信息请参考Method Descriptor Design
FieldDesc
和MethodDesc
相似 , 此结构用来描述字段。除了某些 COM 互操作场景,EE(EEClass
中的EE,即Execution Engine,执行引擎)根本不在乎属性和事件,因为它们最终归结为方法和字段,只有编译器和反射才能生成和理解它们,以便提供语法糖之类的体验。
2这对调试很有用,如果一个TypeHandle
的值是以2, 6, A, 或者E结尾,那么它就是不是MethodTable
,为了成功地观察到TypeDesc
,必须清除额外的位。
3注意ref
和out
之间的区别仅在于参数属性,就类型系统而言,它们都是相同的类型。
2.1 加载级别
当类型加载器被要求加载一个指定的类型时,例如通过一个typedef/typeref/typespec的token和一个Module,它不会一次性做完所有的工作,而是分阶段完成加载;这是因为一个类型经常会依赖其它的类型,如果在能被其它类型引用之前就完全加载,将导致无限递归和死锁,思考下面的代码:
class A<T> : C<B<T>>
{ }
class B<T> : C<A<T>>
{ }
class C<T>
{ }
上面的类型都是有效的,显然 A
与B
相互依赖。
加载器首先会创建表示这个类型的一些结构,然后使用无需加载其它类型就可得到的数据来初始化它们。当“没有依赖”的工作完成,这些结构就可以被其它地方所引用,通常是通过将指向它们的指针粘贴到其它结构中。之后,加载器以增量步骤进行,用越来越多的信息填充这些结构,直到类型完全加载完成。在上面的例子中,首先A
和 B
的基类会近似于不包括其它类型,然后才会被真正的类型所替代。
所谓的加载加载级别,就是用来描述这些半加载状态( half-loaded),从 CLASS_LOAD_BEGIN
开始,到CLASS_LOADED
结束,二者之间还有一些中间级别。在 classloadlevel.h源文件里,每个级别都有丰富且有用的注释。注意,虽然类型可以保存到NGEN镜像中,但表示的结构不能简单地映射或者写入内存,然后不做额外“恢复”工作就使用。一个类型是来自于NGEN镜像并且需要“恢复”,这一信息它的加载级别也可以感知到。
更多关于加载级别的解释请看Design and Implementation of Generics for the .NET Common Language Runtime
2.2 泛型
在没有泛型的世界里,一切都很美好,每个人都很开心,因为每一个普通类型(不是由 TypeDesc
所表示的类型)都有一个 MethodTable
指向他关联的EEClass
,这个EEClass
又指回它的MethodTable
,该类型的所有实例都包含一个指向MethodTable
,作为偏移量为0处的第一个字段,即在被视为参考值的地址上。为了节省空间,由该类型声明的MethodDesc
表示方法,被组织在EEClass
指向的块链表中4。
图3 具有非泛型方法的非泛型类型
4当然,当托管代码运行时,它不会通过在这些块中查找方法来调用它们,调用一个方法是很“热”的操作,正常只需要访问MethodTable
中的信息。
2.2.1 术语
泛型形式参数(Generic Parameter)
一个能被其它类型替换的占位符;如 List<T>
中的T
。有时也称作形式类型参数(formal type parameter)。一个泛型形式参数有一个名字和一个可选的泛型约束。
泛型实际参数(Generic Argument)
一个替换泛型形式参数的具体类型;如List<int>
中的int
。注意,一个泛型形式参数也可以被用作泛型实际参数。思考下面的代码:
List<T> GetList<T>()
{
return new List<T>();
}
这个方法有一个泛型形式参数T
,被用作泛型列表的泛型实际参数。
泛型约束
泛型形式参数对其潜在的泛型实际参数的可选要求。不满足要求的类型不能替换形式参数,这是类型加载器强制的。有下面三种泛型约束:
-
特殊约束
- 引用类型约束 —— 泛型实际参数必须是一个引用类型(相对应的是一个值类型)。在C#里,用
class
表示这个约束。
public class A<T> where T : class
- 值类型约束 —— 泛型实际参数必须是一个除
System.Nullable<T>
之外的值类型。C#里使用struct
这个关键字。
public class A<T> where T : struct
- 默认构造函数约束 —— 泛型实际参数必须有个公开的无参构造函数。C#里用
new()
表示。
public class A<T> where T : new()
- 引用类型约束 —— 泛型实际参数必须是一个引用类型(相对应的是一个值类型)。在C#里,用
-
基类约束 —— 泛型实际参数必须派生自(或者直接就是)给定的非接口类型。显然最多只能有一个引用类型作为基类约束。
public class A<T> where T : EventArgs
-
接口实现约束 —— 泛型实际参数必须实现(或者直接就是)给定的接口类型。可以同时有多个接口约束。
public class A<T> where T : ICloneable, IComparable<T>
上面的约束可以被一个显式AND组合起来,即一个泛型形式参数可以约束要派生自一个给定的类,实现几个接口,并且还要有默认的构造函数。声明类型的所有泛型参数都可以用来表示约束,从而在参数之间引入相互依赖关系,例如:
public class A<S, T, U>
where S : T
where T : IList<U> {
void f<V>(V v) where V : S {}
}
实例(Instantiation)
一组泛型实际参数,用来替换泛型类型或者方法中的泛型形式参数。每一个加载的泛型和方法都有它的实例。
典型实例(Typical Instantiation)
一个实例仅仅包含类型或者方法自己的类型参数,且和声明参数一样的顺序。每个泛型类型和方法只存在一个典型实例。通常当我们提到开放泛型类型(Open generic type)时,就是指它的典型实例,例如:
public class A<S, T, U> {}
C#会把typeof(A<,,>)
编译为一个ldtoken A\'3
,让运行时加载S
, T
, U
实例化的 A`3
。
规范实例(Canonical Instantiation)
一个所有泛型实际参数都是System.__Canon
的实例。System.__Canon
是一个定义在mscorlib中的内部类型,其任务只是为了作为规范,并且与其它可能用作泛型实际参数的类型不同。带有规范实例的类型/方法被用作所有实例的代表,并携带所有实例共享的信息。由于System.__Canon
显然不能满足泛型形参上可能携带的任何约束,因此约束检查对于System.__Canon
是特例,会忽略了这些行为。
2.2.2 共享
随着泛型的出现,运行时加载的类型数量变得更多了,虽然不同实例的泛型(如List<string>
and List<object>
)是不同的类型,它们都有自己的MethodTable
,事实证明,他们有大量信息可以共享。这种共享对内存足迹(memory footprint)有积极的影响,因此也会提高性能。
图4 带有非泛型方法的泛型类型 - 共享EEClass
当前所有包含引用类型的实例都共享同一个EEClass
和 它的 MethodDesc
。这是可行的,因为所有的引用类型大小都一样 —— 4或者8个字节。因此所有这些类型的布局都是相同的。上图为List<object>
和List<string>
阐明了这点。规范的 MethodTable
在第一个引用类型实例被加载之前就自动创建,它包含了热数据,但不是特定于像非虚的方法槽(non-virtual slots)或者RemotableMethodInfo
实例。仅包含值类型的实例是不共享的,并且每个这种实例化的类型都有自己的未共享EEClass
。
目前为止已加载泛型类型的MethodTable
,会被缓存到一个属于它们加载器模块(loader module)的哈希表中5。在一个新的实例构造之前,首先会查询这个哈希表,确保不会有两个或多个 MethodTable
实例表示同一个类型。
更多关于泛型共享的信息请看Design and Implementation of Generics for the .NET Common Language Runtime
5从NGEN镜像加载的类型,事情会变得有点复杂。
后记
本文可以帮助我们了解CLR的类型加载机制(注意是Type类型,而不是Class类),文中涉及到术语或者容易混淆的地方,我有在随后的括号里列出原文和解释。如有翻译不正确的地方,欢迎指正。
文章内容偏底层,有很多琐碎的概念,后面有机会我会一一写文章介绍。
作者: 马行空的博客
出处: https://www.cnblogs.com/netry/p/clr-type-loader-chinese.html
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接。