Tag Archives: VM

全局解释器锁

我们曾经多次提到过一个术语——全局解释器锁(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 了。

Ruby 1.9 虚拟机 YARV

我们来简单的看一下提升 Ruby 1.9 性能的大功臣——YARV。

YARV,全称是 Yet Another Ruby VM,VM 的全称是 Virtual Machine。这个名称,翻译为中文,就是“又一个 Ruby 虚拟机”。这种以 `Yet Another’ 形式开头的命名是一种黑客行话,在有英语语感的程序员看来,这种命名有三分幽默诙谐之感,在业内十分受欢迎。

Ruby 的虚拟机,就和 Oracle 的 JVM(Java 虚拟机)、微软的 CLR(.NET) 一样,是一种由软件实现的虚拟的机器构架,有着自己的指令集,并可以像物理机器一样执行指令。这种针对某个(家族)语言设计的虚拟机被称为进程虚拟机,因为它只是给操作系统上某一个进程(解释器)服务的;与之并列的是系统虚拟机,它需要分享整个系统的物理硬件资源,比如我们 VirtualBox 下的 Guest OS,就是一种虚拟一整个操作系统的技术。

为什么 Ruby 需要虚拟机?大多数程序员都是人类,人类写的代码,通常是按照人类思考的方式去对机器下达命令,但并不一定有着最高的执行效率。同样效果的代码,在很高程度上都存在着另一份更高效的途径,而我们把找寻并迈向这个途径的过程称为优化。对于编译性语言,我们可以设计一个具有自动优化(同时从源代码层、汇编层)的编译器来一次性最优化我们的代码,并编译为机器码;而对于解释性语言,由于我们是在动态地解释脚本,所以不能把代码一次性地优化并编译为机器码。

Ruby 1.8 的解释器被称为 Matz’s Ruby Interpreter(MRI),它解析 Ruby 源文件,并生成一个语法树,然后直接在这个语法树上进行动态求值。这样的过程是毫无优化的。目前主流的解决方案大致可分为两种,其一是采用预编译(Ahead-of-time Compilation),其二是采用即时编译(Just-in-time Compilation)。就 Ruby 1.9.2 的 YARV 来说,它只有前者。所谓预编译,顾名思义,就是预先把代码编译。什么,我之前不是说过“不能把代码一次性地优化并编译为机器码”?是的,的确是这样,但这里的“预编译”不是编译为机器码,而是编译为一种中间代码,通常被称为字节码。字节码和机器码的不同在于,前者是平台无关的,且具备动态性,是由解释器(虚拟机)动态解析的,而不像机器码是直接交给底层的硬件(CPU)去执行。在把可供人类阅读的源代码编译为字节码的过程中,我们就可以进行各式各样的优化,最后产生的字节码,在大部分场合下都比原来的源代码更加高效。

题外话: YARV 的作者 Sasada Koichi 也曾尝试过引入 JIT 即时编译,进一步提高 YARV 的性能,但 YARV 的主页很久没有更新了,也不知进展如何。JIT 本身是一个十分复杂、很难实现的技术,可能对他来说并不是一个人能实现的了的。也许,Ruby 2.0 会……

YARV 就是这样的一个虚拟机,它忠心耿耿地优化着我们所写下的 Ruby 代码,几十年如一日,在大量的 Benchmark 测试下,展示了 Ruby 1.9 几近于 Ruby 1.8 两倍的性能。

在 Ruby 1.9 的官方实现(CRuby)中,有了一个内置的类,叫做 RubyVM。它提供给了用户一些在运行时获取 YARV 状态的实用接口,这里介绍几个比较重要的类和方法。

RubyVM::InstructionSequence,这是一个 YARV 指令序列的类,它可以用来动态地编译、反汇编、执行 Ruby 代码。

instrs = RubyVM::InstructionSequence.compile <<-START
  a = 0
  a = a + 1
  a += 2
  p a
START

puts instrs.disassemble

这里我们先编译了四行简单的 Ruby 代码,并建立了一个 RubyVM::InstructionSequence 对象。之后我们把反汇编后的指令序列按照 Ruby 提供的可供人类阅读的方式打印了出来,输出:

== disasm: <RubyVM::InstructionSequence:@>==========
local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1] s1)
[ 2] a
0000 trace            1                                               (   1)
0002 putobject        0
0004 setlocal         a
0006 trace            1                                               (   2)
0008 getlocal         a
0010 putobject        1
0012 opt_plus
0013 setlocal         a
0015 trace            1                                               (   3)
0017 getlocal         a
0019 putobject        2
0021 opt_plus
0022 setlocal         a
0024 trace            1                                               (   4)
0026 putnil
0027 getlocal         a
0029 send             :p, 1, nil, 8, 
0035 leave

可以看到,四行 Ruby 代码在 YARV 层实际上就是这样的指令序列。我们可以通过 RubyVM::INSTRUCTION_NAMES 来打印出 YARV 所有的指令:

["nop", "getlocal", "setlocal", "getspecial", "setspecial", "getdynamic", "setdy
namic", "getinstancevariable", "setinstancevariable", "getclassvariable", "setcl
assvariable", "getconstant", "setconstant", "getglobal", "setglobal", "putnil",
"putself", "putobject", "putspecialobject", "putiseq", "putstring", "concatstrin
gs", "tostring", "toregexp", "newarray", "duparray", "expandarray", "concatarray
", "splatarray", "checkincludearray", "newhash", "newrange", "pop", "dup", "dupn
", "swap", "reput", "topn", "setn", "adjuststack", "defined", "trace", "definecl
ass", "send", "invokesuper", "invokeblock", "leave", "finish", "throw", "jump",
"branchif", "branchunless", "getinlinecache", "onceinlinecache", "setinlinecache
", "opt_case_dispatch", "opt_checkenv", "opt_plus", "opt_minus", "opt_mult", "op
t_div", "opt_mod", "opt_eq", "opt_neq", "opt_lt", "opt_le", "opt_gt", "opt_ge",
"opt_ltlt", "opt_aref", "opt_aset", "opt_length", "opt_succ", "opt_not", "opt_re
gexpmatch1", "opt_regexpmatch2", "opt_call_c_function", "bitblt", "answer"]

值得注意的还有一个方法:

p RubyVM::InstructionSequence.compile_option

输出:

{:inline_const_cache=>true, :peephole_optimization=>true, :tailcall_optimization
=>false, :specialized_instruction=>true, :operands_unification=>false, :instruct
ions_unification=>false, :stack_caching=>false, :debug_level=>0}

这其实是 YARV 使用的一些优化方法。就目前来说,有很多高级优化技术,YARV 都还没有使用上,可见 Ruby 的性能提升还是有很大空间的。

Continuation

在 Ruby 1.8 中有一个内置类 Continuation,中文直翻是“延续”。这是计算机科学中一个比较底层的概念,它表示进程执行的某一特定时刻的计算过程的实例。通俗的讲,它就是 Ruby 中比 Binding 更加底层的一个能保存当前进程执行环境的物件,就好比游戏里,我们通过存档就能在环境改变之后重新读档,从档案记录的环境,记录的那一刻开始。“延续”一词,即是暗示了从记录的计算过程中的一个点继续执行之意。

Ruby 程序(CRuby 实现)的执行使用了相比于堆要更高效的栈结构来进行函数调用和局部变量的保存,而 Continuation 正是保存了进程运行在某一时刻时的栈内存状态,之后用户可以手动地去调用这个 Continuation 对象,让程序从这一个预先保存的记录点运行。这相当于不少语言中支持的 goto 语句跳转的一个超级版,因为 goto 只能进行局部跳转,而 Continuation 能上天入地,翻江倒海,一个筋斗十万八千里(实际上在 C 标准库中,Continuation 的功能是等同于 setjmp 和 longjmp 这两个函数所提供的功能的)。

有什么用?RM 有一个快捷键为 F12 的重置游戏的功能,当触发了这个命令时,RM 会让解释器从头开始重新执行脚本编辑器里的脚本,但并没有初始化符号表、GC 和堆结构。这使得原有的全局变量、常量、模块、类得以保存,但也可能会让我们与我们的老朋友——stack level too deep——再度重逢。由于脚本中大量使用 alias 别名方法,当 F12 按下后,没有进行判断的 alias 语句再次执行,就会导致两次同名一个方法后产生的经典 BUG(关于这个问题,这里不再赘述,论坛上已经有太多相关的帖子,请自行搜索 stack level too deep)。这个问题可以通过 method_defined? 等方法判断将要 alias 的方法是否已经定义来解决(alias 一次后方法就已定义了,第二次不会再定义),但并不是所有脚本编写者都有这个习惯。这里我们介绍一种使用 Continuation 的通用解法:

在 Main 脚本里的第一行插入一句:

callcc { |$__f12_no_reeval| }

这一句脚本让 RM 在执行到 Main 脚本时将一个保存了当前执行环境的 Continuation 对象赋给了 $__f12_no_reeval 这个全局变量;接着在脚本编辑器开头插入:

$__f12_no_reeval and $__f12_no_reeval.call

这句是先判断 $__f12_no_reeval 是否已初始化(即已经执行到 Main 脚本一次以上),如果是则调用这个 Continuation,让程序直接跳转到 Main 脚本开头的环境继续执行,避免了从开头到 Main 脚本之间的所有脚本的重新执行。由于产生冲突的、包含 alias 的脚本在这些脚本之中,保证只执行它们一次就从根本杜绝了这个问题的滋生。

在 Ruby 1.8 中,Continuation 的实现模型是与线程的实现模型一致的——线程调度器在进行绿色线程切换的时候,会进行类似 Continuation 的环境跳转。在 Ruby 1.9 中,由于 YARV 虚拟机采用的是本地线程,每个线程拥有自己的私有栈,他们之间就产生了区别。有趣的是,在 Ruby 1.9 中又引入了一个新的和 Continuation 类似的概念,那就是我们的 Fiber(纤程),一种轻量级的线程,它基本可以被看作是 Continuation 的替代品,同时由于效率比 Continuation 高(Continuation 保存栈内存状态的过程开销是巨大的),Ruby 社区就是否保留 Continuation 进行了激烈的辩论,就 1.9 而言,Continuation 仍然被作为标准库保留了下来。