Ruby 线程模型之未来

  • Werner Schuster
  • Jason lai

2007 年 5 月 27 日

话题:RubyErlang语言 & 开发架构

最近的一次对 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)在今天看来也是可能的。然而,这样会带来一系列问题:

  • Ruby 进程需要运行一个新的 Ruby 解释器,这就意味着进程必须了解自己是如何启动的(应该使用哪个 Ruby 可执行程序)。这很快会变得很难通过一种可移植的方式完成。举例而言:如果使用了 JRuby,那么可执行程序就该是“JRuby”。更糟糕的情况:运行它的 JVM 或者应用服务器可能不会允许在程序之外运行;
  • 新的 Ruby 解释器必须使用正确的环境变量、LOADPATHs、包含文件路径和要运行的主.rb 文件设置起来;
  • 通信可以通过 DRb 来产生,但必须由网络来完成,而这是唯一一种具备可移植性的 IPC 形式;
  • 网络通信就意味着要用到协商端口(即哪一个端口应成为两个互相监听的程序的“服务端”);
  • 网络通信还意味着与防火墙之间可能存在的问题,如受到程序打开连接或者端口的困扰。

当然,这些问题导致了这种方法比用 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

RubyErlang语言 & 开发架构