[ruby] Ruby中的GVL

489 阅读6分钟

GVL

GVL全称全局虚拟机锁(Global VM Lock),也有人称之为全局解释器锁(Global Interpreter Lock)。但是,GVL与GIL并不是一回事。GVL是CRuby(又名MRI)的独有特性,在其他的Ruby实现中并不存在全局虚拟机锁,如JRuby、TruffleRuby。

虚拟机锁在动态语言的实现中很常见,如Javascript的V8虚拟机中有虚拟机锁,CPython的虚拟机中有全局虚拟机锁。这三门语言(Javascript、Python、Ruby)算是世界上知名度最高的动态语言了。

理解GVL会对如何有效的扩展Ruby程序提供帮助。

为什么要理解GVL

了解GVL是什么以及为什么GVL是全局的,有助于我们回答如下问题:

  1. Sidekiq的并发值应该设置成多少?
  2. Puma应该配置多少个线程?
  3. 我们是否需要从Unicorn、Resque、DelayedJob切换到Puma、Sidekiq?
  4. 事件驱动的并发模型(如:Node)的优点是什么?
  5. 对于没有全局锁的虚拟机(如:Erlang的BEAM,Java的JVM)的优点是什么?
  6. Ruby 3中的并发机制将会如何改变?

GVL锁的是什么?

Koichi Sasada在Ruby 1.9引入了新的VM实现(YARV - Yet Another Ruby VM,也叫KRI - Koichi‘s Ruby Interpreter)后,GIL就从Ruby移除了(或者说被修改了,取决于如何看待)。YARV改变了CRuby的内部结构,让锁围绕虚拟机,而不是解释器。因此,在YARV合入Ruby后,GIL就退出了舞台,GVL进入人们的视野。所以,GVL锁的是虚拟机,而不是解释器。

虚拟机和解释器的差别是什么?

在Ruby 1.9之前,Ruby程序会被解释器逐行解释执行,并不存在虚拟机这一步。在YARV合入Ruby后,Ruby程序只会被解释一次,转变成虚拟机指令,而不是被解释器频繁的解释。这样,会提升程序执行的速度。

Ruby虚拟机识别一组简单的指令,这些指令由解释器将Ruby程序解释后所得。Ruby的虚拟机是基于栈的(Stack)。

那么Ruby的虚拟机和多线程、并发、并行又有什么关系呢?

并发很有趣,但是并行才是真正让系统提速,并增加吞吐量的技术。

GVL究竟做了什么?

Ruby虚拟机会将虚拟机指令(由解释器产生)转换为CPU指令。Ruby虚拟机不是线程安全的,因此,如果同时有2个线程访问Ruby虚拟机,将会导致虚拟机的状态混乱。因此,需要全局锁将Ruby虚拟机锁住,保证同时只能有一个线程访问虚拟机。

对于动态语言的虚拟机而言,实现成非线程安全是很常见的,正如Javascript和CPython的虚拟也是非线程安全的。Java的虚拟机JVM比较特殊,它是线程安全的,这也是为什么很多语言都有基于JVM的实现版本,因为实现线程安全的虚拟机真的很难。

  1. 速度快。单线程的程序执行起来性能更好,因为缺少了不断的加锁、释放锁的过程。
  2. 集成扩展容易,如C扩展。
  3. 实现锁少的虚拟机要比实现带有很多锁的虚拟简单的多。

每个Ruby进程都会有一个GVL,所以,这里的全局指的是进程这个级别的。也就是说,任何时刻任何进程中只能有一个线程持有GVL。

阿姆达尔定律

为什么1个Sidekiq进程的执行速度是DelayedJob或者Resque的2倍呢?

假设我们有一组卫星图片数据要处理,我们编写了一个Sidekiq任务:

class SatelliteDataProcessorJob
  include Sidekiq::Worker

  def perform(some_satellite_data)
    process(some_satellite_data)
    touch_external_service(some_satellite_data)
    add_data_to_database(some_satellite_data)
  end
end

其中process方法是100%的Ruby方法,不涉及C扩展或者外部服务,touch_external_service和add_data_database是100%的IO方法,执行的全部时间用来等待IO操作的结束。

加入SatelliteDataProcessorJob的执行时间是1秒,我们有100个这样个任务,1个Sidekiq进程和线程,总耗时是100秒。如果有2个Sidekiq进程,总耗时会是50秒;4个Sidekiq进程,总耗时是25秒。这就是采用并行的扩展方案。

现在,如果1个Sidekiq进程中包括10个线程,完成100个上述任务需要耗时多久呢?这取决于我们使用哪中Ruby实现,如果是JRuby或者TruffleRuby的话,可能总耗时是10秒,因为这2种Ruby实现中,线程可以做到并行执行。

但是在MRI实现上,我们就要应用阿姆达尔公式来计算。阿姆达尔公式:1/(1-p+p/s)。其中p指的是并行任务的占比,s指的是并行部分的提速因子。

还是上面的例子,1个Sidekiq进程,包含10个线程。假如SatelliteDataProcessorJob任务一半是GVL密集的,一半是IO密集的。这种情况下阿姆达尔公式中的p就是0.5,s就是10,因为IO等待可以并行进行,而且有10个线程。通过阿姆达尔公式计算可得,1个Sidekiq进程(包括10个线程)执行速度是单线程的Resque和DelayedJob的1.81倍。

Ruby中的很多后台任务,至少一半的时间都在等待IO操作结果。这类任务使用Sidekiq就会节省至少一半的资源消耗,因为1个Sidekiq进程的执行效率是单线程进程的2倍。

所以,即便是有GVL,在应用进程中使用多线程,同样会增加程序的吞吐量。

多线程、Puma和GVL导致的服务延迟

Puma和Sidekiq进程需要配置多少个线程取决于线程花费在非GVL执行的时间是多少,也就是程序等待IO操作的时间有多久。如果任务中花费在IO操作的时间超过75%,那么配置线程的个数至少是16个。通常的任务,配置3-5个线程就会有性能提升。

如果任务的并行性不够的话,将Puma或者Sidekiq进程的线程数配置超过5个会导致线程对GVL的竞争,造成服务延迟。

在CRuby进程中添加线程会造成延迟,为什么还要这么做?

因为相比创建一个进程而言,在现有的进程中添加线程会消耗更少的内存,同时,可以提高CPU使用率。Gitlab从单线程模型的Unicorn切换到多线程模型的Puma,内存使用降低了30%

References

The practical effects of GVL on scaling in Ruby

YARV