Ruby Fiber and EventMachine 一些心得

killyfreedom · 2016年02月20日 · 最后由 ywencn 回复于 2016年03月22日 · 9330 次阅读
本帖已被管理员设置为精华贴

大家都知道 Ruby 是有 GIL,为了实现并发处理,我们有很多方案,最近花了一点时间,写了点 Fiber 加上 EventMachine 的实现,拿现有的业务代码做了一部分实现以及各种场景下 benchmark,有点心得,也跟大家分享一下

先简单说下结论:

  1. 多线程在绝大多数的情况下表现比较好
  2. Fiber and EventMachine 实现的并发需要写很多通讯层和业务层之间产生调度的代码
  3. 只有在有非常频繁需要调用非常耗时的远程接口的时候,Fiber 相对于多线程更加轻量级的原因,可以支撑更多的访问

一点点小历史

在 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 开始慢慢火热。

多线程 vs 事件驱动

本质上来说,线程是代码运行的一种容器,而 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 作为一个流程的运行单元,可以非常快的将自身切换到另一个 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挂起了之后,程序便跳转到了这里

说实话,这也是一个挺拗口的方案

EventMachine and 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

EventMachine and Fiber and ThreadPool

具体业务代码是:在 msyql 的数据库中一张大概 10W 条数据的表中查询 3 次数据库

mysql2的版本: 0.3.11
rainbows: 4.6.5
ruby: 2.1.0
event_machine: 1.0.3
EventMachine and Fiber and ThreadPool 性能

改动了 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)

EventMachine and Fiber

具体业务场景是使用 Mongoid 在一张 10W 条数据的 collection 中查询三次

EventMachine and Fiber 性能

重写了 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)

性能总结

EventMachine and Fiber and ThreadPool

在这个情况下,EM 和线程池的性能基本一致,EM 稍稍快一点

EventMachine and Fiber

当 socket 由 EventMachine 接管的时候,性能还是比线程池的要慢好一些,基本上要慢 20%

在打开 GClog 之后,发现EventMachine and Fiber模式明显会有更多的 GC,不过在内存上,EventMachine and Fiber模式是多线程模式的一半

总结

  • EventMachine 在处理 socket 上,其实性能并没有想象的那么好
  • 多线程模式在绝大多数情况下对 socket 还是有比较好的性能的

但是 EventMachine and Fiber 模式在这个场景下,会有些用处:

  1. 当你突然会有大量访问远程 api 的请求的时候,这个时候,我们可以开更多的 fiber 来支撑这些 cpu 占用不高,纯粹是要等待返回的请求,我们可以在一个进程上开 1024 个 fiber,每次远程访问 api 慢的时候,但是,应该不会有人开 1024 个线程吧
  2. 当在微服务模式下,每次客户的请求或多或少会访问多个组件,以及组件调用组件,每次客户的请求将会消耗掉多个线程资源,相对而言,在 fiber 模式下,一次用户调用占用多个 fiber,似乎会占用更少的资源

一点额外的话

  1. 要支持 C10K 的话,一定要注意 nginx 和 rainbows 上 backlog 的问题,关于具体的 backlog,有时间我再开一贴讲讲在支持 C10K 的时候,服务器需要配置些啥
  2. 如果你是通过 http 来调用自己的微服务框架,一定要记住,socket 要复用!!socket 复用的 http 调用比不复用的快将近 4 倍

希望对大家有帮助

谢谢分享。

不过,文章貌似有几处错误:

  • 因为 Ruby 存在 GIL,所以上述代码是不能真正并行的
  • Fiber 代码的解释难以理解。首先,上述代码中,require 'fiber' 是多余的;其次,不存在什么主从 Fiber 之类的概念
  • 可以试下 em-synchrony,应该就是你想要的;作者对此库的介绍

#1 楼 @watraludru 感谢您的指出,不过这几点我的理解和您不太一致:

  1. 这里的并行是指逻辑上的并行,并不是指所有代码上在物理上的同一个时间上运行
  2. require 'fiber'是必须的,进入 irb 状态之后,可以访问到 Fiber 的库,但是 Fiber 的整个运行时态其实是未初始化的,你没办法访问到Fiber.current的方法,当require 'fiber'了之后,它做了以下事情: ```c

void ruby_Init_Fiber_as_Coroutine(void);

void Init_fiber(void) { ruby_Init_Fiber_as_Coroutine(); }


见ruby-2.1.0源代码下`ext/fiber/fiber.c `

ruby_Init_Fiber_as_Coroutine具体是这么实现的:
```c
void
ruby_Init_Fiber_as_Coroutine(void)
{
    rb_define_method(rb_cFiber, "transfer", rb_fiber_m_transfer, -1);
    rb_define_method(rb_cFiber, "alive?", rb_fiber_alive_p, 0);
    rb_define_singleton_method(rb_cFiber, "current", rb_fiber_s_current, 0);
}

见 ruby-2.1.0 源代码下 cont.c 代码的 1686 行

主 Fiber 这个概念是有的,ruby 中的在某个线程中,如果 require 了 fiber,那么当你在调用其它 fiber 的 resume 方法的时候,当前的线程模式就会转换成 fiber 模式,当前线程的代码堆栈就会成为你当前线程的 root_fiber

VALUE
rb_fiber_current(void)
{
    rb_thread_t *th = GET_THREAD();
    if (th->fiber == 0) { // 就是这行
    /* save root */
    rb_fiber_t *fib = root_fiber_alloc(th);
    th->root_fiber = th->fiber = fib->cont.self;
    }
    return th->fiber;
}

见 ruby-2.1.0 源代码下 cont.c 代码的 1333 行

第三个问题: em-synchrony 我通读过所有的代码,但是 fiber 问题不在于 em-synchrony 下简单的实现,而是在于需要定制化你所需求的库的读写,一味将 socket 直接指向 em-synchrony 下的 socket 实现不是一个兼容性非常好的问题

#2 楼 @killyfreedom

  1. 如果说逻辑上的并行,用并发这个术语似乎更恰当?
  2. 我说的是此处的代码 require 'fiber' # (1)这时候的代码进入了一个主的Fiber中,这里只用到了 new,yield,resume,所以实际并不需要(刚才又测试了遍,确实如此啊?),因而觉得这里的解释有点费解。
  3. 因为感觉本文(至少前面篇章)貌似主要利用 Fiber 来避免 EventMachine 的 callback hell,作者的那篇介绍写的蛮清楚的。至于有些不支持的库,我记得 git 上也有解决方式;不过,自己没有实测,不知道兼容性如何了~

#3 楼 @watraludru

  1. 可能是,我去改改,避免大家误解
  2. require 'fiber' 确实会改进当前的线程代码的运行模式,也可能是在后面第一个 fiberresume 的时候做,他会在当前线程上初始化一个 root fiber,这个时候,这个线程之后的代码都可以理解是运行在这个 root fiber 上了,因为这个 root fiber 还有一定的特殊性,所以单独提出来将,希望不要和普通的 fiber 的理解混在一起,具体的话就会深入非常具体的 fiber 的运行机制,展开讲我也吃不消了,^_^,具体可以见这个代码:
if (th->fiber) {
GetFiberPtr(th->fiber, fib);
cont_save_thread(&fib->cont, th);
}
else {
/* create current fiber */
fib = root_fiber_alloc(th);
th->root_fiber = th->fiber = fib->cont.self;
}

这是它 fiber store 里面的实现,那么,确实,root fiber 是在第一个 fiber resume 的时候创建的

static rb_fiber_t *
root_fiber_alloc(rb_thread_t *th)
{
    rb_fiber_t *fib;
    /* no need to allocate vm stack */
    fib = fiber_t_alloc(fiber_alloc(rb_cFiber));
    fib->cont.type = ROOT_FIBER_CONTEXT;
#if FIBER_USE_NATIVE
#ifdef _WIN32
    fib->fib_handle = ConvertThreadToFiber(0);
#endif
#endif
    fib->status = RUNNING;
    fib->prev_fiber = fib->next_fiber = fib;

    return fib;
}

在 windows 上会有非常明显的 ConvertThreadToFiber 的调用,因次,我才说会进入一个主 fiber 的模式

3.我试过,兼容性不太好.....

很棒的分享!深入浅出,有实际的例子和执行结果为证,很有说服力。 ruby fiber 特性偏底层,大部分情况都接触不到,所以看起来会比较吃力的,得趁机再去温习基础知识。如果要在实际项目中利用,估计得封装成 gem,屏蔽一些底层东西。

好文章。如果用 jruby 就能实现真正的并行了吧。有一种说法是 并发。而并行是能充分利用 CPU 多核,并发不一定会充分利用 CPU 多核。在单核心状态通过 CPU 调度可以实现并发,在微观上某个时刻还是只有一个核心(在 ruby GIL 限制下)在处理,即使是多核 CPU。不过使用 fiber 毫无疑问在一定程度上提高了并发的性能,从而提高了系统的性能,可以这样理解吗?

#5 楼 @vincent ^_^

#6 楼 @pathbox 相对于多进程而言,是的,但是,相对于多线程而言,这个需要分场景了 大部分时候应该是多线程优势比较大,暂时是这么觉得的

但是,没办法确定EventMachine and Fiber的解决方案比多线程慢的原因是 fiber 导致的还是 eventmachine 导致的,毕竟在 fiber 的方案中,socket 是由 eventmachine 管理的

有时间,我再写一点详细的 benchmark,确定下到底是不是 Em 导致的性能降低

#7 楼 @killyfreedom 也就是说这样的一个性能总结:多进程 < EventMachine and Fiber < 多线程

折中一下,单线程 event loop 连接,再用多线程分发处理业务,是不是最好的

#8 楼 @pathbox 确实,在现在看来,大多数情况是这样的

#9 楼 @rogerluo410 这个是典型 java 的 netty 的方式,但是 ruby 有 GIL,效果不太好

#9 楼 @rogerluo410 (目前) 是最好的,event loop 处理连接,多线程分发重 IO 的任务。

ruby 没有原生的多线程,应该用 fiber 只会徒增调度成本。。除非请求特别长 这个是之前看过的手动调度 fiber 的 async/await,不知道 em 的底层是不是也是这样的,感觉应该差不多

require 'fiber'

def async &block
  Fiber.new &block
end

def await &block
  thr = Thread.new do
    block.call
  end

  loop do
    t = Time.now
    p '.'
    Fiber.yield while Time.now - t < 0.05
    return thr.value unless thr.alive?
  end
end

require 'net/http'
require 'uri'

reqs = 5.times.map do
  async {
    p await { Net::HTTP.get URI('http://www.baidu.com/'); 'ok' }
  }
end

while reqs.size > 0
  status = reqs.map do |x|
    x.resume
  end
  reqs.select!(&:alive?)
end

#13 楼 @mizuhashi 不一样,你这个是线程和 FIBER 的整合,而且后面用 while 的方式等待 fiber 执行完非常没有效率

EM 维护了一个 event loop 当 Fiber 执行完毕了,会进入 EM 的 event loop 唤醒 fiber

#14 楼 @killyfreedom 不是的,那个 while 是为了把资源分出去,就是接下来 0.05 秒内不参与资源竞争 em 我记得是纯 ruby 实现,想不到别的方法,回头研究一下

#14 楼 @killyfreedom 噢你说下面的 while 啊,那个应该可以用 join 移交到线程里,只是这里这样写了


不对这样就 join 不回去了,我去看了下 em 不是纯 ruby 的,那应该有别的实现

#16 楼 @mizuhashi em 有纯 ruby 实现的,也有 java 和 c 实现的,默认基本是 C 实现的

写的真好

killyfreedom Ruby 3 Fiber 变化前瞻 提及了此话题。 08月03日 13:59
需要 登录 后方可回复, 如果你还没有账号请 注册新账号