如何理解Puma的并行运行

Database and Ruby, Python, History


Puma 是一个基于 rack 的 web server,在 Readme 中,它提到即使有 GVL 的存在,它还是能够实现 IO waiting 方面的 parallel。为什么?

## Built For Speed & Parallelism

Puma is a server for Rack-powered HTTP applications written in Ruby. It is:

Multi-threaded. Each request is served in a separate thread. This helps you serve more requests per second with less memory use.
Multi-process. "Pre-forks" in cluster mode, using less memory per-process thanks to copy-on-write memory.
Standalone. With SSL support, zero-downtime rolling restarts and a built-in request bufferer, you can deploy Puma without any reverse proxy.
Battle-tested. Our HTTP parser is inherited from Mongrel and has over 15 years of production use. Puma is currently the most popular Ruby webserver, and is the default server for Ruby on Rails.
Originally designed as a server for Rubinius, Puma also works well with Ruby (MRI) and JRuby.

On MRI, there is a Global VM Lock (GVL) that ensures only one thread can run Ruby code at a time. But if you're doing a lot of blocking IO (such as HTTP calls to external APIs like Twitter), Puma still improves MRI's throughput by allowing IO waiting to be done in parallel. Truly parallel Ruby implementations (TruffleRuby, JRuby) don't have this limitation.

多进程

https://www.speedshop.co/2020/05/11/the-ruby-gvl-and-scaling.html一文中展示了,在多进程情况下,可以实现真实的并行,即多个 worker 同时处理不同的事情。

多线程

由于 GVL 的存在,每个进程中只能有一个线程去获取 GVL 并运行 Ruby 代码。对于 IO blocking 的操作,Puma 引入了Reactor模式实现的 blocking IO。这背后是通过 IO.select,以及 puma 4 中切换成nio4r,来实现的。这样的结果是,遇到 IO blocking 的操作,系统会释放线程,处理其他操作,直到 IO ready 之后再处理。所以,即使有 GVL,多线程也可以提升并发量。

Ruby 中的 GVL

GVL 的前身是 GIL(全局解释器锁),在 Ruby 1.9 引入 YARV 的时候被移除了,使得锁位于 Ruby 虚拟机,而不再是解释器。在 Ruby 1.9 之前,Ruby 代码是逐行解释成计算机指令。而在 Ruby 1.9 之后,Ruby 会被一次性解释,转换成 VM 指令,再执行这些指令。而 Ruby VM 就是运行指令的“程序”。

当你执行ruby --dump=insns -e "puts 1+1"的时候,就是将 Ruby 代码转换成为指令。

$ ruby --dump=insns -e "puts 1 + 1"
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,10)> (catch: FALSE)
0000 putself                                                          (   1)[Li]
0001 putobject_INT2FIX_1_
0002 putobject_INT2FIX_1_
0003 opt_plus                     <callinfo!mid:+, argc:1, ARGS_SIMPLE>, <callcache>
0006 opt_send_without_block       <callinfo!mid:puts, argc:1, FCALL|ARGS_SIMPLE>, <callcache>
0009 leave

在这里我们可以栈信息。这里可以理解了,因为线程安全的原因,一次只能够有一个线程执行指令,那这个锁就停留在 VM 级别,而不是解释器级别。

Ruby 和 Python 一样,诞生的时候一个进程一般只有一个线程,所以不需要考虑多线程的问题。随着 2000 的到来,多线程编程的出现,YARV 也随之诞生。

并发和并行

简单说,并发是一个收银员轮流对三个顾客,只能够轮流进行;并行就是三个收银员分别对应三个顾客,可以同时进行。很明显,并行可以加快速度。

每个进程都有自己的 GVL,每个进程里的多个线程轮流分享 GVL。

多线程可以提升吞吐量么?

上面提到过了,对于 IO 操作,GVL 会释放锁直到这些 IO ready 再来处理。很显然,这样做的好处就是线程可以多处理其他任务了。

线程也不是越多越好,如果太多,GVL 在线程中轮流跑一圈的延迟会被大大增加。一般推荐 3 到 5 个线程。这里的延迟不是常说的上下文切换的开销。

杂谈

多线程可以提升 blocking IO 的吞吐量,但是也有弊端,比如线程安全,竞争,延迟等。如果一个线程池有 5 个线程,都 hang 在 IO blocking 上了,那么只能够创建新的线程来处理新的请求了。

作为对比,Node.js 是单线程模式,但能够达到甚至更好的效果。背后的原因 Node.js 是基于 event-drive 架构,即一旦有 blocking IO 操作,就放到 event loop 里面去,下次再回来看看能不能够处理。这样的结果就是,即使有很多 IO blocking 操作,我还是能够处理新的请求。但是单线程的缺点就是没法充分利用多核。

而作为新生代的 Golang,是多线程的。但它不仅可以利用多核,还避免了多线程的 context switch 导致的延迟。

Reference

  1. https://www.speedshop.co/2020/05/11/the-ruby-gvl-and-scaling.html