.NET运行时之书(Book of the Runtime,简称BotR)是一系列描述.NET运行时的文档,2007年左右在微软内部创建,最初目的是为了帮助其新员工快速上手.NET运行时;随着.NET开源,BotR也被公开了出来,如果想深入理解CLR,这系列文章不可错过。
BotR系列目录:
[1] CLR类型加载器设计(Type Loader Design)
[2] CLR类型系统概述(Type System Overview)
类型系统概述(Type System Overview)
原文:https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/type-system.md
作者: David Wrighton - 2010
翻译:几秋 (https://www.cnblogs.com/netry/)
介绍
CLR类型系统是我们在ECMA规范+扩展中描述的类型系统的表示形式。
概述
该类型系统是由一系列数据结构(其中一些在BotR的其它章节有描述)和操作这些数据结构的算法组合而成,它不是通过反射暴露出来的类型系统,尽管反射确实依赖于这个系统。
由类型系统维护的主要数据结构是:
- MethodTable
- EEClass
- MethodDesc
- FieldDesc
- TypeDesc
- ClassLoader
由类型系统包含的主要算法是:
- Type Loader: 用于加载类型并创建类型系统中大部分的重要数据结构。
- CanCastTo and similar: 类型比较功能。
- LoadTypeHandle: 主要用于查找类型。
- Signature parsing: 用于比较和收集有关方法和字段的信息
- GetMethod/FieldDesc: 用于查找、加载方法和字段。
- Virtual Stub Dispatch: 用于查找对接口的虚调用的存根。
还有很多辅助数据结构和算法为CLR的其余部分提供各种信息,但它们对于整个系统的理解并不那么重要。
组件架构
类型系统的数据结构通常被各种算法所使用。本文档不会涉及类型系统算法(),但是它会试图描述各种主要数据结构。
依赖
类型系统大体上是给CLR中很多部分提供服务,多数核心组件都对类型系统的行为有某种形式的依赖性。下图描述了影响类型系统的通用数据流,它不是很全面,但是指出了只要的信息流。
组件依赖
类型系统的主要依赖关系如下:
- 加载器工作需要获取正确的元数据。
- 元数据系统(metadata system)提供一个元数据API去收集信息。
- 安全系统(security system)告知类型系统是否允许某些类型系统结构。
- 应用程序域(AppDomain)提供一个LoaderAllocator去处理类型系统数据结构的分配行为。
依赖于此组件的组件
类型系统有3个主要组件依赖于它:
- jit接口和jit helpers主要依赖于类型、方法,和字段搜索功能。一旦找到了类型系统对象,返回的数据结构就会进行裁剪,以提供jit所需的信息。
- 反射使用类型系统提供对ECMA标准化概念相对简单的访问,CLR类型系统的数据结构中正好有这些。
- 常规托管代码执行 需要使用类型系统进行类型比较逻辑和虚拟存根分派
类型系统设计
核心类型系统数据结构是表示实际加载类型的数据结构(例如,TypeHandle, MethodTable, MethodDesc, TypeDesc, EEClass)和允许在加载类型后找到它们的数据结构(例如,ClassLoader, Assembly, Module, RIDMaps)。
加载类型的数据结构和算法在BotR的Type Loader 和 MethodDesc章节中有讨论。
将这些数据结构绑定在一起是一组功能, 允许JIT/Reflection/TypeLoader/stackwalker去查找现存类型和方法,一般的想法是,这些搜索应该很容易地由ECMA CLI规范中指定的元数据令牌/签名(metadata tokens/signatures)驱动。
最后,当合适的类型系统数据结构被找到,我们有算法从类型中收集信息,有and/or比较两个类型。可以在 Virtual Stub Dispatch找到这种算法的一个特别复杂的例子。
设计目标和非目标
目标
- 访问运行时执行(非反射)代码所需的信息要非常快。
- 访问编译时生成代码所需的信息要是非常直截了当的。
- 垃圾回收器(arbage collector)、堆栈遍历器(stackwalker,没找到准确的中文翻译,详情可参考这篇文章 Stackwalking in the .NET Runtime/)无需锁或分配内存就可以方法信息。
- 一次只加载最少量的类型。
- 类型加载时只加载最少需要加载的类型。
- 类型系统的数据结构必须存储在NGEN镜像中。
非目标
- 元数据中的所有信息都直接反映在CLR的数据结构中。
- 所有的反射操作都要非常快。
运行时在托管代码执行期间使用的一个典型算法设计
类型转换算法(casting algorithm)是类型系统中的典型算法,在托管代码的执行过程中大量使用这种算法。
这个算法至少有4个独立的入口(entry point),选择每个入口都是为了提供不同的快速路径,希望能够实现最佳的性能。
- 一个对象能否被转换成一个非类型等价的非数组类型(non-type equivalent non-array type)?
- 一个对象能否被转换成一个没有实现泛型变体(generic variance)的接口类型?
- 一个对象能否被转换成一个数组类型?
- 一个类型的对象能否被转换成转换为任意其他托管类型?
除了最后一个之外,每个实现都进行了优化,以便在不完全通用的情况下提高性能。例如,“一个类型能否被转换成一个父类?”就是 “一个对象能否被转换成一个非类型等价的非数组类型”的变体,它通过单循环遍历一个单链表实现。这只能搜索可能的转换操作的一个子集,但可以通过检查试图强制转换的类型来确定是否是合适的集合,这个算法在jit helper JIT_ChkCastClass_Portable
中实现。
假设:
- 算法的特殊用途实现通常是性能改进。
- 额外版本的算法不会提供无法克服的维护问题(insurmountable maintenance problem)。
类型系统中典型搜索算法设计
在类型系统中很多算法遵循这种常见模式。类型系统通常用于查找类型,这可以通过任意数量的输入触发,如JIT、反射、序列化、远程调用等等。在这些情况下,对类型系统的基本输入是:
- 来自开始搜索的上下文(一个模块或者程序集指针)。
- 在初始上下文中描述所需类型的标识符(identifier),通常是一个令牌(token),或者一个字符串(如果搜索上下文是一个程序集)。
这个算法必须首先解码标识符。对于搜索一个类型的场景,令牌可能是typedef令牌、typeref令牌、typespec令牌,或者是一个字符串。这些不同种类的标识符都会将导致不同形式的查询。
- 一个typedef令牌将导致在模块的RidMap中进行查找。这是一个简单的数组索引。
- 一个typeref令牌将导致查找此令牌所引用的程序集,然后用找到的汇编指针和从typeref表中收集的字符串重新开始类型查找算法。
- 一个typespec令牌指示必须对签名(signature)进行解析才能找到签名。解析签名以查找加载该类型所需的信息,这将递归的触发更多类型查找。
- 名称(name)用于在程序集之间进行绑定。TypeDef/ExportedTypes表用来做匹配搜索。主要:此搜索通过清单模块对象(manifest module object)上的哈希表进行优化。
从这个设计中可以明显看出一些搜索算法在类型系统中的共同特点:
- 搜索使用的输入是和元数据加密耦合的。特别是元数据令牌和字符串名称,它们检查被传来传去,还有,这些搜索是和模块绑在一起的,它们直接映射到 .dll和.exe文件。
- 使用缓存的信息去提高性能。RidMap和哈希表是经过优化的数据结构,用于改进这些查找。
- 这些算法通常根据其输入有3-4个不同的路径。
除了这个总体设计,在此基础上,还有一些额外的需求:
- 假设 在GC停止时搜索已加载的类型是安全的。
- 不变性 一个已经加载了的类型将总是可以被搜索到。
- 问题搜索程序依赖于元数据读取。某些场景可能会导致性能不足。
此搜索算法是 JIT期间使用的典型程序。它具有许多共同的特征:
- 它使用元数据
- 它需要在许多地方查找数据
- 在我们的数据结构中有相对少量的重复数据
- 它通常不会深度递归,也没有循环
这使我们能够满足性能要求,以及使用基于IL的JIT所必需的特征。
类型系统对垃圾回收器的要求
垃圾回收器要有关类型实例分配在GC堆上的信息,这是通过一个指向类型系统数据结构(MethodTable)的指针来完成,该MethodTable位于每一个托管对象的开头。附着到这个MethodTable之上的,是一个描述类型实例GC布局的数据结构。该布局有两种形式(一般类型和对象数组是一种,值类型数组是另一种)。
- 假设:类型系统的数据结构有一个生命周期,它超过了自身描述类型的托管对象的生命周期。
- 要求: 垃圾回收器需要在运行时挂起时执行堆栈遍历器(stack walker),这将在下面讨论。
Stackwalker对类型系统的要求
堆栈遍历器/GC堆栈遍历器在两种情况下要求类型系统输入:
- 用于查找值类型在堆栈上的大小
- 用于查找要在堆栈上的值类型内报告的GC根
由于各种原因,包括希望延迟加载类型,以及避免生成多个版本的代码(仅通过相关的gc信息不同),CLR当前需要遍历堆栈上的方法签名,这种需求很少得到满足,因为它要求在特定的时刻执行堆栈遍历,但是为了满足我们的现实目标,当遍历堆栈的时候,必须能够遍历签名。
堆栈遍历器以大约 3 种模式执行:
- 因为安全或者异常处理的原因,要遍历当前线程的堆栈
- 因为GC,要遍历使用线程的堆栈(所有线程都被EE挂起)
- 使用分析工具(profiler)需要遍历指定的线程(指定线程被挂起)
在GC和分析工具遍历堆栈的情况,由于线程被挂起,分配内存或占用大多数锁是不安全的。这导致我们开发了一条通过类型系统的路径,可以依赖它来遵循上述要求。型系统实现此目标所需的规则是:
- 如果一个方法已经被调用,那么被调用方法的所有值类型参数都将被加载到进程中的某个应用程序域中。
- 从带有签名的程序集到实现该类型的程序集,引用它们的程序集必须在遍历签名之前被解析,这是遍历堆栈中必要的一部分。
这是通过一系列广泛而复杂的强制措施来实施的,包括类型加载器、NGEN镜像生成过程和JIT。
- 问题: 堆栈遍历器对类型系统的要求是非常脆弱的。
- 问题: 在类型系统上实现堆栈遍历器的要求,需要侵入类型系统中的每个搜索类型时可能接触到的函数
- 问题: 执行的签名遍历是使用正常的签名遍历代码完成的。此代码设计是在遍历签名时加载类型,但在这种情况下,类型加载功能是在假设实际上不会触发任何类型加载的情况下使用的。
- 问题: 堆栈遍历器不仅需要类型系统的支持,还需要程序集加载器的支持,要满足类型系统的需要,加载器还有很多问题。
类型系统与NGEN
类型系统数据结构是保存到NGEN镜像中的核心部分,不幸的是,这些数据结构逻辑内有指向其它NGEN镜像的指针。为了处理这种情况,类型系统数据结构实现了一个称为恢复(restoration)的概念。
在恢复期,当第一次需要类型系统数据结构时,该数据结构用正确的指针固定, 这与类型加载级别有关,请看前篇CLR类型加载器设计
还存在一个预恢复(pre-restored)数据结构的概念,这意味着数据结构在NGEN镜像加载时足够正确(在intra-module指针和预先加载类型修正之后),数据结构可以按原样使用。此优化要求将NGEN镜像“硬绑定”("hard bound")到其依赖程序集,详情请查看NGEN相关文档。
类型系统和域中性加载(Domain Neutral Loading)
类型系统是实现域中性加载的核心部分,它通过在AppDomain创建时启用LoaderOptimization选项暴露给用户。Mscorlib始终作为域中性加载,此功能的核心要求是类型系统数据结构不能要求指向特定域状态(domain specific state)的指针,这主要表现在围绕静态字段和类构造函数的需求中。特别是,由于这个原因,一个类的构造函数是否已经运行不是核心MethodTable数据结构的一部分。并且有一种机制来存储附加到DomainFile数据结构而不是MethodTable数据结构。
物理结构
类型系统的主要部分位于:
- Class.cpp/inl/h – EEClass函数, 和BuildMethodTable
- MethodTable.cpp/inl/h – 操作methodtable的函数
- TypeDesc.cpp/inl/h – 检查TypeDesc的函数
- MetaSig.cpp SigParser – 签名代码
- FieldDesc /MethodDesc –检查这些数据结构的函数
- Generics – 泛型特定逻辑
- Array – 处理数组处理所需的特殊情况的代码
- VirtualStubDispatch.cpp/h/inl – 虚拟存根分派(VSD)代码
- VirtualCallStubCpu.hpp – 用于虚拟存根分派的处理器特定代码
主要入口函数是BuildMethodTable、 LoadTypeHandleThrowing、CanCastTo*、 GetMethodDescFromMemberDefOrRefOrSpecThrowing、 GetFieldDescFromMemberRefThrowing、 CompareSigs和 VirtualCallStubManager::ResolveWorkerStatic.