Rubyの並行性と並列性:実用的なチュートリアル
公開: 2022-03-11まず、Ruby開発者の間であまりにも一般的な混乱のポイントを明らかにすることから始めましょう。 つまり、並行性と並列性は同じものではありません(つまり、並行性!=並列性)。
特に、Rubyの同時実行性とは、2つのタスクが重複する期間に開始、実行、および完了することができる場合です。 ただし、必ずしも両方が同時に実行されることを意味するわけではありません(たとえば、シングルコアマシン上の複数のスレッド)。 対照的に、並列処理とは、2つのタスクが文字通り同時に実行される場合です(たとえば、マルチコアプロセッサ上の複数のスレッド)。
ここで重要な点は、並行スレッドやプロセスが必ずしも並行して実行されるとは限らないということです。
このチュートリアルでは、Rubyの並行性と並列性に利用できるさまざまな手法とアプローチの実用的な(理論的ではなく)扱いを提供します。
より現実的なRubyの例については、Rubyインタープリターとランタイムに関する記事を参照してください。
私たちのテストケース
簡単なテストケースとして、次のように、 Mailer
クラスを作成し、( sleep()
メソッドではなく)Fibonacci関数を追加して、各リクエストの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 }
これにより、MRIRuby2.0.0p353を搭載したクアッドコアプロセッサで次の結果が得られました。
15.250000 0.020000 15.270000 ( 15.304447)
マルチプロセスとマルチスレッド
複数のプロセスを使用するか、Rubyアプリケーションをマルチスレッド化するかを決定する場合、「万能」という答えはありません。 次の表は、考慮すべき重要な要素のいくつかをまとめたものです。
プロセス | スレッド |
---|---|
より多くのメモリを使用します | 使用するメモリが少ない |
子供が出る前に親が死んだ場合、子供はゾンビプロセスになる可能性があります | プロセスが終了すると、すべてのスレッドが終了します(ゾンビの可能性はありません) |
OSはすべてを保存してリロードする必要があるため、フォークされたプロセスがコンテキストを切り替えるにはコストがかかります | スレッドはアドレス空間とメモリを共有するため、オーバーヘッドが大幅に少なくなります |
フォークされたプロセスには、新しい仮想メモリスペースが与えられます(プロセス分離) | スレッドは同じメモリを共有するため、同時メモリの問題を制御して処理する必要があります |
プロセス間通信が必要 | キューと共有メモリを介して「通信」できます |
作成と破壊が遅い | 作成と破棄が高速 |
コーディングとデバッグが簡単 | コーディングとデバッグが大幅に複雑になる可能性があります |
複数のプロセスを使用するRubyソリューションの例:
- Resque:バックグラウンドジョブを作成し、それらを複数のキューに配置し、後で処理するためのRedisがサポートするRubyライブラリ。
- Unicorn:低遅延、高帯域幅の接続で高速クライアントのみにサービスを提供し、Unix/Unixライクなカーネルの機能を利用するように設計されたRackアプリケーション用のHTTPサーバー。
マルチスレッドを使用するRubyソリューションの例:
- Sidekiq:Ruby用のフル機能のバックグラウンド処理フレームワーク。 最新のRailsアプリケーションとの統合が簡単で、他の既存のソリューションよりもはるかに高いパフォーマンスを目指しています。
- Puma:並行性のために構築されたRubyWebサーバー。
- シン:非常に高速でシンプルなRubyWebサーバー。
複数のプロセス
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倍高速化しました。
ただし、過度に興奮しないでください。 フォークはRubyの同時実行性の簡単なソリューションであるため、使いたくなるかもしれませんが、消費するメモリの量という大きな欠点があります。 フォークは、特にコピーオンライト(CoW)が使用しているRubyインタープリターによって使用されていない場合、いくらかコストがかかります。 たとえば、アプリが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)
残念。 それは確かにあまり印象的ではありません! どうしたの? コードを同期的に実行したときに得られたのとほぼ同じ結果が得られるのはなぜですか?
多くのRubyプログラマーの存在の悩みの種である答えは、グローバルインタープリターロック(GIL)です。 GILのおかげで、CRuby(MRI実装)は実際にはスレッド化をサポートしていません。
グローバルインタープリターロックは、コンピューター言語インタープリターでスレッドの実行を同期して、一度に1つのスレッドのみを実行できるようにするために使用されるメカニズムです。 GILを使用するインタプリタは、マルチコアプロセッサで実行されている場合でも、常に1つのスレッドと1つのスレッドのみを一度に実行できるようにします。 Ruby MRIとCPythonは、GILを持つ人気のあるインタープリターの最も一般的な例の2つです。
では、問題に戻りましょう。GILに照らして、Rubyでマルチスレッドを活用してパフォーマンスを向上させるにはどうすればよいでしょうか。
さて、MRI(CRuby)では、残念な答えは、基本的に行き詰まっていて、マルチスレッドでできることはほとんどないということです。
ただし、並列処理を使用しないRubyの同時実行は、IOが多いタスク(たとえば、ネットワークで頻繁に待機する必要があるタスク)には非常に役立ちます。 そのため、スレッドはMRIで、IOを多用するタスクに引き続き役立ちます。 結局のところ、マルチコアサーバーが一般的になる前から、スレッドが発明され、使用されていたのには理由があります。

ただし、CRuby以外のバージョンを使用するオプションがある場合は、JRubyやRubiniusなどの代替Ruby実装を使用できます。これは、GILがなく、実際の並列Rubyスレッドをサポートしているためです。
ポイントを証明するために、以前とまったく同じスレッドバージョンのコードを実行したときに得られる結果を次に示しますが、今回は(CRubyではなく)JRubyで実行します。
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のスレッドを生成した後、OSX10.8でエラーが発生しました。
can't create Thread: Resource temporarily unavailable (ThreadError)
予想通り、遅かれ早かれ、私たちはスラッシングを開始するか、リソースを完全に使い果たします。 したがって、このアプローチのスケーラビリティは明らかに制限されています。
スレッドプール
幸いなことに、もっと良い方法があります。 つまり、スレッドプーリングです。
スレッドプールは、必要に応じて作業を実行するために使用できる、事前にインスタンス化された再利用可能なスレッドのグループです。 スレッドプールは、実行する短いタスクが少数ではなく、実行する短いタスクが多数ある場合に特に役立ちます。 これにより、スレッドを何度も作成するオーバーヘッドが発生しなくなります。
スレッドプールの主要な構成パラメーターは、通常、プール内のスレッドの数です。 これらのスレッドは、一度に(つまり、プールが作成されたときに)インスタンス化することも、遅延して(つまり、プール内のスレッドの最大数が作成されるまで必要に応じて)インスタンス化することもできます。
プールに実行するタスクが渡されると、プールはそのタスクを現在アイドル状態のスレッドの1つに割り当てます。 アイドル状態のスレッドがない場合(および最大数のスレッドがすでに作成されている場合)、スレッドが作業を完了してアイドル状態になるのを待ってから、そのスレッドにタスクを割り当てます。
したがって、例に戻ると、 Queue
を使用することから始め(スレッドセーフなデータ型であるため)、スレッドプールの単純な実装を採用します。
「./lib/mailer」が必要「benchmark」が必要「thread」が必要
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エコシステムのおかげで、マルチスレッドの複雑さの多くは、すぐに使用できる多くの使いやすいRubyGemsにきちんとカプセル化されています。
良い例は、私のお気に入りのルビーの宝石の1つであるセルロイドです。 セルロイドフレームワークは、Rubyでアクターベースの並行システムを実装するためのシンプルでクリーンな方法です。 セルロイドを使用すると、シーケンシャルオブジェクトからシーケンシャルプログラムを作成するのと同じくらい簡単に、コンカレントオブジェクトからコンカレントプログラムを作成できます。
この投稿での議論の文脈では、私は特にプール機能に焦点を当てていますが、あなた自身を支持して、それをより詳細にチェックしてください。 Celluloidを使用すると、デッドロックなどの厄介な問題を心配することなくマルチスレッドのRubyプログラムを構築でき、FuturesやPromisesなどの他のより高度な機能を使用するのは簡単です。
メーラープログラムのマルチスレッドバージョンがCelluloidを使用しているのは次のとおりです。
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
クリーンで、簡単で、スケーラブルで、堅牢です。 これ以上何を求めることができますか?
バックグラウンドジョブ
もちろん、運用上の要件と制約に応じて、実行可能な別の代替案は、バックグラウンドジョブを採用することです。 バックグラウンド処理(つまり、ジョブをキューに保存し、現在のスレッドをブロックせずに後で処理する)をサポートするために、多数のRubyGemsが存在します。 注目すべき例としては、Sidekiq、Resque、Delayed Job、Beanstalkdなどがあります。
この投稿では、SidekiqとRedis(オープンソースのKey-Valueキャッシュとストア)を使用します。
まず、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
とてつもなくシンプル。 また、ワーカー数を変更するだけで簡単に拡張できます。
もう1つのオプションは、私のお気に入りの非同期RoR処理ライブラリの1つであるSuckerPunchを使用することです。 SuckerPunchを使用した実装は非常に似ています。 Sidekiq::Worker
ではなくSuckerPunch::Job
を含め、 MailWorker.new.async.perform()
ではなくMailWorker.perform_async()
を含める必要があります。
結論
高い同時実行性はRubyで達成できるだけでなく、想像以上に単純です。
実行可能なアプローチの1つは、実行中のプロセスをフォークして処理能力を増やすことです。 もう1つの手法は、マルチスレッドを利用することです。 スレッドはプロセスよりも軽量であり、必要なオーバーヘッドは少なくなりますが、同時に開始するスレッドが多すぎると、リソースが不足する可能性があります。 ある時点で、スレッドプールを使用する必要があるかもしれません。 幸い、マルチスレッドの複雑さの多くは、Celluloidやそのアクターモデルなど、利用可能な多くの宝石のいずれかを活用することで簡単になります。
時間のかかるプロセスを処理する別の方法は、バックグラウンド処理を使用することです。 アプリケーションにバックグラウンドジョブを実装できるようにするライブラリとサービスはたくさんあります。 人気のあるツールには、データベースに基づくジョブフレームワークやメッセージキューなどがあります。
フォーク、スレッド化、およびバックグラウンド処理はすべて実行可能な代替手段です。 どちらを使用するかは、アプリケーションの性質、運用環境、および要件によって異なります。 うまくいけば、このチュートリアルが利用可能なオプションの有用な紹介を提供しました。