Ruby Fiber and EventMachine 一些心得

killyfreedom · 2016年02月20日 · 最后由 ywencn 回复于 2016年03月22日 · 7107 次阅读
本帖已被设为精华帖!

大家都知道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倍

希望对大家有帮助

共收到 18 条回复

谢谢分享。

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

  • 因为 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实现的

写的真好

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册