Ruby 实例说明 Ruby 多线程的潜力和弱点

vincent · 2013年05月27日 · 最后由 davidqhr 回复于 2013年09月08日 · 24354 次阅读
本帖已被管理员设置为精华贴

Web 应用大多是 IO 密集型的,利用 Ruby 多进程 + 多线程模型将能大幅提升系统吞吐量。其原因在于:当 Ruby 某个线程处于 IO Block 状态时,其它的线程还可以继续执行。但由于存在 Ruby GIL (Global Interpreter Lock),MRI Ruby 并不能真正利用多线程进行并行计算。JRuby 去除了 GIL,是真正意义的多线程,既能应付 IO Block,也能充分利用多核 CPU 加快整体运算速度。

上面说得比较抽象,下面就用例子一一加以说明。

Ruby 多线程和 IO Block

先看下面一段代码(演示目的,没有实际用途):

# File: block_io1.rb

def func1
  puts "sleep 3 seconds in func1\n"
  sleep(3)
end

def func2
  puts "sleep 2 seconds in func2\n"
  sleep(2)
end

def func3
  puts "sleep 5 seconds in func3\n"
  sleep(5)
end

func1
func2
func3

代码很简单,3 个方法,用 sleep 模拟耗时的 IO 操作。 运行代码(环境 MRI Ruby 1.9.3)结果是:

$ time ruby block_io1.rb
sleep 3 seconds in func1
sleep 2 seconds in func2
sleep 5 seconds in func3

real  0m11.681s
user  0m3.086s
sys 0m0.152s

比较慢,时间都耗在 sleep 上了,总共花了 10 多秒。

采用多线程的方式,改写如下:

# File: block_io2.rb

def func1
  puts "sleep 3 seconds in func1\n"
  sleep(3)
end

def func2
  puts "sleep 2 seconds in func2\n"
  sleep(2)
end

def func3
  puts "sleep 5 seconds in func3\n"
  sleep(5)
end

threads = []
threads << Thread.new { func1 }
threads << Thread.new { func2 }
threads << Thread.new { func3 }

threads.each { |t| t.join }

运行的结果是:

$ time ruby block_io2.rb
sleep 3 seconds in func1
sleep 2 seconds in func2
sleep 5 seconds in func3

real  0m6.543s
user  0m3.169s
sys 0m0.147s

总共花了 6 秒多,明显快了许多,只比最长的 sleep 5 秒多了一点。

上面的例子说明,** Ruby 的多线程能够应付 IO Block,当某个线程处于 IO Block 状态时,其它的线程还可以继续执行,从而使整体处理时间大幅缩短 **。

Ruby GIL 的影响

还是先看一段代码(演示目的):

# File: gil1.rb

require 'securerandom'
require 'zlib'

data = SecureRandom.hex(4096000)

16.times { Zlib::Deflate.deflate(data) }

代码先随机生成一些数据,然后对其进行压缩,压缩是非常耗 CPU 的,在我机器 (双核 CPU, MRI Ruby 1.9.3) 运行结果如下:

$ time ruby gil1.rb

real  0m8.572s
user  0m8.359s
sys 0m0.102s

更改为多线程版本,代码如下:

# File: gil2.rb

require 'securerandom'
require 'zlib'

data = SecureRandom.hex(4096000)

threads = []
16.times do
  threads << Thread.new { Zlib::Deflate.deflate(data) }
end

threads.each {|t| t.join}

多线程的版本运行结果如下:

$ time ruby gil2.rb

real  0m8.616s
user  0m8.377s
sys 0m0.211s

从结果可以看出,由于 MRI Ruby GIL 的存在,Ruby 多线程并不能重复利用多核 CPU,使用多线程后整体所花时间并不缩短,反而由于线程切换的影响,所花时间还略有增加。

JRuby 去除了 GIL

使用 JRuby (我的机器上是 JRuby 1.7.0) 运行 gil1.rb 和 gil2.rb,得到很不一样的结果。

$ time jruby gil1.rb

real  0m12.225s
user  0m14.060s
sys 0m0.615s
$ time jruby gil2.rb

real  0m7.584s
user  0m22.822s
sys 0m0.819s

可以看到,JRuby 使用多线程时,整体运行时间有明显缩短(7.58 比 12.22),这是由于 JRuby 去除了 GIL,可以真正并行的执行多线程,充分利用了多核 CPU。

补充说明,Ruby 2.0 Zlib 库去除了 GIL

在 Ruby 2.0 下,由于 Zlib 去除了 GIL,见:https://github.com/ruby/ruby/blob/v2_0_0_0/NEWS#L512-L513/,执行多线程版本 gil2.rb 有非常大的性能提升。 详细数据见:

time ruby gil1.rb

real    0m8.708s
user    0m8.664s
sys 0m0.025s
time ruby gil2.rb

real    0m2.102s
user    0m17.630s
sys 0m0.147s

这是在一台 单 CPU 6 核(带超线程)机器,在 ruby-2.0.0-p195 的执行结果。 但是对于普通的 Ruby 代码 和 类库,Ruby 2.0 还是有 GIL 存在,限制利用多线程并行能力。 我另外构建了例子 gil_digest1.rbgil_digest2.rb (见 Github 项目),在 Ruby 2.0 下运行并没有性能提升。 感谢 @zj0713001 @hooopo @5long 的提醒和说明。

总结:Ruby 多线程可以在某个线程 IO Block 时,依然能够执行其它线程,从而降低 IO Block 对整体的影响,但由于 MRI Ruby GIL 的存在,MRI Ruby 并不是真正的并行执行,JRuby 去除了 GIL,可以做到真正的多线程并行执行

上述涉及的代码在 github 上: https://github.com/xiewenwei/ruby-multiple-threads/

不错的实例解释

#1 楼 @steven_yue 这么晚还不睡?还是已经起了?

楼主为何不测试 fork 的情况

#3 楼 @iBachue 你是指 Thread.fork 吗?还 fork process 呢? 基本上 Thread.fork 和 Thread.new 是一样的,fork process 就另当别论了。

多核就用多个进程就好了,没坏处。用单一进程跑多核,GC 会成为一个巨大的瓶颈的。

多核多个进程,这个没错,但不能满足于此吧,对于其中一个核吧,假设一个请求 1 秒(只是打个比方),其中 0.25 秒是执行非 IO,0.75 执行 IO,最理想的是一个核 1 秒能执行 4 个请求,而不是一个,即使达不到 4 个,也应该是靠近 4,而不是靠近 1,我不清楚的是,在这样的情况下,rials 能满足的请求是多少个呢?

GIL 是为了线程安全,这也是让工程獅能更专注代码功能的设计

标准 Ruby 用的是伪线程,他的多线程简单理解应该可以理解为代码片段。 不知道最新的版本还是不是这样

Jruby 没有很多的研究

我在 ruby 2.0.0p195 的运行结果是 ruby gil1.rb 7.28s user 0.08s system 99% cpu 7.369 total ruby gil2.rb 11.08s user 0.15s system 336% cpu 3.337 total

我的是 Late 2012 Mini I5 的 CPU 可能是双核四线程 结果显示多线程版本的 gil2.rb 使用了 336% 的 CPU 总时间 3.337 而未加多线程版本使用了 99%CPU 总时间 7.368

明显快了一半多啊 是 ruby 版本的问题?

用异步 io 就可以不阻塞了,这样单线程也能有不错的吞吐了

试了一个简单的粗暴的方法,如下:

#7 楼 @xwf286 你说的这种情况,就是接近 4,因为一旦调用外部 IO 操作,当前线程就会释放 CPU 内核控制权,另外一个线程就会获得 CPU 资源。

def index sleep(10) end def show end 先在一个浏览器里执行 index,然后在另一个浏览器里马上执行 show 之后,show 完全是 10m 之后才有响应,现在不确定的是,在 rails 里,sleep 这样的操作,是不是与 db.query 这样的操作是等价的,如果是这样的话,那前面对 ruby 里 blocked-io 的讨论对于 rails 来说,就没有意义了,完全是单线程(连 IO 也是)。

#9 楼 @ZombieCoder 但是,至少不能将 IO 放在一个线程里

#13 楼 @robbin ruby 本身是接近 4 的话,那 rails 呢,没有打开多线程模式(您也说了,如果贸然打开多线程的话,有可能有预想不到的 bug),还是接近 4 吗?

#16 楼 @xwf286 Rails 我没有测试过,理论上来说是支持的,Rails3.2 多线程还是有些问题,Rails4.0 估计应该没问题了,不过 Rails4.0 我还没有使用,所以没有测试过。

我测试比较多的是 sinatra/padrino,多线程肯定是没有问题的,非常稳定。

#14 楼 @xwf286 不知道你的情况是为何 ruby 2.0.0 p195 rails 3.2.13 config.allow_concurrency = true 使用 puma 开启单进程默认 16 线程 你说的现象不存在 sleep 的请求还在请求中 后续的请求可以完成

#17 楼 @robbin Rails3 的多线程问题您能举点例子么?我觉得更多的问题是业务上的吧 可能我接触的代码还是少 我发现大部分的旧代码都没有考虑过线程安全的问题...

#10 楼 @zj0713001 @robbin 我使用 ruby-2.0.0-p195 试了一下,确实使用多线程有明显加快。

time ruby gil1.rb

real    0m8.708s
user    0m8.664s
sys 0m0.025s
time ruby gil2.rb

real    0m2.102s
user    0m17.630s
sys 0m0.147s

这是在一台 单 CPU 6 核(带超线程)机器,在 ruby-2.0.0-p195 的执行结果。 按理,Ruby 2.0 并没有去除 GIL,应该不能真正并行执行多线程的,有点超出现在的理解范围了,谁能解析一下?

#11 楼 @reus 异步 IO 也是经常使用的解决 IO 问题一种方法,不过它额外的要求:

  1. 必须使用 callback 方式的写法
  2. 使用的类库必须使用专门异步 IO 版本 有时候这两点还是比较麻烦的。

#20 楼 @vincent GIL 只对纯 ruby 的内存运算有效,一旦有 IO 调用,CPU 就是阻塞等待状态,GIL 就解锁了,这个时候线程会释放 CPU 资源的。

#19 楼 @zj0713001 @robbin Rails 3 的多线程确实有比较大的问题,主要是稳定性不行,我搭过一个简单测试例子,使用 Rainbows ThreadPool 方式,允许每个进程最多 64 线程,当并发数到 20 的时候,就有 2% 左右请求失败率,具体原因不祥。期待 Rails 4 正式版本推出的时候,这一问题已解决。

#22 楼 @robbin 还不是很明白,gil2.rb 主要是压缩的工作,是耗 CPU 而不是 IO 等待的,另外,Ruby 1.9.3 和 Ruby 2.0.0 差异明显,方式有修改吗?

#23 楼 @vincent 多谢指导 你在 24 楼的问题我也想问问 哈哈

#24 楼 @vincent GVL 只限制 Ruby,C 扩展里没有这个限制。Ruby2 的 zlib 库做了修改,Release GVL in zlib when calling inflate() or deflate()

@luikore 的 slide 里有提到...: https://speakerdeck.com/luikore/ruby-2-dot-1-walk-thru-title-bait?slide=53

#26 楼 @hooopo #27 楼 @5long 看的都真细...看来以后要多订阅...

#26 楼 @hooopo #27 楼 @5long #28 楼 @zj0713001 两位真是认真细致啊,这个都注意到了,呵呵 我需要把例子再补充完善,以考虑到这种情况。

#23 楼 @vincent 失败可能是这样的:

rainbows 应该是用了 kgio 的 TCPServer, kgio 的继承了标准库的 TCPServer, 标准库的 TCPServer 调用了系统函数 listen, 然后 listen 有个 backlog 参数,就是系统维护的请求队列的大小,这个队列如果满了,其他请求就会扔掉,标准库用了个默认的 backlog 是 Socket::SOMAXCONN 往往比较小 (我的机器上是 128).

TCPServer 的连接数限制可以用 TCPServer#listen(new_max_conn) 解决掉,但好像 rainbows / unicorn 本身也有一个最大连接数的限制不知道怎么改...

#23 楼 @vincent 另外一个原因可能是系统限制了 fd (文件描述符) 的最大数,每个请求都会在服务器端和请求端各产生一个新的 fd (某些把请求传递到子进程的构架会多一个 pipe fd), 你还可以 ulimit -n unlimited 一下...

#30 楼 @luikore 多谢提醒,回头我根据你说的更改试试,看看是不是这个原因。

#30 楼 @luikore 这个队列只是 accept 之前啊,及时 accept 就没问题了啊,难道你有随时都有很多连接连过来?

#33 楼 @bhuztez 队列大点对真实服务器作用就是应付突发的 pike, 子进程的线程池都满了就停止 accept 了

#34 楼 @luikore 所以要用 PHP 啊,就放 100 个进程在那里 accept ...

#35 楼 @bhuztez fcgi prefork 100 个,100 个都在处理中了还是会暂时无人 accept, 扔掉请求。或者你的意思是 apache 的默认 listen() 值比较大?还是说可以动态增加 worker?

#36 楼 @luikore 但是你要是一秒内真的有 100 个连接,你一天不得百万 PV 了

#37 楼 @bhuztez 这个很难说的,像秒杀之类的应用某个时刻就有巨大的峰值,其它时候就没什么压力

另外我是试图用 listen(backlog) 解释本机测试为什么有 fail ...

#38 楼 @luikore 搞秒杀是自己折腾自己好不好

#38 楼 @luikore 本地多开几个进程就是了。ab 其实也压不了多少...

#40 楼 @bhuztez 进程开太多也可能导致请求被扔掉的... 好多个在 accept 状态的进程被同时唤醒就会产生 thundering herd 效应,然后服务器一卡队列就又堆起来了... 另外每个进程都有数据库连接,拼命开直接就把数据库打爆了...

#40 楼 @bhuztez #38 楼 @luikore 峰值的处理情形的确不常见,不过我觉得提升处理能力的用处在于:

  1. 稳定性,偶尔有情况达到峰值,这个时候你的系统总不能轻易挂掉;
  2. 安全性,遇到坏人拿你的系统做压力测试怎么办。(当然安全还需要额外措施加固)

#41 楼 @luikore 感觉 blocking 模式下,直接 accept,内核不会那么傻

先 select 再 accpet,内核才会比较无语吧

单核其实也问题不大吧,除非你 select 返回之后,计算半天还不去 accept

#43 楼 @bhuztez

所以有服务器设计成主进程 accept 然后通过 pipe 分派给子进程的,但这样代码就变得比较复杂并且多产生很多 fd 了。就像你之前说的,如果 unix 能暴露一个进程间共享 fd 的 api 就完美了...

p.s. 我修改补充了下 thundering herd 的链接...

#44 楼 @luikore 从 unix domain socket 发 fd,其实也还好,那个 socket 只用来 sendmsg 也不会太乱。

我要的是直接往另外一个进程发内存页 ...

#45 楼 @bhuztez 好吧我记错了...

#8 楼 @xwf286 是很详细,他的 Gist 里面也有 3 个例子:https://gist.github.com/panthomakos/1742042

@vincent 1.9 中计算密集型任务,多线程并不能提高计算效率,换言之没有使用多核,是因为两个限制:

  1. GIL 存在
  2. 线程为 green thread,而不是 native thread

这么理解对吧?

vincent Ruby on Rails 线程安全代码 提及了此话题。 07月15日 11:02
需要 登录 后方可回复, 如果你还没有账号请 注册新账号