作者: 松本行弘,节选自《 松本行弘:编程语言的设计与实现》

本文将介绍编程语言处理器的核心部分——虚拟机(Virtual Machine,VM)的实现。运行源代码编译结果的是运行时,运行时有多种实现方法,本文要讲的虚拟机就是其中之一。

用软件实现的 CPU 来运行

虚拟机这个单词有多种不同的含义,本文中指“用软件实现的(无实际硬件的)计算机”。

这与在虚拟机软件和云计算等语境中出现的虚拟机的含义不同。在虚拟机软件等语境中,虚拟机是指通过把实际存在的硬件用某种软件封装进行虚拟化,从而实现多个系统的同时运行以及系统在硬件间的迁移。维基百科中把这种虚拟机归类到了“系统虚拟机”中,而把本文所要介绍的虚拟机归类到了“进程虚拟机”中。

Ruby 到版本 1.8 为止都没有实现(进程)虚拟机,而是通过遍历编译器生成的语法树(支持用指针链接起来的结构体所实现的 Ruby 程序语法的树结构)来运行程序的(图 1-12)。这种方法虽然非常简单,但每执行一个指令都要访问指针,成本不容小觑。在 Ruby 1.8 出来之前大家都说 Ruby 很慢,这就是其中一个原因。

int vm(node* node) { while(node) { switch (node->type) { case NODE_ASSIGN: /* 赋值处理 */ ... break; case NODE_CALL: /* 方法调用处理 */ ... break; ... } /* 跳到下一个节点 */ node = node->next; /* ← 这里慢 */ } }

图 1-12 语法树解释器(概要)

为什么以前的 Ruby 很慢

我觉得需要说明一下为什么这么简单的结构运行速度会那么慢。大家都知道硬盘的访问速度要比内存的访问速度慢很多,可内存的访问速度又如何呢?大家平常写代码时,很少会注意内存的速度吧。

但实际上,CPU 与内存之间的距离出乎意料地远。与 CPU 的执行速度相比,通过内存总线读取指定地址的数据的速度要慢很多。在访问内存时,CPU 只能等待数据的到来,这个等待时间就会对执行速度产生影响。

为了削减这样的等待时间,CPU 中内置了“内存缓存”(memory cache)的机制,该机制简称为“缓存”。缓存是 CPU 电路中嵌入的小容量的高速内存。通过事先将数据从主存读取到缓存中,把对内存的读写转化为对高速缓存的读写,能够削减访问内存的等待时间,提高处理速度。

由于缓存必须嵌入到 CPU 内部,所以其容量有着严格的限制,能够预先读入的数据很少。(现在的 CPU 都把缓存分为多个层级来增大缓存容量。即便如此,容量还是比主存小得多,而且也没有解决难以事先将接下来要访问的内存空间读入到缓存的问题。)

为了有效利用缓存,需要把接下来要访问的内存空间事先读取到缓存中,但这是非常困难的。一般来说,只有在形成内存访问局部性时才可能做到。也就是说,由于程序一次性访问的内存空间非常小且距离非常近,所以会对一次性读取到缓存的内存空间进行多次读写。

在虚拟机上灵活运用缓存

遗憾的是,从缓存访问的立场来看,图 1-12 那样的语法树解释器是最糟糕的。构成语法树的节点都是一个个单独的结构体,各自的地址不一定邻近,也不会连续。这就导致难以事先将接下来要访问的内存空间读入到缓存中。

这里如果将语法树转换为指令序列,并储存到连续的内存空间上,那么内存访问局部性就会有所增强,性能也会因为缓存的作用而得到极大的提升。

Ruby 1.9 中引入的被称为 YARV 的虚拟机就使用这样的方法实现了性能提升。YARV 是 Yet Another Ruby VM(另一个 Ruby 虚拟机)的缩写。之所以叫这个名字,是因为当初开发时已经有多个以运行 Ruby 为目的的虚拟机在开发了。起初,YARV 只是一个实验项目,但在这些虚拟机中只有它达到了能运行 Ruby 语言全部特性的效果,因此最终 YARV 替代了 Ruby 自己的虚拟机。

虚拟机的优点和缺点

采用虚拟机的语言中最有名的应该是 Java 了吧,但虚拟机这项技术并不是在 Java 中首次出现的,而是在 20 世纪 60 年代后期就已经有了。比如,20 世纪 70 年代初出现的 Smalltalk 语言就因从早期就采用了字节码而名声大噪(这只是部分原因)。再往前说,后来设计了 Pascal 语言的尼古拉斯·沃斯(Niklaus Wirth)以 Algol68 语言为基础设计的 Eular 语言据说也完成了虚拟机的实现。Smalltalk 之父艾伦·凯(Alan Kay)说,Smalltalk 的虚拟机的实现受到了 Eular 的虚拟机的启发。

说起 Pascal 就会想起 UCSD Pascal。由加州大学圣地亚哥分校开发的 UCSD Pascal 把 Pascal 程序变更为字节码 P-code 之后运行。将 Pascal 程序变更为 P-code,可以轻松地将 UCSD Pascal 移植到各种操作系统和 CPU 的计算机上,这也使得 UCSD Pascal 作为具有较强移植性的编译器被广泛使用。

从这里我们就能明白,虚拟机最大的优点就是拥有可移植性。配合各种各样的 CPU 生成机器语言的代码生成处理是编译器中最复杂的部分。根据后续出现的各种 CPU 重新开发代码生成处理,对语言处理器的开发者来说是很大的负担。

现在 x86 和 ARM 等架构占据统治地位,CPU 的种类比以往减少了许多,但在 20 世纪六七十年代,新架构层出不穷,甚至同一家公司的同一系列的计算机也会根据型号而使用不同的 CPU。虚拟机在减少这类负担上起到了很大作用。

另外,虚拟机能够配合目标语言进行设计,因此我们就可以将指令集的范围限定在实现这个语言所必需的指令中。与通用 CPU 相比,可以缩小规格,开发也变得更简单。

但虚拟机并非只有优点。与在硬件上直接执行相比,模拟虚拟的 CPU 运行的虚拟机在性能上有很大的问题。采用了虚拟机的语言处理器会产生几倍,甚至几百倍的性能损失。不过我们可以使用 JIT 编译等技术在一定程度上减少这种性能损失。

虚拟机的实现技术

用硬件实现的真正的 CPU 与用软件实现的虚拟机在性能上各有不同。下面我们来看一下虚拟机性能相关的实现技术,以下是具有代表性的几种。

(1) RISC 与 CISC

(2) 栈与寄存器

(3) 指令格式

(4) 直接跳转

RISC是Reduced Instruction Set Computer(精简指令集计算机)的缩写,是通过减少指令的种类、简化电路来提高 CPU 性能的架构。在 20 世纪 80 年代流行的架构中,具有代表性的 CPU 有 MIPS 和 SPARC 等。在移动设备上广泛使用的 ARM 处理器就属于 RISC。

CISC 是与 RISC 相对的一个词汇,是 Complex Instruction Set Computer(复杂指令集计算机)的缩写,简单来说就是“不是 RISC 的 CPU”。CISC 的每个指令执行的处理都非常大,而且指令的种类繁多,因此实现起来也比较复杂。

不过,RISC 与 CISC 的对立是 21 世纪之前的事情了,在如今的硬件 CPU 中,RISC 与 CISC 的对立没有任何意义。这是因为纯粹的 RISC 的 CPU 失去了人气,现在已经很少见到了。即便如此,SPARC 还是存活了下来,被日本超级计算机“京”等设备采用。

RISC 中前景较好的 ARM 也在不断增加指令,朝着 CISC 的方向发展。而作为 CISC 代表架构的英特尔 x86,通过在表面上提供复杂的指令集以维持与过去版本的兼容性,并在内部把指令转换为类 RISC 的内部指令(μ op),从而实现了高速运行。

但对虚拟机来说,RISC 和 CISC 之争有不同的意义。如果是用软件实现的虚拟机,我们就不能忽视取指令(Instruction Fetch,IF)处理所需要的成本。也就是说,做同样的处理时所需的指令数越少越好。好的虚拟机指令集是类 CISC 架构的指令集,它的全部指令都是高粒度的。

虚拟机的指令要尽可能地抽象,程序设计得小一些会比较好。有些虚拟机以紧凑化为目标,提供复合指令,把频繁被连续调用的多条指令整合为一条,这样的技术称为“指令融合”或“super operator”。

栈与寄存器

虚拟机架构的两大流派是栈式虚拟机和寄存器式虚拟机。栈式虚拟机原则上通过栈对数据进行操作(图 1-13),而寄存器式虚拟机的指令中包含寄存器编号,原则上对寄存器进行操作(图 1-14)。

push 1 ← ① 向栈push 1 push 2 ← ② 向栈push 2 add ← ③ 将栈中的两个数相加,然后将结果push到栈中

执行各指令时栈的状态

虚拟机如何进行开发(一篇文章带你了解)(1)

图 1-13 栈式虚拟机的指令及其结构

load R1 1 ← ① 将第1个寄存器赋值为1 load R2 2 ← ② 将第2个寄存器赋值为2 add R1 R1 R2 ← ③ 将第1个寄存器和第2个寄存器的数值相加,并将结果保存到第1个寄存器

图 1-14 寄存器式虚拟机的指令

与寄存器式虚拟机相比,栈式虚拟机更为简单,程序也相对较小。然而,由于所有的指令都通过栈来交换数据,所以对指令之间的先后顺序有很大的依赖,很难实施交换指令顺序这样的优化。

而寄存器式虚拟机由于指令中包含寄存器信息,所以程序相对较大。这里需要注意的是,程序大小与取指令处理的成本不一定相关,这一点我们在后面也会提到。另外,寄存器式虚拟机由于显式指定了寄存器,所以对指令顺序依赖较小,优化空间较大。不过,小规模语言高度优化的例子几乎不存在,所以这一点也就没那么重要了。

那么栈式虚拟机和寄存器式虚拟机哪个更好呢?这个问题现在还没有定论,使用这两种架构的虚拟机都有很多。表 1-2 展示了这两种架构在各种语言的虚拟机中的使用情况。我们发现,即使是同一语言,也会因实现的不同而采用不同的架构,有时采用栈式虚拟机,有时采用寄存器式虚拟机。这种现象很有趣。

虚拟机如何进行开发(一篇文章带你了解)(2)

表 1-2 各种语言的虚拟机架构

……

虚拟机如何进行开发(一篇文章带你了解)(3)

本书由Ruby 之父松本行弘在《日经Linux》杂志上的连载整合而成,主要介绍了新语言Streem 的设计与实现过程。作者从设计Streem 这门新语言的动机开始讲起,由浅入深,详细介绍了新语言开发中的各个环节,以及语言设计上的纠结与取舍,其中也不乏对其他编程语言的调查与思考,向读者展示了创建编程语言的乐趣。

,