Tag Archives: Interpreter

全局解释器锁

我们曾经多次提到过一个术语——全局解释器锁(Global Interpreter Lock,下简称 GIL)。GIL 是一个通用的术语,而 Ruby 1.9 的开发员似乎又赋予了 Ruby 1.9 的 GIL 另一个特有的名称——Giant VM Lock,硬翻为中文就是“巨型虚拟机锁”。

在官方和其它一些 C/C++ 的 Ruby 实现(如 Rubinius),以及其它有着相似线程模型的动态类型语言实现(如 CPython)中都存在着 GIL。顾名思义,它是被集成在解释器核心、为解释器量身打造的一个全局的互斥锁,用来保护解释器内部的共享资源。

虽然 CRuby 解释器的核心部分是线程安全的,但这并不代表本地扩展库提供的代码也是线程安全的(当然,编写扩展的人可以刻意地去写出线程安全的代码)。当我们使用标准库、第三方 Gem 时,我们很多时候都是在使用包含了线程不安全代码的本地扩展,而这些本地扩展多少要访问一些解释器核心的共享资源。一旦线程不安全代码与解释器内核在没有任何线程安全措施的情况下同时访问这些资源,就可能发生各式各样的多任务问题,尤其是竞争条件。这些问题使得我们的程序充斥着奇怪的八阿哥却又极其难以调试,所以我们迫切地需要一个措施来实现线程安全。

通常保证线程安全性有四种途径:

  1. 设计可重入的子程序
  2. 使用线程局部存储
  3. 仅使用原子操作
  4. 多任务同步(引入互斥锁)

然而,前三种途径并不能解决 Ruby 解释器的资源分配问题。解释器的共享资源对于一个解释器实例(解释器进程)来说具有全局性。既然解释器核心和本地扩展都需要变异全局静态的数据,那么即便解释器核心可重入,本地扩展仍然可以在并发时变异这些数据,使得解释器核心代码线程不安全。这也强调了一点:可重入并不意味着线程安全。由于涉及到共享资源,仅仅使用第二种途径的线程局部存储自然也是不够的。原子操作的定性和底层平台具体的(虚拟或物理)机器指令有关,对于一个需要跨平台的解释器实现来说,依赖原子操作而实现并发并不是一个好习惯。最后我们只剩下通过互斥锁设定一个内存屏障,建立临界区这个方案。

同步多任务也可分为细加锁(Fine-grained locking)和粗加锁(Coarse-grained locking)两种加锁方案。细加锁是一种高密度加锁,它在所有和共享资源有关的结构上分别加锁(这些锁相互独立);粗加锁则是一种低密度加锁,它使用一个被所有结构共享的全局锁。

假设某组织一幢办公大楼,有若干工作人员和若干工作室,工作人员需要按工作室的顺序进入各个工作室工作,而从当前工作室出来到进入下一个工作室之间的这段时间他们也有一些别的任务需要完成。细加锁,就好比这些工作人员一旦进入其中一个工作室就将其门锁住,直到自己工作结束,再解锁出门。这时可能有若干工作人员为了进入某个工作室而被迫等在门外,但也可能有若干工作人员正在完成不需要在工作室中进行的任务(即前文所说的从当前工作时出来到进入下一个工作室之间需要完成的任务)。在另一方面,粗加锁就好比工作室并没有锁,但整个办公大楼有一道大锁,一旦有一个工作人员进入办公大楼就锁住大楼入口,其它任何人都不得进入。每一个工作人员进入后可以自由工作 15 分钟,之后解锁走出办公大楼,让下一个工作人员进入并工作 15 分钟,如此反复。

这里的工作人员就相当于线程,而工作室则是有着共享资源的临界区,办公大楼好比是整个进程。显然,较之粗加锁,细加锁有更高的并发性,有更多的工作人员可以在同一时刻工作。然而,细加锁在工作人员每次出入工作室时都多了一道解锁、加锁的程序。试想该组织只有一个工作人员,他独自一人在办公大楼中自是通行无阻,也无人和他争夺工作室,又何须频繁加锁解锁?这在现实世界中是举手之劳,在某些语言实现(比如曾经依赖于引用计数的 CPython 垃圾回收器)中却会带来巨大的开销。因此,细加锁会降低单线程程序的执行效率,提高多线程程序的执行效率,而粗加锁反之。

想必看官业已猜到,粗加锁的这把锁,就是我们的话题——GIL。Ruby 语言的创始人 Matz 在他的 MRI 实现中使用的是 GIL 进行粗加锁。他之所以做出这个抉择,无非是为了不用考虑本地 Ruby 扩展的线程安全问题,以及提高单线程程序的执行效率。此举是否得当当然也是可商榷的。有些人宁愿在写扩展的时候自行考虑线程安全问题,也不愿为了代码简易而丢失效率。而从长远来看,程序迟早将全面步入并发计算领域,为了单线程执行效率而牺牲多线程执行效率的意义似乎越来越小了。

Ruby 1.8 内置的 Thread 类使用的是绿色线程,而并非本地线程。Ruby 1.8 的解释器通过模拟本地 OS 的排程机制实现了 Ruby 层线程之间的周期性切换。因此,自始至终每个 Ruby 解释器进程都只有一个真正的用于求值的本地线程(不包含解释器的辅助线程)。哪怕有再多的 CPU 内核,在 Ruby 1.8 中也绝不可能实现真正的并发。更甚的是,当你调用某个外部的耗时函数时(比如进行阻塞 Socket),由于调用线程是唯一的一个负责绿色线程调度的本地线程,整个解释器进程都会因之而阻塞,Ruby 层的绿色线程自然也会尽数僵死。出于这两点考虑,Ruby 1.9 的 Thread 类使用了本地线程。然而,由于 GIL 的存在,两个 Thread 的实例仍然不能真正的并发运行。既然如此,将绿色线程替换为本地线程的意义何在呢?

首先,绿色线程的调度是在用户空间中管理的,在很多情况下下效率不及由 OS 在内核空间中管理的本地线程;其次,本地线程可以解决解释器进程整体阻塞的问题。Ruby 1.9 默认将部分耗时运算(如 Bignum 的乘法)分配到了一个本地线程中运行,并在进行该运算时暂时释放 GIL,直到运算结束再重新收回 GIL。在 GIL 被释放的这段时间里,其它 Ruby 层的线程就可以运行,那么在多核 CPU 的环境下,即便很短暂,也实现了真正意义上的并发。在写本地 Ruby 扩展的时候,用户可以调用 rb_thread_blocking_region 这个函数来进行可能导致阻塞的操作,其内部其实就是封装了一个释放并收回 GIL 的过程。

为了科学严谨性,我们最后还要强调一个事实,那就是采用 GIL 只是一个实现细节而并非语言特性。比如,同样是 Ruby 语言,基于 Sun JVM 的 JRuby 就不需要 GIL,因为它在底层依赖于有着自己的独特线程模型的 Java 虚拟机。Sun JVM 并不依赖于 GIL 的保护,相反它依赖于细加锁,这和现代化的 GC、高效的 JIT 乃至于整个虚拟机的复杂度都是密切相关的,这也是为什么 Sun JVM 的性能能得到公众的认可。

编译性和解释性语言实现

你可能听说过“解释性语言”这个词,它意味着一种间接被软件解释器程序执行(解释)的语言;与之相对的是“编译性语言”,即可以直接被翻译为机器码并交给 CPU 执行的语言(实际上,CPU 也可以被看作一种机器指令的解释器,只不过是硬件集成的)。然而,无论是“解释”或是“编译”,都并非是语言本质上的特性,而只是实现语言的方式。理论上来说,任何语言都同时可以被编译和解释。所以,“解释性语言”、“编译性语言”在实践时通常是指“频繁被解释的语言”、“频繁被编译的语言”。

早期的程序语言,要么就是被编译,要么就是被解释,所以他们只需要编译器或解释器的其中一个。比如,Shell 脚本就总是由命令行解释器(软件)来执行,而 C 程序总是由 C 编译器一次性翻译为机器码。随着计算机科学的不断发展,这种单调性也逐渐被淘汰,出现了很多使用混合模式的语言实现——同时拥有解释性和编译性。

官方的 Ruby 实现(CRuby)在 1.9 版本之前还没有一个虚拟机,所以它只有一个被称为 MRI(Matz’s Ruby Interpreter)的解释器。MRI 是一个纯解释性质的 AST walker(AST = Abstract Syntax Tree),硬直翻译成中文就是“抽象语法树步行器”,意即是在表示了整个程序的抽象语法树上行走并求值(之所以说“抽象”,是因为这个语法树并不一定非得有一个物理的树结构)。

到了 CRuby 1.9 版本时,出现了 YARV 这个 Ruby 虚拟机,“解释性”再也不是 CRuby 的唯一特性。在执行 Ruby 程序时,YARV 会首先将 Ruby 代码编译为 YARV 虚拟机器指令,这是一种中间代码,和 JVM 的字节码,CLR 的 托管代码类似。之后,YARV 再在这一系列 YARV 指令上进行动态的解释。所以 YARV 即是编译器,也是解释器。它进行了一种被称为 预编译(AOT,Ahead of Time Compilation)的处理,使得优化代码的空间得到了提升。

同理,同样是基于虚拟机之上的 JRuby 和 IronRuby 等实现自然也都会先对代码进行编译,产生一种中间代码。当然,他们还有别的模式,比如下文提到的 JIT 编译。

为了进一步强调第一段黑体文字所描述的事实,这里再举两个例子。

C 语言通常被描述为“编译性语言”,但实际上只是“频繁被编译的语言”。目前已经存在很多 C 解释器以及 C 虚拟机,能够让 C 被解释执行,或混合编译和解释执行。两个比较有名的是 CINT 和 Ch。

Sun 的 Java 实现更是典型的混合模式实现。它的虚拟机(JVM)发展至今,已然十分健全,除了预编译器以外,还有即时编译器(JIT,Just-in-Time Compiler),充分应用了 LLVM 结构。即时编译机制会进行运行时程序概要分析,巧妙地找到所谓的被经常执行的被称为“热点”(hot-spot)的一段指令,在运行时重编译为本地机器码,并让 CPU 静态执行。基于 JVM 之上的 JRuby 也早已支持 JIT 了。