最近的一次对Ruby创始人Matz(Yukihiro Matsumoto,松本行弘)和YARV创始人笹田耕一(Sasada Koichi)的采访,就Ruby对线程的处理这个话题进行了深入探讨。目前,Ruby的稳定版本使用的是用户空间线程(user space threads,也称为“绿色线程 [green threads]”),也就是说Ruby解释器负责线程调度的每一个细节。这就和内核线程(Kernel Threads)形成了对比,在后者中,线程的创建、调度和同步都是由OS的系统调用完成的,这使得这些操作代价高昂——至少和它们在用户空间线程中的等价物相比确实如此。从另一个角度来说,用户空间线程无法利用多核或者多CPU(因为OS不知道这些线程的存在,因此无法在这些核/CPU上调度它们)。
Ruby 1.9在近期将YARV作为新的Ruby VM整合了进来,这是1.9中带来的改变之一,它将内核线程引入了Ruby。内核线程(也叫“原生线程[native threads]”)的引入赢得了广泛掌声,尤其是来自Java和.NET平台的开发人员更是交口称赞,因为这两个平台下用的就是内核线程。尽管如此,阻碍还是存在着。笹田耕一解释道:
大家知道,YARV支持原生线程。这就是说,你可以并发地把每一个Ruby线程运行在每一个原生线程上。
这并不是说,每一个Ruby线程都是并行运行的。YARV里有一把全局VM锁(全局解释器锁),只有唯一在运行的Ruby线程才能拥有。我们大家可能很乐于看见这样的决定,因为我们可以把用C语言写成的大部分扩展运行起来,而不需要进行任何改动。
这就意味着:不管存在着多少个内核或者CPU,只有一个Ruby线程能在任意给定时间里运行。解决方法还是存在的,原生的扩展可以以更加灵活的方式处理全局解释器锁(Global Interpreter Lock,GIL),例如,在开始长时间操作之前将锁释放。笹田耕一解释了释放GIL的可用API:
你必须在进行阻塞操作之前释放巨型VM锁。如果你需要在扩展库中这么做,请使用rb_thread_blocking_region()
这个API。
API:rb_thread_blocking_region (
blocking_func, /* function that that will block */
data, /* this will be passed above function */
unblock_func /* if another thread cause exception with Thread#raise,
this function is called to unblock or NULL */
)
问题在于:这样做有效地排除了对内核线程最大的赞成观点——对多核或者多CPU的利用,而又保留了内核线程的问题。
内核线程的引入也是Continuation可能在今后的Ruby版本中移除的原因。Continuation是协作调度(cooperative scheduling)的一种方式,即吧一个线程中执行的操作显式地转给另一个来控制。这个特性也以“协同程序(Coroutine)”之名为人所知,并且存在了很长的一段时间。最近,因为基于Smalltalk的Web框架Seaside使用了Continuation非常显著地简化了Web应用,它也逐渐开始在公众眼前亮相。
这个结合GIL使用内核线程的方式和Python的线程系统是很相似的,后者同样使用GIL,而且用了很长一段时间。Python的GIL引发了无数论战,探讨怎样将其移除,尽管争论热火朝天,GIL仍然悍然不动。
然而,我们考察一下Python语言的创立者Guido van Rossum对于线程的看法,不难发现Ruby线程调度可选的一条未来之路。在最近一篇关于GIL的帖子里,Guido van Rossum解释说:
然而,没错,GIL并不像你最初想象的那么坏:你要做的就是赶快从Windows和Java支持者的洗脑中恢复过来,他们似乎认为线程仅仅是同步活动的唯一实现方式。
仅仅因为Java曾经以在不能支持多地址空间的机顶盒OS上运行作为目标,或者只是由于在Windows中创建进程曾经慢得和狗一样,并不意味着与线程相比,多进程(加上对IPC的合理使用)对于多CPU机器来说就不是一种好多得多的方法。
你所要做的,就是对加锁、死锁、锁的粒度、活锁、非确定性和竞争条件(race conditions)的邪恶组合说“不”。
关于共享地址空间、有抢先调度权的线程所能带来的益处的争论由来已久。Unix作为单线程或者用户空间线程系统的时间最长,它的并行操作是以多个线程通过不同的进程间通信(InterProcess Communication,IPC)的形式(如管道、先进先出[FIFOs]或者显式共享的内存区)来实现的。这是通过fork
系统调用的方式支持的,这种方式可以以低廉的代价复制正在运行的进程。
近来,诸如Erlang之类的语言因为同样使用了一种无共享(share nothing)的方式(也称为“轻量级进程”)+简单的IPC方法,开始受到青睐。“轻量级进程(lightweight processes)”并不是OS进程,实际上存在于相同的地址空间之内。它们之所以被称为“进程”,是因为它们无法窥探彼此的内存空间。“轻量级”则是由于它们是由用户空间的调度程序处理的。在很长一段时间内,这意味着Erlang拥有和其它在用户空间进行线程调度的系统一样的问题:不支持多核或者多CPU,并且阻塞性系统调用将阻塞所有线程。不过最近,有人采用了m:n的方式解决了这些问题:目前Erlang运行时使用多个内核线程,每个线程都运行着一个用户空间调度器。这就是说,现在Erlang可以利用多核或者CPU,而且不需要改变自身的运行模式。
Ruby社区很有幸,Ruby团队已经知晓此事,并且考虑将它作为Ruby线程调度的未来方向:
[...]如果我们在一个进程内有多个VM实例,这些VM就可以同步运行。我会在近期着手此事(作为我的研究课题)。
[...]如果原生线程存在许多许多问题,我将实现绿色线程。大家知道,相比原生线程它有一些优势(如轻量级线程创建等)。这会是一次很有趣的Hack过程(跟大家说一声,我的毕业论文就是在我们特定的SMT CPU上实现用户级线程库)。
这就表明,Ruby的用户空间(绿色)线程版本并不会从议事桌上撤离,特别是因为在不同OS上线程系统的实现问题,例如这个问题:
使用原生线程编写代码有它自身的问题。例如,在MacOSX上,如果有其它线程运行的话(,
exec()
不能正常工作(会引发异常)(这是移植性问题之一)。如果我们在使用原生线程时发现严重问题,我会把绿色线程的版本放到代码主干上(YARV)。
为什么会需要笹田耕一的多VM(Mutilple VM)方案呢?运行多个Ruby解释器,并使它们以IPC方式进行通信(例如,通过Socket)在今天看来也是可能的。然而,这样会带来一系列问题:
当然,这些问题导致了这种方法比用Thread来启动一个新的执行线程要复杂得多:
x = Thread.new {
p "hello"
}
或者也比这个Erlang范例要复杂:
pid_x = spawn(Node, Mod, Func, Args)
这段Erlang代码产生了一个新的轻量级进程,而且这确确实实就是所有它需要的代码。所有的配置代码都已经处理好了,问题中没有一个解释了上面的原因。
这个pid是新产生进程的句柄,并且支持如简单通讯这样的操作:
pid_x ! a_message
这段代码会向pid_x变量存放的pid对应的线程发送一条简单消息。消息可以包含不同的类型,例如原子(Atoms)——Erlang下的Ruby符号(Symbol)等价物。
像这样简单的IPC在Ruby中理所当然可以实现。Erlectricity是一个新的支持Erlang和Ruby间通信的库,但它同样可以用在Ruby VM之间。Erlang IPC尤其有意思,因为它使用了一个模式匹配的方式来辅助消息传递,并使自身变得非常简洁。
毫无疑问,Ruby MVM是对Ruby线程的未来最有希望的设想。它避免了GIL和手动管理Ruby进程的问题,并且使用了“无共享”的理念,这个理念使得Erlang还有其它系统在并行计算方面非常引人注目。
JRuby是唯一一个使用内核线程的Ruby实现,主要原因在于它运行在支持内核线程的JVM之上。创建内核线程的开销在一定程度上因为线程池(事先创建出若干空闲线程,并在需要时取用)的使用而抵消掉了。IronRuby对线程进行支持的细节目前尚不清楚,但由于CLR和JVM非常相近,它很可能也会使用内核线程。
为Ruby MVM的想法创建原型并进行实验的可能性之一,就是在同一个JVM内部启动多个JRuby实例,并让它们之间互相通信。这样就能很有效地带来同样的低廉的IPC(只要数据是只读的,它们就可以很容易通过传递指针的方式传递)。
Ola Bini最近撰文阐述了他关于jrubysrv的想法,这个想法允许运行在一个JVM内部运行多个JRuby实例,以节省内存。
看起来未来在Ruby中进行线程支持的细节仍然有待决定,并且可能在不同的实现中各有差别。
查看英文原文:The Futures of Ruby Threading