大家都知道 Ruby 是有 GIL,为了实现并发处理,我们有很多方案,最近花了一点时间,写了点 Fiber 加上 EventMachine 的实现,拿现有的业务代码做了一部分实现以及各种场景下 benchmark,有点心得,也跟大家分享一下
先简单说下结论:
在 1.8 的时代,一个 ruby 的进程在绝大多数的情况下是消耗不了一个 CPU 核心的性能,因为当访问到 io 操作的时候,整个进程都会被挂起,为了的问题,通常的方式起多个进程,借助系统的进程的调度来实现使用更多的 CPU 性能
到了 1.9,ruby 开始依托操作系统的实现来实现线程,这个时候,多线程的方案被慢慢开始热了起来,借助多线程,虽然一个 ruby 进程最多只能使用一个 CPU,但是在,一个进程的之间的多个线程在 io 操作的时候依然是可以做到并发的,当这个时候,一个 ruby 进程已经可以消耗掉一个 CPU 内核的性能,于此同时,ruby 1.8 上的那套线程调度模型,改吧改吧,推出了一个新的概念,Fiber.
题外话,ruby 多线程方案的真正火热应该是 rails4 之后,在 rails3 的时候,rails 还是遮遮掩掩的说明,似乎我们应该是支持多线程的,如果有问题,恩,一定是外部库的问题,到了 rails4 的时候,社区多线程的方案基本已经成熟,同期,passenger 推出来多线程的支撑 [虽然是收费的],rainbows,puma 开始慢慢火热。
本质上来说,线程是代码运行的一种容器,而 Fiber 也是一种代码运行的容器,但是,Fiber 和 Thread 的概念有很大的区别,Thread 是一种 Concurrent Mode,但是 Fiber 只是一种调度模式,与多线程对等应该是在 node 界十分火热的事件驱动模型,本文另一个主角 eventmachine 其实就是 ruby 上事件驱动模型的一种实现。
多线程和事件驱动在 ruby 上都可以实现并发的处理,但是相对于多线程而言,eventmachine 所代表的事件驱动往往在代码上并不直观
多线程实现 http 代码:
resp = RestClient.get('https://ruby-china.org/')
=> {status: headers: body}
EventMachine 实现 http 代码:
http = EventMachine::HttpRequest.new('https://ruby-china.org/')
http.callback do |resp|
resp
end
http.errback do |resp|
resp
end
http.start
当只有一次的时候似乎还好,如果你在 callback 中如果还需要调用其它异步 io,你会发现好多好多的 callback 的层叠调用,这就被称为callback hell
但是,当你使用 Fiber 层桥接了 EventMachine 和业务逻辑,那么,依然可以使用同步的代码写法异步的逻辑
Fiber 是一种流程控制逻辑,通常需要依托于线程做真正的实现,fiber 作为一个流程的运行单元,可以非常快的将自身切换到另一个 fiber,当你 require 了 fiber 之后,主线程将会切换进入一个主 Fiber 中
当一个 Fiber 挂起了之后,后一步运行的是前一个 fiber 的代码
这一段其实很重要,不然,很多 fiber 的代码会变得很难理解,不管你 Fiber 切换了多少次,一旦当前 fiber 被挂起,前一个 fiber 就会紧接着开始运行
require 'fiber' # (1)这时候的代码进入了一个主的Fiber中
fiber = Fiber.new
while true
puts 1 # (4)被主fiber唤醒了之后,打出1
Fiber.yield # (5)挂起当前fiber
end
end # (2)我们创建了一个fiber
fiber.resume # (3)这时候,主fiber切换到了我们新创建的fiber的代码中
puts 2 # (6)当我们创建的fiber挂起了之后,程序便跳转到了这里
说实话,这也是一个挺拗口的方案
当开始使用 Fiber 和 EventMachine 了之后,代码会变成这个样子
def call_http(uri)
current_fiber = Fiber.current
http = EventMachine::HttpRequest.new('https://ruby-china.org/')
http.callback do |resp|
current_fiber.resume(0, resp)
end
http.errback do |resp|
current_fiber.resume(1, resp)
end
http.start
status, resp = current_fiber.yield
if status == 0
resp
else
nil
end
end
EventMachine.run do
Fiber.new do
resp = call_http(uri)
end.resume
end
我把这个样子的代码分为两个部分,一个是正常的业务代码,一个是作为 EventMachine 和正常业务代码之间的 bridge 代码
在刚才的代码例子中,只有resp = call_http(uri)
是业务层面的代码,其它的代码都是用来和 EventMachine 做桥接的
画个图解释下
-------- Thread.main ------------------------------------------------------------------------
== Event Loop |== Fiber
= |= # 获取当前fiber
= |= current_fiber = Fiber.current
= |= # 创建EM HTTP REQUest
= |= http = EventMachine::HttpRequest.new(uri)
= |= # 设定正常回掉
= |= http.callback { current_fiber.resume(0, resp) }
= |= http.errback { ... } # 设定异常回掉
= |= # 启动http调用,但是由于是异步机制
= |= # 正真的操作将会在事件循环中执行
= |= http.start
= |= # 挂起当前的fiber
= |= # 由于主fiber中运行的是EventMachine的事件核心
= |= # 因此将会运行EM中的其它逻辑
= |= # 注意,在当前状态下,yield方法是尚未返回的
= |= status, resp = current_fiber.yield
= # 由于Fiber的yield,得到了CPU的使用权 |=
= # 开始正常调用EM loop 执行http通讯 |=
= # 当http调用完毕 |=
= # EventMachine调动正常callback |=
= current_fiber.resume(0, resp) |= # 这个时候,由于EM上调用了resume
= |= # 这个fiber被重新激活
= |= # yield方法开始return
= |= # status 和 resp被赋值, http调用完成
= |=
这样子就完成了一次可并发的 http 调用, 所有的外部 io 的场景都可以用这种模式运作
从理论上来说,EventMachine 可以维护上 K 个 io,那么,我们自然也可以有上千个 Fiber 在挂起的状态等待 EventMachine 做 io 的唤醒
但是,这样的 io 操作必须是它本身就支持 EventMachine,但事实上,大部分库其实是不支持 EventMachine 的,比如 mysql2,这个时候怎么办?
EventMachine and Fiber and ThreadPool
在这种情况下,EventMachine 并不做 io 的控制,io 依然由原始 Socket 控制,但是为了不阻塞当前线程,可以将 io 操作放到线程池里面,那么 call_http 就会变成这样:
def call_http(uri)
current_fiber = Fiber.current
thread_pool.exec do
resp = RestClient.get(uri)
EventMachine.next_tick do
current_fiber.resume(0, resp)
end
end
status, resp = current_fiber.yield
if status == 0
resp
else
nil
end
end
这里还需要 EventMachine 的唯一一个原因是 fiebr 的创建和 resume 必须要是同一个线程中,不然会异常,所以需要 EventMachine 的 next_tick 来支撑将代码 block 传回到主线程中的工作,当然,如果你自己创建一个 loop 的实现,那么就不再需要 EventMachine
所有的性能结果都是拿同样的业务代码,只不过在底层切换了并发的机制 server 是 rainbows
具体业务代码是:在 msyql 的数据库中一张大概 10W 条数据的表中查询 3 次数据库
mysql2的版本: 0.3.11
rainbows: 4.6.5
ruby: 2.1.0
event_machine: 1.0.3
改动了 MySQL2 的查询机制,利用 MySQL2 原生多 EventMachine 的部分支持,当 SQL 写入到 socket 之后,用 EventMachine 的 watch_io 等待 notify_readable, 当可读的时候,将 io 放入线程池中做读取操作,之后通过 EventMachine 的 next_tick 回到主线程 resume 当前挂起的 Fiber
性能:
Concurrency Level: 10000
Time taken for tests: 9.877 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 3360000 bytes
HTML transferred: 1710000 bytes
Requests per second: 1012.44 [#/sec] (mean)
Time per request: 9877.135 [ms] (mean)
Time per request: 0.988 [ms] (mean, across all concurrent requests)
Transfer rate: 332.21 [Kbytes/sec] received
Percentage of the requests served within a certain time (ms)
50% 5012
66% 6487
75% 7330
80% 7792
90% 8708
95% 9157
98% 9446
99% 9542
100% 9723 (longest request)
性能:
Concurrency Level: 10000
Time taken for tests: 10.565 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 3360000 bytes
HTML transferred: 1710000 bytes
Requests per second: 946.49 [#/sec] (mean)
Time per request: 10565.321 [ms] (mean)
Time per request: 1.057 [ms] (mean, across all concurrent requests)
Transfer rate: 310.57 [Kbytes/sec] received
Percentage of the requests served within a certain time (ms)
50% 5522
66% 7005
75% 7940
80% 8399
90% 9376
95% 9938
98% 10212
99% 10264
100% 10315 (longest request)
具体业务场景是使用 Mongoid 在一张 10W 条数据的 collection 中查询三次
重写了 Mongoid 的 socket 的用法,所有 socket 的写入写出通过 EventMachine 控制
Document Path: /test/test5.json
Document Length: 1680 bytes
Concurrency Level: 10000
Time taken for tests: 8.431 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 18460000 bytes
HTML transferred: 16800000 bytes
Requests per second: 1186.09 [#/sec] (mean)
Time per request: 8431.032 [ms] (mean)
Time per request: 0.843 [ms] (mean, across all concurrent requests)
Transfer rate: 2138.21 [Kbytes/sec] received
Percentage of the requests served within a certain time (ms)
50% 4162
66% 5298
75% 6282
80% 6909
90% 7671
95% 7984
98% 8183
99% 8252
100% 8316 (longest request)
Document Path: /test/test5.json
Document Length: 1680 bytes
Concurrency Level: 10000
Time taken for tests: 6.715 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 18460000 bytes
HTML transferred: 16800000 bytes
Requests per second: 1489.22 [#/sec] (mean)
Time per request: 6714.927 [ms] (mean)
Time per request: 0.671 [ms] (mean, across all concurrent requests)
Transfer rate: 2684.67 [Kbytes/sec] received
Percentage of the requests served within a certain time (ms)
50% 3293
66% 4331
75% 4954
80% 5229
90% 5799
95% 6261
98% 6435
99% 6511
100% 6632 (longest request)
在这个情况下,EM 和线程池的性能基本一致,EM 稍稍快一点
当 socket 由 EventMachine 接管的时候,性能还是比线程池的要慢好一些,基本上要慢 20%
在打开 GClog 之后,发现EventMachine and Fiber模式
明显会有更多的 GC,不过在内存上,EventMachine and Fiber模式
是多线程模式的一半
但是 EventMachine and Fiber 模式在这个场景下,会有些用处:
希望对大家有帮助