.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></string>
)或者一个 TypeDesc
(表示 byref、指针、函数指针、数组,以及泛型变量)的指针。它构成了一个类型的标识,因为当且仅当两个句柄表示同一类型时,它们才是相等的。为了节省空间, TypeHandle
通过设置指针的第二低位为 1(即 (ptr|2))来表示它指向的 TypeDesc
,而不是用额外的标志。 TypeDesc
是”抽象的”,并且有如下的继承体系:
图2 TypeDesc体系
TypeDesc
抽象类型描述符。具体的描述符类型由标志确定。
TypeVarTypeDesc
表示一个类型变量,即 List<t></t>
或者 Array.Sort<t></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 : C>
{ }
class B : C>
{ }
class C
{ }
上面的类型都是有效的,显然 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>
中的 T
。有时也称作形式类型参数(formal type parameter)。一个泛型形式参数有一个名字和一个可选的泛型约束。
泛型实际参数(Generic Argument)
一个替换泛型形式参数的具体类型;如 List<int></int>
中的 int
。注意,一个泛型形式参数也可以被用作泛型实际参数。思考下面的代码:
List GetList()
{
return new List();
}
这个方法有一个泛型形式参数 T
,被用作泛型列表的泛型实际参数。
泛型约束
泛型形式参数对其潜在的泛型实际参数的可选要求。不满足要求的类型不能替换形式参数,这是类型加载器强制的。有下面三种泛型约束:
- 特殊约束
- 引用类型约束 —— 泛型实际参数必须是一个引用类型(相对应的是一个值类型)。在C#里,用
class
表示这个约束。
public class A where T : class
- 值类型约束 —— 泛型实际参数必须是一个除
System.Nullable<t></t>
之外的值类型。C#里使用struct
这个关键字。
public class A where T : struct
- 默认构造函数约束 —— 泛型实际参数必须有个公开的无参构造函数。C#里用
new()
表示。
public class A where T : new()
- 基类约束 —— 泛型实际参数必须派生自(或者直接就是)给定的非接口类型。显然最多只能有一个引用类型作为基类约束。
public class A where T : EventArgs
- 接口实现约束 —— 泛型实际参数必须实现(或者直接就是)给定的接口类型。可以同时有多个接口约束。
public class A where T : ICloneable, IComparable
上面的约束可以被一个显式AND组合起来,即一个泛型形式参数可以约束要派生自一个给定的类,实现几个接口,并且还要有默认的构造函数。声明类型的所有泛型参数都可以用来表示约束,从而在参数之间引入相互依赖关系,例如:
public class A
where S : T
where T : IList {
void f(V v) where V : S {}
}
实例(Instantiation)
一组泛型实际参数,用来替换泛型类型或者方法中的泛型形式参数。每一个加载的泛型和方法都有它的实例。
典型实例(Typical Instantiation)
一个实例仅仅包含类型或者方法自己的类型参数,且和声明参数一样的顺序。每个泛型类型和方法只存在一个典型实例。通常当我们提到开放泛型类型(Open generic type)时,就是指它的典型实例,例如:
public class A {}
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></string>
and List<object></object>
)是不同的类型,它们都有自己的 MethodTable
,事实证明,他们有大量信息可以共享。这种共享对内存足迹(memory footprint)有积极的影响,因此也会提高性能。
图4 带有非泛型方法的泛型类型 – 共享EEClass
当前所有包含引用类型的实例都共享同一个 EEClass
和 它的 MethodDesc
。这是可行的,因为所有的引用类型大小都一样 —— 4或者8个字节。因此所有这些类型的布局都是相同的。上图为 List<object></object>
和 List<string></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类),文中涉及到术语或者容易混淆的地方,我有在随后的括号里列出原文和解释。如有翻译不正确的地方,欢迎指正。
文章内容偏底层,有很多琐碎的概念,后面有机会我会一一写文章介绍。
Original: https://www.cnblogs.com/netry/p/clr-type-loader-chinese.html
Author: 几秋
Title: 【BotR】CLR类型加载器设计
原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/802244/
转载文章受原作者版权保护。转载请注明原作者出处!