あいわらず鍋ばかり食べているWebエンジニアの本間です。 ただ、鍋の種類はごま豆乳鍋から寄せ鍋に変わりました。
さて1ヶ月以上遅れになりますが、 Ruby 3.0 が無事リリースされました。 まずはリリースに携わった全ての皆さま、本当にありがとうございます。 また今回は念願のメジャーバージョンアップであり、Ruby 3x3の達成おめでとうございます! 速く、使いやすくなったRubyをこれからもバリバリと使わせいただこうと思っています。
そのRuby 3.0で並行処理関連の2つの機能が追加されました。
- Ractor
- Fiber Scheduler
これらを見て「そういえばRubyの並行処理って何があるんだろう?」という疑問が浮かびました。
また、過去にこのようなエントリを投稿していたこともあります。
新しい機能が追加されたタイミングで過去の機能も含めて整理しておきたいと思い、このエントリを書こうと思いました。
並行処理の種類
自分が知る限りRubyで並行処理を行う場合、以下の選択肢があります。
- Thread
- Process
- Fiber
- Ractor
それぞれ本当に並行処理ができるのか、サンプルコードでチェックしてみます。
まず、比較用に並行処理なしで sleep 5.0
を逐次実行するコードを用意します。
def sleepy(duration = 5.0) sleep duration end puts "Start: none" 4.times { sleepy } puts "End"
これを実行してみると、
$ time ruby test/none.rb Start: none End real 0m20.251s user 0m0.097s sys 0m0.067s
となり、「5秒間sleep」×「4回」で約20秒ほど完了まで時間がかかります。
Thread
最初はThreadです。 Rubyで並行処理を検討する場合、一番お手軽な選択肢だと思います。
今回は parallel gemを使って試してみます。
require 'parallel' def sleepy(duration = 5.0) sleep duration end puts "Start: thread" Parallel.each(Array.new(4), in_threads: 4) do sleepy end puts "End"
$ time ruby test/thread.rb Start: thread End real 0m5.256s user 0m0.109s sys 0m0.075s
sleep 5.0
が並列で実行されたため、約5秒で処理が完了しています。
Process
次はProcessです。
Kernel#fork
を使用することで別プロセスを生成して並行処理を実現しています。
こちらも parallel gemを使って試してみます。
require 'parallel' def sleepy(duration = 5.0) sleep duration end puts "Start: process" Parallel.each(Array.new(4), in_processes: 4) do sleepy end puts "End"
$ time ruby test/process.rb Start: process End real 0m5.479s user 0m0.154s sys 0m0.132s
スレッドと同じく、 sleep 5.0
が並列で実行されていることがわかります。
あとProcessの場合だけ、rubyのプロセスが5個生成されます。(他は1つのみ)
ps a | grep ruby # => 94967 s000 S+ 0:00.13 ruby test/process.rb # => 94995 s000 S+ 0:00.00 ruby test/process.rb # => 94996 s000 S+ 0:00.00 ruby test/process.rb # => 94997 s000 S+ 0:00.00 ruby test/process.rb # => 94998 s000 S+ 0:00.00 ruby test/process.rb
Fiber
次はFiberです。 FiberはRuby 1.9から導入しているのですが、これまで使ったことはありませんでした。
調べてみるとわかりますが、ThreadやProcessと異なりFiberを使っただけでは自動で並行処理を実現することはできません。
def sleepy(duration = 5.0) sleep duration end puts "Start: process" fibers = 4.times.map { Fiber.new { sleepy } } fibers.each(&:resume) puts "End"
こんな感じのプログラムを実行すると、並行処理なしと同じく20秒ほど完了までかかります。 Fiberは並行処理を実現する一つの仕組みですが、その制御はユーザー自身で行わなければなりません。
今回、Ruby 3.0で入ったFiber Schedulerの機能を使うことで、ユーザー側でスケジューリングを柔軟に制御することができるようになりました。 適切なSchedulerを実装しFiberにセットすることで、ThreadやProcessと同じように、もしくはそれ以上に効率的に並行処理を実行できる可能性があります。
しかし、Schedulerの実装は自分には難しすぎたので、今回は async gemを使わせてもらい、お手軽にFiberで並行処理をやってみます。
require 'async' def sleepy(duration = 5.0) Async do |task| sleep duration end end puts "Start: fiber" task = Async do 4.times { sleepy } end task.wait puts "End"
$ time ruby test/fiber.rb Start: fiber End real 0m5.355s user 0m0.142s sys 0m0.093s
無事、並行に動かすことができました。
Ractor
最後に、Ruby 3.0で追加されたRactorです。 Ractorに関しては、クックパッド社の開発者ブログで開発者の笹田さんから詳しく解説しているので、そちらをご一読することをオススメします。
こちらのブログを参考に、とりあえず sleep 5.0
を並列で動かすプログラムを書いてみます。
def sleepy(duration = 5.0) sleep duration end puts "Start: ractor" 4.times.map { Ractor.new { sleepy } }.each(&:take) puts "End"
time ruby test/ractor.rb Start: ractor <internal:ractor>:267: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues. End real 0m5.252s user 0m0.101s sys 0m0.071s
実行してみると警告が出ますが、無事並行で sleep 5.0
を動かすことができました!
以上から、4つあげた機能全てで並行処理ができることを確認しました。
複数CPUの利用
Rubyで並行処理の話をする場合、複数のCPUが利用できるかどうかも必ずチェックする必要があります。 Rubyは基本的に「1プロセス 1 CPU」という制約があったのですが、そこが今回追加されたRactorでどうなるかを確認しておきたいです。
複数CPUを使えるかどうかの確認に、Ractorの紹介ブログで記載されていたbenchmarkプログラムをほぼそのまま流用させていただきました。
require 'benchmark' require 'async' require 'parallel' def tarai(x = 14, y = 7, z = 0) x <= y ? y : tarai(tarai(x-1, y, z), tarai(y-1, z, x), tarai(z-1, x, y)) end Benchmark.bm(8) do |x| x.report('none') { 4.times{ tarai } } x.report('thread') { Parallel.each(Array.new(4), in_threads: 4) { tarai } } x.report('process') { Parallel.each(Array.new(4), in_processes: 4) { tarai } } x.report('fiber') { task = Async do 4.times { Async() { |_task| tarai } } end task.wait } x.report('ractor') { 4.times.map do Ractor.new { tarai } end.each(&:take) } end
上記のRubyコードを自分のMac(4 Core CPU, 8 Hyper threads)で動かしてみます。 結果は下記のようになりました。
$ bash-3.2$ ruby -W0 benchmark.rb user system total real none 90.377737 0.223379 90.601116 ( 91.481220) thread 95.449784 0.296152 95.745936 ( 96.297949) process 0.001782 0.003923 116.020758 ( 29.516097) fiber 89.511911 0.113980 89.625891 ( 89.925268) ractor 102.737935 0.186575 102.924510 ( 27.250595)
ProcessとRactorが約3.5倍速いですね!
MacのActivity Monitorで、CPU利用率やプロセス数がどう変化するかも見てみました。
まず「並行処理なし」、「Thread」、「Fiber」は概ね以下のようになります。
Rubyのプロセスが1つだけで、CPU使用率は100%になります。 この環境では4個CPUがあるため、4つフルに使用した場合、400%近くになるはずです。 そのためこれらの場合、複数CPUは使用しておらず1個のCPUのみを使用している状態と考えられます。
次にProcessです。
こちらは4つのRubyプロセス(正確には大元のRubyプロセスが別でもう1つある)があり、それぞれがCPUを100%近く使用しています。 合計すると400%近くになり、4つのCPUをフルで使えています。
最後にRactorです。
こちらは1つのプロセスで、CPU使用率が400%近くになっています。 1つのRubyプロセスで、4つのCPUをフルに使えていることがわかりました。 これは今までのRubyではできなかったことであり、Ractorの追加によりRubyがさらに一歩進化したことを実感できました。
一覧表
個人的なまとめ表です。
種類 | 並行処理 | 複数CPU利用 | メモリ使用量 | メモリ共有 | 実装難易度 |
---|---|---|---|---|---|
Thread | ○ | × | ○- | ○ | △ |
Fiber | ○ | × | ○ | ○ | △- |
Process | ○ | ○ | × | × | ○ |
Ractor | ○ | ○ | ? | ○ | × |
どの技術を使うかに関して、まず「複数CPUを使いたいか?」で大きく2つに分かれそうです。 IO処理がメインで1 CPUで問題なければ「Thread」or 「Fiber」、CPUメインの処理で複数CPUを使いたければ「Process」or「Ractor」。
「Thread」or 「Fiber( with async gem )」に関しては、普段使いには大きな差はないのかなと感じました。 メモリ使用量や立ち上げのオーバーヘッドを極力抑えたいのであればFiber、それ以外のケースではThreadという感じでよいのかなと。 どちらのケースでもメモリを共有できますが、メモリを共有することによる謎のエラーも発生しやすいので実装は注意して行う必要があります。
「Process」or「Ractor」に関しては、大きく違いがありそうです。 メモリが潤沢にありメモリ共有が不要、お手軽に実装したい場合、「Process」、メモリを極力節約し、並行処理間でメモリを共有したい場合「Ractor」を選ぶことになりそうです(Ractorの実装難易度を「×」にしてすいません)。
まとめ
今回は、Ruby 3.0で追加されたRactorを含めて、Rubyでできる並行処理を自分なりにまとめてみました。 それぞれの方法で特徴があり、用途に合わせて適切な技術を選ぶのが大切だなと改めて実感しました。
ユニファでは、こんな感じでRubyが好きなエンジニアを募集しております。 もし気になったら、お気軽にカジュアル面談をお申し込みください。
以上になります。最後までご覧いただきありがとうございました。