知识在与温故、总结-再读CLR

时间:2023-06-13 08:12:20

CLR,通用语言运行时,每个.Net 程序猿,都会第一时间接触到。记得2008年,第一次学习Jeffrey Richter的CLR Via C#,读的懵懵懂懂,大抵因为编码太少,理解的只是概念和皮毛。10年之后,再次找出Jeffrey Richter的CLR Via C#这本书,重读CLR。归纳总结,同时加深自我的底层技术理解和深度。分享给大家自己的总结笔记:

讲在前面的话

合抱之木,生于毫末;九层之台,起于垒土!

整个.Net 大厦建筑的底层基础技术就是CLR,通用语言运行时。CLR给我们带来JIT、垃圾回收、MSIL、Meta Data、Application Domain等一系列概念,它们共同协作,合力打造了一个与非托管代码完全不同的一个新的开发环境。每个组件如何和谐地与其他组件协作,平稳地运行.Net 应用,只有深入了解CLR,才能“拨开云雾见天日,守得云开见月明”!

总结Part1:CLR是什么? 我们的源码如何被编译成托管模块的?

  CLR:公共语言运行时(Common Language Runtime,CLR),一个可以由多种不同编程语言使用的运行时。

Mircosoft面向CLR提供了几种编程语言编译器,包括:C#、VB、J#、C++、Jscript以及一个中间语言(Intermediate Language,IL)等编译器。因此,我们可以使用支持CLR的任何编程语言来创建源代码文件,实现实际的业务逻辑。

创建源代码文件之后,要使用一个相对应的编译器来检查语法和分析源码,最后编译成一个“托管模块”。

托管模块是一个标准的32位Microsoft Windows可移植执行体(PE32)文件,后者是一个标准的64位Microsoft Windows可移植执行体(PE32+)文件,这些文件需要CLR才可以执行。

将源代码编译为托管模块:

知识在与温故、总结-再读CLR

所有CLR支持的编译器通过编译生成的都是“中间语言(Intermediate Language,IL)代码”,IL代码有时也称为托管代码,因为CLR会管理它的执行。除了生成IL,编译器还会在每个托管模块中生成完整的元数据,元数据Meta Data是一系列特殊的数据表,描述了模块中定义的内容,比如类型及成员。同时元数据表还记录了当前托管模块引用的内容,比如引用的类型及成员。

托管模块由哪几个部分组成呢?

知识在与温故、总结-再读CLR

总结Part2:将托管代码编译为程序集

将源代码编译为托管模块之后,CLR实际上并不和托管模块一起工作,相反,CLR与程序集一起工作。

程序集(assembly)是一个抽象的概念,是一个或者多个模块/资源文件的逻辑性分组。同时,程序集是一个最小的重用、安全性以及版本控制单元。

默认情况下,将托管模块和源文件转换成一个程序集的工作由编译器完成。

知识在与温故、总结-再读CLR

总结Part3:加载CLR

我们编译的每个程序集既可以是一个可执行的应用程序,也可以是一个DLL(动态链接库,其中含有一系列有可执行程序使用的类型)。最终由CLR来管理这些程序集中的代码的执行。这就要求主机上必须安装.Net Framework.

在了解CLR具体如何加载之前,我们先了解下程序集的32位和64位版本问题,及支持X86和X64 Windows平台。在Visual Studio的Project属性Build选项中,可以选择Target Platform,如果选择X86,C#编译器生成的程序集包含一个PE32头,如果选择X64,将包含PE32+头。运行一个可执行文件时,Windows会检查这个EXE文件的头,判断应用程序需要的是32位地址空间,还是64位地址空间,具有PE32头的文件可以在32位和64位地址空间中运行,具有PE32+头的文件则要求一个64位地址空间。

以下总结了C#编译器指定不同的Platform选项,会得到什么托管模块,以及运行的Windows平台:

知识在与温故、总结-再读CLR

Windows会检查EXE文件头,判断是32位进程,还是64位进程,或者Wow64进程之后,Windows在进程的地址空间中加载MSCoreEE.dll的X86、X64或者IA64版本,进程的主线程会调用MSCoreEE.dll内部定义的一个方法,这个方法会初始化CLR,加载EXE程序集,然后调用其入口方法(Main)方法。随即,被托管的应用程序将启动并运行。

如果一个非托管应用程序通过LoadLibrary来加载一个托管的程序集,Windows会加载并初始化CLR来处理包含在程序集中的代码。

总结Part4:代码执行

前面我们介绍了,托管程序集同时包含元数据和中间语言(IL),IL是一种与CPU无关的机器语言

IL比大多数CPU机器语言要高级的多,IL能访问和操作对象类型,并提供相应的指令来创建和初始化对象,在对象上调用虚方法,并能直接操作数组元素,甚至提供了用于抛出异常和捕获异常的指令,以实现错误处理。因此IL可以被看作是一种面向对象的机器语言。

开发人员使用C#、VB、C++等高级语言来编程实现业务逻辑,这些高级语言的编译器会将源代码编译为IL。

为了执行具体的某一个方法,这个方法对应的IL首先必须转换为本地CPU指令。这是CLR的JIT(Just-in-time)即时编译器的职责。

知识在与温故、总结-再读CLR

上面这段代码是如何执行的?

在Main方法执行之前,CLR会检测出Main的代码引用的所有类型,CLR会分配一个内部数据结构,用于管理对引用类型的访问

示例代码中,Main方法引用了单一类型Console,CLR为Console分配了一个单独的内部数据结构,Console类型中每个方法都对应一条记录,每条记录都容纳了一个地址,根据这个地址就可以找到方法的实现。CLR对这个内部数据结构初始化的时候,每条记录都设置成CLR内部包含的一个未文档化的函数,这个函数称为JITCompiler

Main方法首次调用WriteLine时,会调用JITCompiler函数,JITCompiler函数负责将这个方法的IL代码编译为本地CPU指令,由于IL是“即时”编译的,所以通常将CLR这个即时编译组件称为JIT编译器

第一次调用Console.WriteLine这个方法时:

知识在与温故、总结-再读CLR

  1. JITCompiler函数被调用时,它知道要调用哪个方法,以及具体的类型定义了该方法。
  2. 然后,JITCompiler会在程序集的元数据中搜索被调用方法的IL。
  3. JITCompiler验证IL代码,并将IL代码编译成本地CPU指令,同时本地CPU指令保存在一个动态分配的内存中。
  4. 然后,JITCompiler回到CLR为类型创建的内部数据结构中,找到与被调用方法对应的那条记录,将最初调用它的那个引用替换成CPU指令内存块的地址。
  5. 最后,JITCompiler函数会跳转到内存块中的代码(CPU指令),即Console.WriteLine方法的具体实现。
  6. 代码执行完毕后,返回到Main方法中,继续执行其他代码。

       Main函数第二次调用Console.WriteLine方法

第二次调用时,由于已经对WriteLine方法进行了验证和编译,所以会直接执行内存块中代码(CPU指令),完全跳过了JITCompiler函数。因此,一个方法只有在第一次调用时才会造成一定的性能消耗。后续对此方法的调用都以本地代码的方法全速运行。

知识在与温故、总结-再读CLR

总结:

1. 一个方法只有在第一次调用时的JIT编译才会造成一定的性能消耗。后续对此方法的调用都以本地代码的方法全速运行

2. JIT编译器将本地CPU指令保存在动态内存中,一旦应用程序终止,编译好的本地CPU指令代码会被丢弃。所以,如果关闭重新运行应用程序,或者同时启动了应用程序的两个实例(两个不同的进程),JIT编译器必须再次将IL编译成本地CPU指令.

3. 对于大多数应用程序来说,因JIT编译造成的性能损失并不明显,大多数应用程序倾向于反复调用相同的方法。

4. 同时,CLR的JIT编译器会对本地代码进行优化。代码在优化后将获得更出色的性能。

先临时写到这,10年后重读CLR Via C#,更多的收获是,对.Net 底层技术原理的理解更深,同时有了更敬畏之心。后续计划再重读更多内容,分享给大家。

知识在与分享和总结!

周国庆

2018/6/9