作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
平等的古兰经头像

Eqbal古兰经

Eqbal是一名高级全栈开发人员,拥有超过十年的web和移动开发经验.

专业知识

以前在

语法
分享

让我们首先澄清一个非常常见的混淆点 Ruby开发人员; namely: 并发性 和 并行性 are 同样的事情(i.e.,并发 !=平行).

特别是Ruby 并发性 两个任务何时可以同时启动、运行和完成 重叠 时间. 不过,这并不一定意味着它们会在同一时刻运行.g.(单核机器上的多个线程). 相比之下, 并行性 是两个任务同时运行的时候 同时 (e.g.(多核处理器上的多线程).

这里的关键点是并发线程和/或进程将 不一定 并行运行.

本教程提供了对Ruby中可用于并发性和并行性的各种技术和方法的实用(而非理论)处理.

有关更多真实世界的Ruby示例,请参阅关于的文章 Ruby解释器和运行时.

我们的测试案例

对于一个简单的测试用例,我将创建 梅勒 类并添加斐波那契函数(而不是 睡眠() 方法)使每个请求更加占用cpu,如下所示:

类梅勒

  def自我.交付(&块)
    邮件 = MailBuilder.新(&块).邮件
    邮件.s结束_邮件
  结束

  邮件= Struct.New (:from,:to,:subject,:body) do 
    def s结束_邮件
      心房纤颤(30)
      放置"E邮件 from: #{from}"
      将"E邮件 to: #{to}"
      放置"主题:#{主题}"
      放置"Body: #{Body}"
    结束

    def fib (n)
      n < 2 ? N: fib(N -1) + fib(N -2)
    结束  
  结束

  类MailBuilder
    def初始化(&块)
      @邮件 =邮件.新
      instance_eval (&块)
    结束
    
    . attr_reader:邮件

    %w(从主题到主体).各做各的
      定义方法(m) do |val|
        @邮件.发送(" # {m} = ", val)
      结束
    结束
  结束
结束

然后我们可以调用这个 梅勒 类发送邮件如下:

梅勒.提供做 
  从“eki@eqbalq.com”
  “jill@example.com”
  主题“线程和分叉”
  body“一些内容”
结束

(注意:这个测试用例的源代码是可用的 在这里 github上.)

建立基线以作比较, 让我们从做一个简单的基准测试开始, 调用邮件器100次:

将基准.测量{
  100.时间是这样的
    梅勒.提供做 
      我从“eki_ # {} @eqbalq.com”
      “jill_ #{我}@example.com”
      主题“线程和分叉(#{i})”
      body“一些内容”
    结束
  结束
}

这在使用MRI Ruby 2的四核处理器上产生了以下结果.0.0p353:

15.250000   0.020000  15.270000 ( 15.304447)

多进程vs. 多线程

在决定是使用多个进程还是让Ruby应用程序多线程时,没有一个“放之四海而皆准”的答案. 下表总结了一些需要考虑的关键因素.

流程线程
使用更多内存使用更少的内存
如果父进程在子进程退出之前死亡,则子进程可能成为僵尸进程当进程死亡时,所有线程都会死亡(没有僵尸的机会)
由于操作系统需要保存和重新加载所有内容,因此分叉进程切换上下文的成本更高线程的开销要小得多,因为它们共享地址空间和内存
分叉进程被赋予一个新的虚拟内存空间(进程隔离)线程共享相同的内存,因此需要控制和处理并发内存问题
需要进程间通信能“沟通”吗 队列 共享内存
创造和毁灭的速度更慢更快地创造和毁灭
更容易编码和调试可以更复杂的编码和调试

使用多个进程的Ruby解决方案示例:

  • Resque一个由redis支持的Ruby库,用于创建后台作业, 将它们放在多个队列中, 然后再处理.
  • 独角兽:用于机架应用程序的HTTP服务器,专为低延迟的快速客户端提供服务, 高带宽连接并利用Unix/类Unix内核中的特性.

使用多线程的Ruby解决方案示例:

  • Sidekiq: Ruby的全功能后台处理框架. 它的目标是简单地与任何现代Rails应用程序集成,并且比其他现有解决方案具有更高的性能.
  • 彪马:一个为并发而构建的Ruby web服务器.
  • 一个非常快速和简单的Ruby web服务器.

多个进程

在我们研究Ruby多线程选项之前, 让我们探索生成多个进程的简单路径.

在Ruby中, fork () 系统调用用于创建当前进程的“副本”. 这个新进程是在操作系统级别调度的, 因此它可以与原始进程并发运行, 就像任何其他独立的过程一样. (注意: fork () 是一个POSIX系统调用,因此在Windows平台上运行Ruby是不可用的.)

让我们运行测试用例,但这次使用 fork () 采用多种方法:

将基准.测量{
  100.时间是这样的
    叉做     
      梅勒.提供做 
        我从“eki_ # {} @eqbalq.com”
        “jill_ #{我}@example.com”
        主题“线程和分叉(#{i})”
        body“一些内容”
      结束
    结束
  结束
  过程.wait所有
}

(过程.wait所有 等待 所有 子进程退出并返回进程状态数组.)

这段代码现在产生以下结果(同样是在使用MRI Ruby 2的四核处理器上).0.0p353):

0.000000   0.030000  27.000000 (  3.788106)

不太寒酸! 我们只修改了几行代码(例如.e.,使用 fork ()).

但不要过于兴奋. 虽然使用分叉可能很诱人,因为它是Ruby并发性的简单解决方案, 它有一个主要的缺点,那就是它将消耗大量的内存. 分叉是有点昂贵的,特别是如果 即写即拷(牛) 没有被您正在使用的Ruby解释器利用. 如果你的应用使用了20MB的内存, 例如, 将它分叉100次可能会消耗多达2GB的内存!

也, 尽管多线程也有它自己的复杂性, 在使用时需要考虑许多复杂性 fork (), 例如共享文件描述符和信号量(在父和子分支进程之间), 需要通过管道进行通信, 等等......。.

Ruby多线程

好了,现在让我们试着用Ruby多线程技术让同样的程序变得更快.

由于共享地址空间和内存,单个进程中的多个线程的开销比相应数量的进程要小得多.

考虑到这一点,让我们重新审视我们的测试用例,但这次使用Ruby的测试用例 线程 类:

线程= []

将基准.测量{
  100.时间是这样的
    线程 << 线程.新做的     
      梅勒.提供做 
        我从“eki_ # {} @eqbalq.com”
        “jill_ #{我}@example.com”
        主题“线程和分叉(#{i})”
        body“一些内容”
      结束
    结束
  结束
  线程.地图(&:加入)
}

这段代码现在产生以下结果(同样是在使用MRI Ruby 2的四核处理器上).0.0p353):

13.710000   0.040000  13.750000 ( 13.740204)

游手好闲的人. 这确实不令人印象深刻! 这是怎么回事呢? 为什么这会产生与同步运行代码时几乎相同的结果?

答案是,这是许多Ruby程序员存在的祸根 全局解释器锁. 由于GIL, CRuby (MRI实现)并不真正支持线程.

全局解释器锁 在计算机语言解释器中是否使用一种机制来同步线程的执行,以便一次只能执行一个线程. 使用GIL的解释器会 总是 只允许一个线程和 一次只能执行一个线程,即使在多核处理器上运行. Ruby MRI和CPython是两个最常见的具有GIL的解释器的例子.

回到我们的问题, 我们如何利用Ruby中的多线程来提高GIL的性能?

好吧, 核磁共振成像(CRuby), 不幸的答案是,您基本上卡住了,多线程几乎不能为您做什么.

没有并行性的Ruby并发性仍然非常有用, 虽然, 对于io繁重的任务(e.g.(需要经常在网络上等待的任务). 所以线程 可以 在MRI中仍然有用,对于io繁重的任务. 线程是有原因的, 毕竟, 甚至在多核服务器普及之前就发明和使用了.

但也就是说, 如果您可以选择使用CRuby以外的其他版本, 您可以使用另一种Ruby实现,例如 JRuby or Rubinius,因为它们没有GIL,而且它们确实支持真正的并行Ruby线程.

JRuby线程化

为了证明这一点, 下面是我们运行与之前完全相同的线程版本代码时得到的结果, 但这次在JRuby上运行它(而不是CRuby):

43.240000   0.140000  43.380000 (  5.655000)

现在我们来谈谈!

但是…

线程不是免费的

多线程带来的性能提升可能会让人相信,只要不断添加更多的线程——基本上是无限地——就能让代码运行得越来越快. 如果这是真的那就太好了, 但现实是线程不是免费的, 迟早的事, 你将耗尽资源.

例如,我们想要运行示例邮件程序,不是100次,而是10,000次. 让我们看看会发生什么:

线程= []

将基准.测量{
  10_000.时间是这样的
    线程 << 线程.新做的     
      梅勒.提供做 
        我从“eki_ # {} @eqbalq.com”
        “jill_ #{我}@example.com”
        主题“线程和分叉(#{i})”
        body“一些内容”
      结束
    结束
  结束
  线程.地图(&:加入)
}

繁荣! 我的OS X 10出错了.8产卵后大约2000个线程:

无法创建线程:资源暂时不可用(线程Error)

不出所料,我们迟早会开始折腾,或者完全耗尽资源. 因此,这种方法的可扩展性显然是有限的.

线程池

幸运的是, t在这里 is a better way; namely, thread pooling.

线程池是一组预实例化的线程, 可根据需要执行工作的可重用线程. 当有大量的短任务要执行而不是少量的长任务要执行时,线程池特别有用. 这可以避免多次创建线程带来的开销.

线程池的一个关键配置参数通常是线程池中的线程数. 这些线程可以一次全部实例化(i.e.,当池创建时)或惰性地(i.e.,直到创建了池中的最大线程数为止)。.

当将任务交给池执行时,它会将任务分配给当前空闲的线程之一. 如果没有线程是空闲的(并且已经创建了最大数量的线程),它等待线程完成它的工作并变得空闲,然后将任务分配给该线程.

线程池

那么,回到我们的例子,我们将从使用 队列 (因为它是 线程安全的 数据类型)并使用线程池的简单实现:

需要“./lib/邮件er” 需要“基准” 需要“线程”

Pool_size = 10

工作 = 队列.新

10_0000.我*{| |工作.推动我}

工人 = (POOL_SIZE).次.地图做的
  线程.新做的
    开始      
      而x =工作.流行(真正的)
        梅勒.提供做 
          从“eki_ # {x} @eqbalq.com”
          “jill_ # {x} @example.com”
          主题“线程和分叉(#{x})”
          body“一些内容”
        结束        
      结束
    救援线程Error
    结束
  结束
结束

工人.地图(&:加入)

在上面的代码中,我们首先创建一个 工作 为需要执行的作业排队. 我们使用 队列 出于这个目的,因为它是线程安全的(所以如果多个线程同时访问它), 它将保持一致性,从而避免了需要使用更复杂的实现 互斥锁.

然后,我们将邮件的id推送到作业队列,并创建了包含10个工作线程的池.

在每个工作线程中,我们从作业队列中弹出项目.

因此, 工作线程的生命周期就是不断地等待任务被放入作业队列并执行它们.

好消息是,这是可行的,而且扩展没有任何问题. 不幸的是,即使对于我们简单的教程来说,这也是相当复杂的.

赛璐珞

多亏了 Ruby Gem 生态系统, 多线程的许多复杂性被整齐地封装在许多易于使用的开箱即用Ruby Gems中.

赛璐珞就是一个很好的例子,它是我最喜欢的红宝石之一. 赛璐珞框架是在Ruby中实现基于参与者的并发系统的一种简单而干净的方式. 赛璐珞 使人们能够从并发对象中构建并发程序,就像从顺序对象中构建顺序程序一样简单.

在我们这篇文章讨论的背景下, 我特别关注的是泳池功能, 但是帮你自己一个忙,更详细地检查一下. 使用赛璐珞,您将能够构建多线程Ruby程序,而不必担心死锁等讨厌的问题, 你会发现使用其他更复杂的功能,如期货和承诺,是微不足道的.

下面是我们的邮件程序的多线程版本是如何使用赛璐珞的:

需要“./lib/邮件er”
需要“基准”
需要“赛璐珞”

类MailWorker
  包括电影

  def s结束_e邮件 (id)
    梅勒.提供做 
      从“eki_ # {id} @eqbalq.com”
      “jill_ # {id} @example.com”
      主题“线程和分叉(#{id})”
      body“一些内容”
    结束       
  结束
结束

邮件er_pool = MailWorker.池(尺寸:10)

10_000.时间是这样的
  邮件er_pool.异步.s结束_e邮件(我)
结束

干净、简单、可扩展和健壮. 你还能要求什么呢?

背景的工作

当然, 另一个可能可行的选择, 根据您的操作需求和约束条件将被采用 背景的工作. 有很多Ruby Gems支持后台处理(如.e.(将作业保存在队列中,稍后在不阻塞当前线程的情况下处理它们). 值得注意的例子包括 Sidekiq, Resque, 拖延工作, Beanstalkd.

对于这篇文章,我将使用 Sidekiq复述, (一个开源的键值缓存和存储).

首先,让我们安装复述,并在本地运行:

Brew安装redis
redis-server /usr/local/etc/redis.相依

随着本地复述,实例的运行,让我们来看看我们的示例邮件程序的一个版本(邮件_worker.rb)使用Sidekiq:

require_relative”../lib/邮件er”
需要“sidekiq”

类MailWorker
  包括Sidekiq:工人
  
  def执行(id)
    梅勒.提供做 
      从“eki_ # {id} @eqbalq.com”
      “jill_ # {id} @example.com”
      主题“线程和分叉(#{id})”
      body“一些内容”
    结束  
  结束
结束

我们可以用 邮件_worker.rb 文件:

sidekiq - r ./ 邮件_worker.rb

然后从 IRB:

⇒irb
>> require_relative”邮件_worker"
=> true
>> 100.次我{| | MailWorker.perform_异步 (i)}
2014-12-20 02:42:30 z46549 TID-ouh10w8gw信息:Sidekiq客户端与redis选项{}
=> 100

惊叹地简单. 只要改变员工的数量,它就可以很容易地扩大规模.

另一种选择是使用 《欧博体育app下载》是我最喜欢的异步RoR处理库之一. 使用《欧博体育app下载》的实现将非常相似. 我们只需要包含 SuckerPunch::工作 而不是 Sidekiq:工人, MailWorker.新.异步.执行()MailWorker.perform_异步 ().

结论

高并发性不仅在 Ruby,但也比你想象的简单.

一种可行的方法是简单地派生一个正在运行的进程,以增加其处理能力. 另一种技术是利用多线程. 尽管线程比进程轻, 需要更少的开销, 如果同时启动太多线程,仍然可能耗尽资源. 在某些情况下,您可能会发现有必要使用线程池. 幸运的是, 通过利用许多可用的gem,可以简化多线程的许多复杂性, 比如赛璐珞和它的演员模型.

处理耗时进程的另一种方法是使用后台处理. 有许多库和服务允许您在应用程序中实现后台作业. 一些流行的工具包括数据库支持的作业框架和消息队列.

分叉、线程和后台处理都是可行的替代方案. 使用哪一个取决于应用程序的性质, 您的操作环境, 和需求. 希望本教程对可用的选项提供了有用的介绍.

聘请Toptal这方面的专家.
现在雇佣
平等的古兰经头像
Eqbal古兰经

位于 安曼,约旦安曼省

成员自 2014年6月13日

作者简介

Eqbal是一名高级全栈开发人员,拥有超过十年的web和移动开发经验.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

专业知识

以前在

语法

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

Toptal开发者

加入总冠军® 社区.