GIL 能否让你的 Ruby 代码线程安全?

GIL 究竟能不能让你的 Ruby 代码线程安全?这个问题在 Ruby 社区中一直存在着误解,所以在文章开始之前,先给出一个结果:GIL 不会让你的 Ruby 代码线程安全

我们首先来从技术层面来分析 GIL。资源竞争在用来实现 MRI 的 C 代码中是很常见的,而 GIL 看起来可以降低这个危险,至少 MRI 原生方法类似于 Array#<< 方法可以避免资源竞争。

GIL 使 MRI 的原生 C 方法都可以实现原子操作。也就是说,原生的实现可以避免资源竞争。但是这仅仅对于 MRI 的 C 原生方法有用,对于我们自己的 Ruby 代码就是另一回事了。这是一个挥之不去的问题又来了 (挖掘机究竟哪家强?):

MRI 能否保证你的代码是线程安全的?

在前言中已经解答过这个问题了。现在我们开始深入的讲解,以免之前的误解被继续传播。

回到资源竞争

当多个线程企图在同一时间内操作同一个资源的时候,这是就会出现资源竞争的情况。这时如果我们没有锁等同步策略的话,程序就可能会出现一些预料之外的状况。

我们先来模拟一个资源竞争的情景,我们用下面这段代码来举个例子:

class Sheep
  def initialize
    @shorn = false
  end

  def shorn?
    @shorn
  end

  def shear!
    puts "shearing..."
    @shorn = true
  end
end

这个类定义很简单,就不多说了。

sheep = Sheep.new

5.times.map do
  Thread.new do
    unless sheep.shorn?
      sheep.shear!
    end
  end
end.each(&:join)

上面的代码创建了一只新的小羊羔然后创建了5个线程。每个线程都会去检测小羊羔有没有被剃毛。如果没有剃毛,则标注一下这个小羊羔要剃毛了。接着在终端运行这段代码,下面是运行了几次之后的结果:

$ ruby check_then_set.rb
shearing...
$ ruby check_then_set.rb
shearing...
shearing...
$ ruby check_then_set.rb
shearing...
shearing...

结果很伤感,有些小羊羔被剃毛剃两次。

按照我们对于 GIL 的理解,MRI 下 的 Ruby 代码同一时刻其实只有一个线程在工作,不管是几核的处理器。为什么会出现这样的情况?上面的运行结果揭示了结果,GIL 无法做出如此的保证。如果继续运行上面的代码,可能还会看到别的结果,也可能有更凄惨的小绵羊要被剃三次毛。

这些预料外的结果是由于代码中有资源竞争的情况。这是一类很常见的导致资源竞争,我们称之为 check-then-set 资源竞争。在这种情况中,两个或者更多的线程去检查一个值然后根据这个值来更改某个状态。在非原子操作的情况下,可能出现两个资源检查一个值之后,然后同时执行 set 操作,这时就会出现我们不想要的情况了。

认识资源竞争

上下文切换可以发生在我们代码中的任意一行。当从一个线程切换到另一个线程的时候,可以认为我们的代码被扔到一个不相关的块中。这个块就是一个交叉的集合。有些交叉是我们所期望的,但是并非所有都是我们所期望的。

以上面的例子为例,可能发生的交叉是像下图这样

由于 GIL 的存在,线程 A 永远不可能和线程 B 同时工作。在这种交叉的情况下,线程 A 完成了它的所有工作,然后线程调度触发了上下文切换,线程 B 开始工作。但是事情并非总是像这样。因为线程调度切换上下文可能发生在任意的时刻,下面这种极坏的情况也是可能发生的

在这种情况下,就会发生我们预期外的结果,而且线程调度是我们无法控制的,因此上面的情况是极有可能发生的。一只小绵羊被剃两次毛可能无所谓。但是如果把剃毛换成付款,那么我们可能就会被不开心的顾客吊打。