ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Rubyの並行処理についてまとめてみる

あいわらず鍋ばかり食べているWebエンジニアの本間です。 ただ、鍋の種類はごま豆乳鍋から寄せ鍋に変わりました。

さて1ヶ月以上遅れになりますが、 Ruby 3.0 が無事リリースされました。 まずはリリースに携わった全ての皆さま、本当にありがとうございます。 また今回は念願のメジャーバージョンアップであり、Ruby 3x3の達成おめでとうございます! 速く、使いやすくなったRubyをこれからもバリバリと使わせいただこうと思っています。

そのRuby 3.0で並行処理関連の2つの機能が追加されました。

  • Ractor
  • Fiber Scheduler

これらを見て「そういえばRubyの並行処理って何があるんだろう?」という疑問が浮かびました。

また、過去にこのようなエントリを投稿していたこともあります。

tech.unifa-e.com

新しい機能が追加されたタイミングで過去の機能も含めて整理しておきたいと思い、このエントリを書こうと思いました。

並行処理の種類

自分が知る限り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に関しては、クックパッド社の開発者ブログで開発者の笹田さんから詳しく解説しているので、そちらをご一読することをオススメします。

techlife.cookpad.com

こちらのブログを参考に、とりあえず 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」は概ね以下のようになります。

f:id:ryu39:20210128145625p:plain

Rubyのプロセスが1つだけで、CPU使用率は100%になります。 この環境では4個CPUがあるため、4つフルに使用した場合、400%近くになるはずです。 そのためこれらの場合、複数CPUは使用しておらず1個のCPUのみを使用している状態と考えられます。

次にProcessです。

f:id:ryu39:20210128145640p:plain

こちらは4つのRubyプロセス(正確には大元のRubyプロセスが別でもう1つある)があり、それぞれがCPUを100%近く使用しています。 合計すると400%近くになり、4つのCPUをフルで使えています。

最後にRactorです。

f:id:ryu39:20210128145652p:plain

こちらは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が好きなエンジニアを募集しております。 もし気になったら、お気軽にカジュアル面談をお申し込みください。

unifa-e.com

以上になります。最後までご覧いただきありがとうございました。