“托管代码”概念
能够执行额外记录一般在“几乎任何时刻”报告其正在使用的有效GC引用的代码,就称做 托管代码 (因为其被CLR“管理”)。不能实现这个目标的代码叫 非托管代码。因此在CLR之前存在的代码都是非托管代码,特别来说,所有操作系统的代码都是非托管的。
堆栈展开的问题
由于托管代码需要用到操作系统的服务,所有有时托管代码需要调用非托管代码。相应的,托管代码最初是操作系统启动的,因此有时非托管代码也会调用托管代码。因此,当你在任意位置中断托管程序,堆栈上混合了由托管代码和非托管代码创建的帧。
非托管代码的堆栈帧对其上运行的程序 没有 要求。即一般不要求 展开 (非托管堆栈帧)来找到它的调用者。这意味着当程序在非托管函数中断时,一般[1] 是没有办法找到它的调用函数的。只能在调试器里才能做到,这是因为在符号(PDB)文件里保存了额外信息。但这些信息不保证一直存在(这就是为什么在调试器里无法获取准确堆栈信息的原因)。这在托管代码里是很大的问题,因为在无法展开的堆栈里可能包含托管代码帧(它包含了需要收集的GC引用)。
托管代码有一些额外需求:不仅是在运行过程中需要跟踪所有的GC引用,而且还需要能展开它的调用函数。另外,无论何时从托管代码到非托管代码(或发过来)的过渡,托管代码都需要做一些额外的记录来避免非托管代码无法展开堆栈带来的影响。实际上,托管代码在堆栈里将托管代码帧链接起来了。因此,即使在无法利用额外信息展开非托管代码堆栈帧的情况下,还是能在堆栈上找到托管代码块并枚举托管代码帧。
[1] 最近的平台ABI(应用程序二进制接口 application binary interfaces)定义了编码这些信息的约定,但其不是一个所有代码都必须遵循的严格规范。
托管代码的“世界”
结果就是进出托管代码的每次过渡都要做特殊的记录。托管代码只能在CLR理解的“世界”。这两个世界是非常不同的(在任何时刻,代码要么在 托管世界,要么在 非托管世界)。而且,因为在CLR格式里定义了托管代码的执行(即 [通用中间语言]cil-spec),并由CLR在原生硬件上解释执行,CLR对运行情况有 非常多 的控制。例如,CRL可以更改从一个对象里获取字段的值或调用一个函数的意思。实际上,CLR在创建MarshalByReference对象时就是这么做的。它们看起来是普通的本地对象,但实际上存在于另外一台机器。简单来说,CLR的托管世界里有很多 运行时钩子 来支持后续章节要介绍的强大功能。
另外,托管代码还有一个很重要但不是很明显的衍生物。在非托管代码里,GC指针是不被允许的(因为其不可被跟踪),在托管和非托管之间的切换有一个记录的成本。这意味着虽然你 可以 在托管代码里调用任意的非托管方法,但过程通常不是很方便。非托管代码无法使用GC对象作为其参数或者返回值,也就是说这些非托管方法创建和使用的任何“对象”或“对象引用”都需要显式释放。这实在是个悲剧。因为这些API无法享受到诸如异常或继承这样的CLR的功能的益处,这将会导致与托管代码交互时“不匹配”的用户体验。
其结果就是大部分非托管接口在暴露给托管代码开发者时是 封装的。举个例子,当访问文件时,一般不使用操作系统提供的Win32 CreateFile 函数,而是用封装了其的System.IO.File托管代码类。实际上直接使用非托管API的地方非常少。
虽然这种封装在一些地方看起来“差劲”(更多的代码不见得做的更多),实际上它还是增加了一些价值的。请记住直接暴露非托管接口总是可能的;但我们选择封装这些功能,为什么?因为整个运行时设计的首要目标是使编程更简单,而且一般来说非托管代码不是足够简单。通常来说,非托管接口一开始就不是为了使用简单而设计的,而是为完整性优化的。当你看到CreateFile或者CreateProcess函数的参数列表时,很难将其归类到简单里。幸运的是,这些接口进入托管世界“整了次容”,虽然过程很“没技术含量”(无非就是重命名,简化,重组其功能),但非常有用。为CLR编写的一个非常重要的文档就是 [Framework Design Guidelines][fx-design-guidelines]。这本800+页的文档详细描述了创建新的托管代码类库的最佳实践。
到这里,我们分析了托管代码和非托管代码里两个重要不同:
- 高科技:代码在两个不同的世界里运行,而CLR在程序运行时的各个方面进行良好的把控(甚至到单个指令级别),而且CLR可以检测什么时候进入或退出托管代码的运行。这点使很多有用的功能变得可能。
- 低技术含量:在托管和非托管代码之间有切换成本,而且非托管代码无法使用GC对象这点事实鼓励用facade模式封装非托管代码。即通过遵循一系列的命名和设计指南来达到一定程度的一致性和可发现性来“整容”并简化(操作系统)接口。
上面两个特性对于托管代码的成功都非常重要。