Ruby 並發和並行:實用教程
已發表: 2022-03-11讓我們首先澄清一個在 Ruby 開發人員中非常常見的混淆點; 即:並發和並行不是一回事(即並發!=並行)。
特別是,Ruby並發是指兩個任務可以在重疊的時間段內啟動、運行和完成。 但是,這並不一定意味著它們會同時運行(例如,單核機器上的多個線程)。 相反,並行性是指兩個任務同時運行(例如,多核處理器上的多個線程)。
這裡的關鍵點是並發線程和/或進程不一定會並行運行。
本教程對 Ruby 中可用於並發和並行性的各種技術和方法進行了實踐(而不是理論)處理。
有關更多真實世界的 Ruby 示例,請參閱我們關於 Ruby 解釋器和運行時的文章。
我們的測試用例
對於一個簡單的測試用例,我將創建一個Mailer
類並添加一個 Fibonacci 函數(而不是sleep()
方法)以使每個請求更加佔用 CPU,如下所示:
class Mailer def self.deliver(&block) mail = MailBuilder.new(&block).mail mail.send_mail end Mail = Struct.new(:from, :to, :subject, :body) do def send_mail fib(30) puts "Email from: #{from}" puts "Email to : #{to}" puts "Subject : #{subject}" puts "Body : #{body}" end def fib(n) n < 2 ? n : fib(n-1) + fib(n-2) end end class MailBuilder def initialize(&block) @mail = Mail.new instance_eval(&block) end attr_reader :mail %w(from to subject body).each do |m| define_method(m) do |val| @mail.send("#{m}=", val) end end end end
然後我們可以如下調用這個Mailer
類來發送郵件:
Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end
(注意:此測試用例的源代碼可在 github 上找到。)
為了建立比較基準,讓我們先做一個簡單的基準測試,調用郵件程序 100 次:
puts Benchmark.measure{ 100.times do |i| Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end }
這在使用 MRI Ruby 2.0.0p353 的四核處理器上產生了以下結果:
15.250000 0.020000 15.270000 ( 15.304447)
多進程與多線程
在決定是使用多進程還是多線程 Ruby 應用程序時,沒有“一刀切”的答案。 下表總結了一些需要考慮的關鍵因素。
流程 | 線程 |
---|---|
使用更多內存 | 使用更少的內存 |
如果父進程在子進程退出之前就死了,子進程就會變成殭屍進程 | 當進程死亡時所有線程都死亡(沒有殭屍的機會) |
由於操作系統需要保存和重新加載所有內容,因此分叉進程切換上下文的成本更高 | 線程的開銷要少得多,因為它們共享地址空間和內存 |
分叉的進程被賦予一個新的虛擬內存空間(進程隔離) | 線程共享同一個內存,所以需要控制和處理並發內存問題 |
需要進程間通信 | 可以通過隊列和共享內存“通信” |
創建和銷毀速度較慢 | 更快地創建和銷毀 |
更容易編碼和調試 | 編碼和調試可能要復雜得多 |
使用多個進程的 Ruby 解決方案示例:
- Resque:一個 Redis 支持的 Ruby 庫,用於創建後台作業,將它們放置在多個隊列中,並在以後處理它們。
- Unicorn:用於 Rack 應用程序的 HTTP 服務器,旨在僅在低延遲、高帶寬連接上為快速客戶端提供服務,並利用 Unix/類 Unix 內核中的功能。
使用多線程的 Ruby 解決方案示例:
- Sidekiq:用於 Ruby 的全功能後台處理框架。 它旨在易於與任何現代 Rails 應用程序集成,並且比其他現有解決方案具有更高的性能。
- Puma:為並發而構建的 Ruby Web 服務器。
- Thin:一個非常快速和簡單的 Ruby Web 服務器。
多進程
在我們研究 Ruby 多線程選項之前,讓我們探索產生多個進程的更簡單的路徑。
在 Ruby 中, fork()
系統調用用於創建當前進程的“副本”。 這個新進程是在操作系統級別調度的,因此它可以與原始進程同時運行,就像任何其他獨立進程一樣。 (注意: fork()
是一個 POSIX 系統調用,因此如果您在 Windows 平台上運行 Ruby,則不可用。)
好的,讓我們運行我們的測試用例,但這次使用fork()
來使用多個進程:
puts Benchmark.measure{ 100.times do |i| fork do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end Process.waitall }
( Process.waitall
等待所有子進程退出並返回一個進程狀態數組。)
此代碼現在產生以下結果(同樣,在具有 MRI Ruby 2.0.0p353 的四核處理器上):
0.000000 0.030000 27.000000 ( 3.788106)
不是太寒酸! 我們通過修改幾行代碼(即使用fork()
)使郵件程序快了約 5 倍。
不過不要太興奮。 儘管使用 fork 可能很誘人,因為它是 Ruby 並發的簡單解決方案,但它有一個主要缺點,即它會消耗大量內存。 分叉有點昂貴,特別是如果您正在使用的 Ruby 解釋器沒有使用 Copy-on-Write (CoW)。 例如,如果您的應用程序使用 20MB 內存,則將其分叉 100 次可能會消耗多達 2GB 的內存!
此外,儘管多線程也有其自身的複雜性,但在使用fork()
時需要考慮許多複雜性,例如共享文件描述符和信號量(在父子進程和子進程之間),需要通過管道進行通信, 等等。
Ruby 多線程
好的,現在讓我們嘗試使用 Ruby 多線程技術來加快相同的程序。
單個進程中的多個線程的開銷比相應數量的進程要少得多,因為它們共享地址空間和內存。
考慮到這一點,讓我們重新審視我們的測試用例,但這次使用 Ruby 的Thread
類:
threads = [] puts Benchmark.measure{ 100.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }
此代碼現在產生以下結果(同樣,在具有 MRI Ruby 2.0.0p353 的四核處理器上):
13.710000 0.040000 13.750000 ( 13.740204)
真可惜。 這肯定不是很令人印象深刻! 發生什麼了? 為什麼這會產生與我們同步運行代碼時幾乎相同的結果?
答案是全球解釋器鎖 (GIL) ,它是許多 Ruby 程序員存在的禍根。 多虧了 GIL,CRuby(MRI 實現)並不真正支持線程。
全局解釋器鎖是計算機語言解釋器中使用的一種機制,用於同步線程的執行,以便一次只能執行一個線程。 使用 GIL 的解釋器將始終只允許一個線程和一個線程一次執行,即使在多核處理器上運行也是如此。 Ruby MRI 和 CPython 是具有 GIL 的流行解釋器的兩個最常見示例。
回到我們的問題,我們如何利用 Ruby 中的多線程來提高 GIL 的性能?
好吧,在 MRI (CRuby) 中,不幸的答案是你基本上被卡住了,多線程可以為你做的很少。

但是,對於 IO 繁重的任務(例如,需要經常在網絡上等待的任務),沒有並行性的 Ruby 並發仍然非常有用。 因此,對於 IO 繁重的任務,線程在 MRI中仍然很有用。 畢竟,線程在多核服務器普及之前就被發明和使用是有原因的。
但話雖如此,如果您可以選擇使用 CRuby 以外的版本,則可以使用替代的 Ruby 實現,例如 JRuby 或 Rubinius,因為它們沒有 GIL,而且它們確實支持真正的並行 Ruby 線程。
為了證明這一點,下面是我們運行與以前完全相同的線程版本代碼時得到的結果,但這次在 JRuby(而不是 CRuby)上運行它:
43.240000 0.140000 43.380000 ( 5.655000)
現在我們正在談論!
但…
線程不是免費的
多線程性能的提高可能會讓人相信我們可以不斷地添加更多線程——基本上是無限的——來讓我們的代碼運行得越來越快。 如果這是真的,那確實很好,但現實情況是線程不是免費的,所以遲早會耗盡資源。
例如,假設我們希望運行示例郵件程序不是 100 次,而是 10,000 次。 讓我們看看發生了什麼:
threads = [] puts Benchmark.measure{ 10_000.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }
繁榮! 在生成大約 2,000 個線程後,我的 OS X 10.8 出現錯誤:
can't create Thread: Resource temporarily unavailable (ThreadError)
正如預期的那樣,我們遲早會開始崩潰或完全耗盡資源。 所以這種方法的可擴展性顯然是有限的。
線程池
幸運的是,有更好的方法; 即線程池。
線程池是一組預先實例化的、可重用的線程,可用於根據需要執行工作。 當需要執行大量短任務而不是少量較長任務時,線程池特別有用。 這可以避免產生大量創建線程的開銷。
線程池的一個關鍵配置參數通常是池中的線程數。 這些線程可以一次全部實例化(即,在創建池時)或延遲實例化(即,根據需要,直到創建池中的最大線程數)。
當池被交給要執行的任務時,它會將任務分配給當前空閒的線程之一。 如果沒有線程處於空閒狀態(並且已經創建了最大線程數),它會等待線程完成其工作並變為空閒狀態,然後將任務分配給該線程。
因此,回到我們的示例,我們將從使用Queue
開始(因為它是線程安全的數據類型)並使用線程池的簡單實現:
需要“./lib/mailer” 需要“基準” 需要“線程”
POOL_SIZE = 10 jobs = Queue.new 10_0000.times{|i| jobs.push i} workers = (POOL_SIZE).times.map do Thread.new do begin while x = jobs.pop(true) Mailer.deliver do from "eki_#{x}@eqbalq.com" to "jill_#{x}@example.com" subject "Threading and Forking (#{x})" body "Some content" end end rescue ThreadError end end end workers.map(&:join)
在上面的代碼中,我們首先為需要執行的作業創建了一個jobs
隊列。 我們為此目的使用了Queue
,因為它是線程安全的(因此如果多個線程同時訪問它,它將保持一致性),這避免了需要使用互斥鎖的更複雜的實現。
然後,我們將郵件程序的 ID 推送到作業隊列並創建了 10 個工作線程池。
在每個工作線程中,我們從作業隊列中彈出項目。
因此,工作線程的生命週期是不斷地等待任務放入作業隊列並執行它們。
所以好消息是,這可以正常工作並且可以擴展,沒有任何問題。 不幸的是,即使對於我們簡單的教程,這也相當複雜。
賽璐珞
多虧了 Ruby Gem 生態系統,多線程的大部分複雜性都被巧妙地封裝在許多開箱即用的易於使用的 Ruby Gem 中。
一個很好的例子是賽璐珞,我最喜歡的紅寶石之一。 賽璐珞框架是在 Ruby 中實現基於角色的並發系統的一種簡單而乾淨的方法。 賽璐珞使人們能夠從並發對象構建並發程序,就像他們從順序對象構建順序程序一樣容易。
在本文討論的上下文中,我特別關注池功能,但請幫自己一個忙,並更詳細地查看它。 使用賽璐珞,您將能夠構建多線程 Ruby 程序,而不必擔心諸如死鎖之類的令人討厭的問題,並且您會發現使用其他更複雜的功能(例如 Futures 和 Promises)是微不足道的。
以下是我們的郵件程序的多線程版本使用賽璐珞的簡單程度:
require "./lib/mailer" require "benchmark" require "celluloid" class MailWorker include Celluloid def send_email(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end mailer_pool = MailWorker.pool(size: 10) 10_000.times do |i| mailer_pool.async.send_email(i) end
乾淨、簡單、可擴展且強大。 你還能要求什麼呢?
後台作業
當然,根據您的操作要求和限制,另一個可能可行的替代方案是使用後台作業。 許多 Ruby Gem 支持後台處理(即,將作業保存在隊列中並稍後處理它們而不阻塞當前線程)。 值得注意的例子包括 Sidekiq、Resque、Delayed Job 和 Beanstalkd。
在這篇文章中,我將使用 Sidekiq 和 Redis(一種開源鍵值緩存和存儲)。
首先,讓我們安裝 Redis 並在本地運行它:
brew install redis redis-server /usr/local/etc/redis.conf
隨著我們的本地 Redis 實例運行,讓我們看一下使用 Sidekiq 的示例郵件程序 ( mail_worker.rb
) 的一個版本:
require_relative "../lib/mailer" require "sidekiq" class MailWorker include Sidekiq::Worker def perform(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end
我們可以使用mail_worker.rb
文件觸發 Sidekiq:
sidekiq -r ./mail_worker.rb
然後來自 IRB:
⇒ irb >> require_relative "mail_worker" => true >> 100.times{|i| MailWorker.perform_async(i)} 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100
非常簡單。 只需更改工人的數量,它就可以輕鬆擴展。
另一種選擇是使用 Sucker Punch,這是我最喜歡的異步 RoR 處理庫之一。 使用 Sucker Punch 的實現將非常相似。 我們只需要包含SuckerPunch::Job
而不是Sidekiq::Worker
和MailWorker.new.async.perform()
而不是MailWorker.perform_async()
。
結論
高並發不僅在 Ruby 中可以實現,而且比您想像的要簡單。
一種可行的方法是簡單地分叉一個正在運行的進程以增加其處理能力。 另一種技術是利用多線程。 儘管線程比進程更輕量,需要更少的開銷,但如果同時啟動太多線程,您仍然可能會耗盡資源。 在某些時候,您可能會發現有必要使用線程池。 幸運的是,多線程的許多複雜性都可以通過利用許多可用的 gem 中的任何一個來簡化,例如 Celluloid 及其 Actor 模型。
另一種處理耗時過程的方法是使用後台處理。 有許多庫和服務允許您在應用程序中實現後台作業。 一些流行的工具包括數據庫支持的作業框架和消息隊列。
分叉、線程和後台處理都是可行的選擇。 關於使用哪一個的決定取決於您的應用程序的性質、您的操作環境和要求。 希望本教程對可用選項提供了有用的介紹。