【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

图2 TypeDesc体系

TypeDesc

抽象类型描述符。具体的描述符类型由标志确定。

TypeVarTypeDesc

表示一个类型变量,即 List<T>或者Array.Sort<T>中的T(参见下文关于泛型的部分)。类型变量不会在多个类型或者方法间共享,因此每个变量有且只有一个所有者。

FnPtrTypeDesc

表示一个函数指针,实质上是一个引用返回类型和参数的变长type handle列表,这个描述符不太常见,因为C#不支持函数指针。然而托管C++会使用它们。

ParamTypeDesc

这个描述符表示一个byref和指针类型,byref是refout这两个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注意refout之间的区别仅在于参数属性,就类型系统而言,它们都是相同的类型。

2.1 加载级别

当类型加载器被要求加载一个指定的类型时,例如通过一个typedef/typeref/typespec的token和一个Module,它不会一次性做完所有的工作,而是分阶段完成加载;这是因为一个类型经常会依赖其它的类型,如果在能被其它类型引用之前就完全加载,将导致无限递归和死锁,思考下面的代码:

class A<T> : C<B<T>>
{ }

class B<T> : C<A<T>>
{ }

class C<T>
{ }

上面的类型都是有效的,显然 AB相互依赖。

加载器首先会创建表示这个类型的一些结构,然后使用无需加载其它类型就可得到的数据来初始化它们。当“没有依赖”的工作完成,这些结构就可以被其它地方所引用,通常是通过将指向它们的指针粘贴到其它结构中。之后,加载器以增量步骤进行,用越来越多的信息填充这些结构,直到类型完全加载完成。在上面的例子中,首先AB的基类会近似于不包括其它类型,然后才会被真正的类型所替代。

所谓的加载加载级别,就是用来描述这些半加载状态( 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

图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,被用作泛型列表的泛型实际参数。

泛型约束

泛型形式参数对其潜在的泛型实际参数的可选要求。不满足要求的类型不能替换形式参数,这是类型加载器强制的。有下面三种泛型约束:

  1. 特殊约束

    • 引用类型约束 —— 泛型实际参数必须是一个引用类型(相对应的是一个值类型)。在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()
    
  2. 基类约束 —— 泛型实际参数必须派生自(或者直接就是)给定的非接口类型。显然最多只能有一个引用类型作为基类约束。

     public class A<T> where T : EventArgs
    
  3. 接口实现约束 —— 泛型实际参数必须实现(或者直接就是)给定的接口类型。可以同时有多个接口约束。

     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
图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类),文中涉及到术语或者容易混淆的地方,我有在随后的括号里列出原文和解释。如有翻译不正确的地方,欢迎指正。
文章内容偏底层,有很多琐碎的概念,后面有机会我会一一写文章介绍。

posted @ 2022-09-20 22:14  马行空的博客  阅读(840)  评论(1编辑  收藏  举报