如前所述,托管程序集同时包含元数据和IL。IL是与CPU无关的机器语言,是Microsoft在请教外面的几个商业及学术性语言/编译器的作者之后,费尽心思开发出来的。IL比大多数CPU机器语言都要高级。IL能访问和操作对象类型,并提供了指令来创建和初始化对象、调用对象上的虚方法以及直接操作数组元素。它甚至提供了抛出和捕捉异常的指令来实现错误处理。可将IL视为一种面向对象的机器语言。
通常,开发人员是使用C#,C++/CLI或者Visual Basic等高级语言来编程。这些高级语言的编译器将生成IL。然而,和其他任何机器语言一样,IL也能使用汇编语言来写,Microsoft甚至专门提供了一个名为ILAsm.exe的IL汇编器和一个名为ILDasm.exe的IL反汇编器。
注意,高级语言通常指公开了CLR的所有功能的一个子集。然而,IL汇编语言允许开发人员访问CLR的所有功能。所以,如果你选择的一种变成语言隐藏了你迫切需要的一个CLR功能,可以换用IL汇编语言或者提供了所需功能的另一种编程语言来写那部分代码。
要知道CLR具体提供了哪些功能,唯一的办法就是CLR文档。本书致力于讲解CLR的功能,以及C#语言如何公开这些功能。对于C#没有公开的CLR功能,本书也进行了说明。相比之下,其他大多数书记和文章都是从一种语言角度讲解CLR,造成大多数开发人员误以为CLR只提供了他们选用的那一种语言所公开的那一部分功能。不过,只要用一种语言就能达到目的,这种误解也并不一定是一件坏事。
重要提示:在我看来,允许在不同编程语言之间方便地切换,同时又保持紧密集成,这是CLR的一个非常出色的特性。遗憾的是,许多开发人员都忽视了这一特性。例如,C#和Visual Basic等语言能很好的执行I/O,APL语言能很好地执行高级工程或金融计算。通过CLR,应用程序的I/O部分可用C#编写,工程计算部分则换用APL编写。CLR在这些语言之间提供了其他技术无法媲美的集成度,是“混合语言编程”成为许多开发项目一个值得慎重考虑的选择。
为了执行一个方法,首先必须把它的IL转换成本地CPU指令。这是CLR的JIT(just-in-time或者“即时”)编译器的职责。
图1-4展示了一个方法首次调用时发生的事情。
图1-4 方法的首次调用
就在Main方法执行之前,CLR会检测出Main的代码引用的所有类型。这导致CLR分配一个内部数据结构,它用于管理对所引用的类型的访问。在图1-4中,Main方法引用了一个Console类型,这导致CLR分配一个内部结构。在这个内部数据结构中,Console类型定义的每个方法都有一个对应的记录项。每个记录项都容纳了一个地址,根据此地址即可找到方法的实现。对这个结构进行初始化时,CLR将每个记录项都设置成(指向)包含在CLR内部的一个文档化的函数。我将这个函数称为JITCompiler。
Main方法首次调用WriteLine时,JITCompiler函数会被调用。JITCompiler函数负责将一个方法的IL代码编译成本地CPU指令。由于IL是“即时”(just in time)编译的,所以通常将CLR的这个组件成为JITter或者JIT编译器。
注意:如果应用程序在Windows的x86版本或者WoW64中运行,JIT编译器将生成x86指令。如果应用程序以一个64位应用程序的形式在Windows的x64或Itanium版本上运行,那么JIT编译器将分别生成x64或IA64指令。
JITCompiler函数被调用时,它知道要调用的是哪个方法,以及具体是什么类型定义了该方法。然后,JITCompiler会在定义(该类型的)程序集的元数据中查找被调用的方法的IL。接着,JITCompiler验证IL代码,并将IL代码编译成本地CPU指令。本地CPU指令被保存到一个动态分配的内存块中。然后JITCompiler返回CLR为类型创建的内部数据结构,找到与被调用的方法对应的那一条记录,修改最初对JITCompiler的引用,让它现在指向内存块(其中包含了刚才编译好的本地CPU指令)的地址。最后,JITCompiler函数跳转到内存块中的代码。这些代码正是WriteLine方法(获取单个String参数的那个版本)的具体实现。这些代码执行完毕并返回时,会返回至Main中的代码,并跟往常一样继续执行。
现在,Main要第二次调用WriteLine。这一次,由于已对WriteLine的代码进行了验证和编译,所以会直接执行内存块中的代码,完全跳过JITCompiler函数。WriteLine方法执行完毕之后,会再次返回Main。图1-5展示了第二次调用WriteLine时发生的事情。
图1-5 方法的第二次调用
一个方法只有在首次调用时才会造成一些性能损失。以后对该方法的所有调用都以本地代码的形式全速运行,无需重新验证IL并把它编译成本地代码。
JIT编译器将本地CPU指令存储到动态内存中。一旦应用程序终止,编译好的代码也会被丢弃。所以,如果将来再次运行应用程序,或者同时启动应用程序的两个实例(使用两个不同的操作系统进程),JIT编译器必须再次将IL编译成本地指令。
对于大多数应用程序,因JIT编译造成的性能损失并不显著。大多数应用程序都会反复调用相同的方法。在应用程序运行期间,这些方法只会对性能造成一次性的影响。另外,在方法内部花费的时间很有可能被花在调用方法上的时间多得多。
还要注意的是,CLR的JIT编译器会对本地代码进行优化,这类似于非托管C++编译器的后端所作的工作。同样地,可能要花费较多的时间来生成优化的代码。但是,和没有优化时相比,代码在优化之后将获得更出色的性能。
有两个C#编译器开关会影响代码的优化:/optimize和/debug。下面总结了这些开关对C#编译器生成的IL代码的质量的影响,以及对JIT编译器生成的本地代码的质量的影响。
编译器开关设置 |
C# IL代码质量 |
JIT本地代码质量 |
/optimize-/debug-(默认) |
未优化 |
有优化 |
/optimize-/debug(+/full/pdbonly) |
未优化 |
未优化 |
/optimize+ /debug(-/+/full/pdbonly) |
有优化 |
有优化 |
如果使用/optimize-,在C#编译器生成的未优化IL代码中,将包含许多NOP(no-operation,空操作)指令;还包含许多分支指令,它们用于跳转到下一行代码。利用这些指令,Visual Studio可提供调试期间的“编辑并继续”(edit-and-continue)功能。另外,利用这些额外的指令,还可在控制流程指令(比如for,while,do,if,else,try,catch和finally语句块)上设置断点,使代码更容易调试。相反,如果生成优化的IL代码,C#编译器会删除这些多余的NOP和分支指令,使代码变得难以在调试器中进行单步调式,因为控制流程会被优化。另外,在调试器中执行时,有的函数的求值过程可能难以为继。不过,在优化之后,IL代码变得更小,结果的EXE/DLL文件也更小。另外,如果你像我一样,喜欢检查IL来理解编译器生成的东西,这种IL显得更容易理解。
除此之外,只有在指定/debug(+/full/pdbonly)开关的前提下,编译器才会生成一个Program Database(PDB)文件。PDB文件帮助调试器查找局部变量并将IL指令映射到源代码。/debug:full开关告诉JIT编译器你打算调试程序集,JIT编译器会帮你记录每一条IL指令所生成的本地代码。这样一来,就可利用Visual Studio的JIT调试器功能,将一个调试器连接到一个正在运行的进程,并方便地对源代码进行调试。如果没有指定debug:full开关,JIT编译器默认不会记录IL与本地代码的联系,这会使JIT编译器运行得稍微快一些,用的内存也会少一些。如果一个进程是用Visual Studio调试器来启动的,会强迫JIT编译器跟踪记录IL与本地代码的联系(无论/debug开关的设置是什么)--除非在Visual Studio中关闭了“在模块加载时取消JIT优化(仅限托管)”选项。
在Visual Studio中新建一个C#项目时,项目的“调试”(Debug)配置指定的是/optimize-和/debug:full开关,而“发布”(Release)配置指定的是/optimize+和/debug:pdbonly开关。
一直使用非托管C或C++的开发人员可能担心所有这些对于性能的影响。毕竟,非托管代码是针对一种具体的CPU平台编译的。一旦调用,代码直接就能执行。但在这种托管环境中,代码的编译是分两个阶段完成的。首先,编译器遍历源代码,使尽可能多的工作来生成IL代码。但是,为了真正得以执行,这些IL代码本身必须在运行时编译成本地CPU指令,这需要分配更多的内存,并需要花费额外的CPU时间。
事实上,我自己最初也是从C/C++的背景开始接触CLR的,当时也对此持怀疑态度,并格外关心这种额外的开销。经过实践,我发现运行时发生的第二个编译阶段确实会影响性能,也确实会分配动态内存。但是,Microsoft进行了大量性能优化工作,将这种额外的开销保持在最低限度。
如果仍然不放心,就实际生成一些应用程序,亲自测试一下性能。此外,应该运行由Microsoft或其他公司生成的一些比较正式的托管应用程序,并测试其性能。相信它们出色的性能表现会让你喜出望外。
虽然这样说很难让人信服,但许多人(包括我)都认为托管应用程序的性能实际上超过了非托管应用程序。有许多原因使我们对此深信不疑。例如,当JIT编译器在运行时将IL代码编译成本地代码时,编译器对执行环境的认识比非托管编译器更加深刻。下面列举了托管代码相较于非托管代码的优势:
- JIT编译器能判断应用程序是否运行在一个Intel Pentium 4 CPU上,并生成相应的本地代码来利用Pentium 4支持的任何特殊指令。相反,非非托管应用程序通常是针对具有最小功能集合的CPU编译的,不会使用能提升应用程序性能的特殊指令。
- JIT编译器能判断一个特定的测试在它运行的机器上是否总是失败。例如,假定一个方法包含以下代码:
if(numberOfCPUs > 1){ ... }
如果主机只有一个CPU,JIT编译器不会为上述代码生成任何CPU指令。在这种情况下,本地代码将针对主机进行优化,最终的代 码变得更小,执行得更快。
- 应用程序运行时,CLR可以评估代码的执行,并将IL重新编译成本地代码。重新编译的代码可以重新组织,根据刚才观察到的执行模式,减少不正确的分支预测。虽然目前版本的CLR还不能做到这一点,但将来的版本也许就可以了。
除了这些理由,还有另一个理由使我们相信在执行效率上,未来的托管代码会比当前的非托管代码更优秀。大多数托管应用程序目前的性能已相当不错,将来还有望进一步提升。
如果试验表明,CLR的JIT编译器似乎没有使自己的应用程序达到应有的性能,那么为了进一步确认,还应该使用.NET Framework SDK配套提供的NGen.exe工具。这个工具将一个程序集的所有IL代码编译成本地代码,并将这些本地代码保存到一个磁盘文件中。在运行时,一旦加载一个程序集,CLR就会自动判断是否存在该程序集的一个预编译版本。如果是,CLR就加载预编译代码。这样一来,就避免了在运行时进行编译。注意,NGen.exe对最终执行环境做出的假设是非常保守的(它不得不如此)。所以,NGen.exe生成的代码不会像JIT编译器生成的代码那样进行高度优化。本章稍后将进一步详细讨论NGen.exe。
注解:
本书将entry翻译成“记录项”,其他译法还有条目、入口等等。虽然某些entry包含了一个地址,所以相当于一个指针,单并非所有entry都是这样的。在其他entry中,还可能包含了文件名、类型名、方法名和位标志等信息。