はじめまして、Optimal Biz開発チームの片岡です。 私は業務では主にRubyを使ってWebアプリケーションの開発をしています。
5/11~13に開催されたRubyKaigi 2023 に参加してきました。 私は今回、Ractorに注目して参加していたため、Koichi Sasadaさんの講演 "Ractor" reconsidered を紹介します。
RactorとThread
講演の紹介の前に、RactorとThreadについて確認します。
Threadとは
Rubyでは、並行・並列処理を行う仕組みとしてRactor/Thread/Fiber/Processをサポートしています。
例としてThreadを確認します。 Threadは、ネイティブスレッド(カーネルスレッド)を用いて、同時実行を行います。
Ruby 3.2 リファレンスマニュアル Thread
ネイティブスレッドを用いて実装されていますが、現在の実装では Ruby VM は Giant VM lock (GVL) を有しており、同時に実行されるネイティブスレッドは常にひとつです。
Rubyにおける特徴として、Giant VM lock(以下GVL)を有しているため、同時に実行されるネイティブスレッドは常にひとつです。
ただし、IO 関連のブロックする可能性があるシステムコールを行う場合には GVL を解放します。その場合にはスレッドは同時に実行され得ます。
この性質上、ThreadはIO待ちが発生する処理ではマルチコアを活用して性能を発揮しますが、 CPU負荷が高い計算処理の場合、シングルコアのみを利用するため、CPUの性能を最大限発揮出来ないことになります。
Ractorとは
Ruby3.0で導入されたRactorでは、GVLをRactorオブジェクトごとに有しています。 これにより、IO処理を含まないCPU負荷が高い計算処理のみの場合でも、マルチコアを活用するため、CPUの性能を最大限発揮出来ます。
他にも、RactorはThreadがオブジェクトをすべて共有することに対して、共有可能なオブジェクトが制限されているという特徴があります。 これにより、ThreadはMutaxなどを用いて開発者が意識して排他ロックをかける必要がありますが、 Ractorでは共有するオブジェクトを明示する必要があるため、比較的安全に並列処理を書く事ができます。
RactorとThreadの比較
Ractorは重い計算処理を得意としていることを実際に確認します。
- 以下コードはRuby 3.2.0で実行
require 'benchmark' def cpu_bound (1..100_000_000).each do |i| 100_000 % i end end def io sleep 30 end Benchmark.bm 10 do |r| r.report "Ractor CPU Bound" do ractors = 10.times.map do Ractor.new { cpu_bound } end ractors.each(&:take) end r.report "Ractor IO" do ractors = 10.times.map do Ractor.new { io } end ractors.each(&:take) end r.report "Thread CPU Bound" do threads = 10.times.map do Thread.new { cpu_bound } end threads.each(&:join) end r.report "Thread IO" do threads = 10.times.map do Thread.new { io } end threads.each(&:join) end end
初めに、CPU負荷部分のみの実行結果です。
user system total real Ractor CPU Bound 62.664348 0.019670 62.684018 ( 16.036564) Ractor CPU Bound 73.585763 0.267800 73.853563 ( 18.937455) Ractor CPU Bound 68.076373 0.035880 68.112253 ( 17.674539) Thread CPU Bound 35.877646 0.024894 35.902540 ( 35.848599) Thread CPU Bound 35.387314 0.048037 35.435351 ( 35.366753) Thread CPU Bound 32.595941 0.038544 32.634485 ( 32.580958)
RactorのCPU消費の様子
ThreadのCPU消費の様子
Ractorで実行している最中はマルチコアを活かしていますが、 Threadの場合はマルチコアを活かせていないことが分かります。
次に、IO処理部分のみの実行結果です。
user system total real Ractor IO 0.000000 0.002835 0.002835 ( 30.001762) Ractor IO 0.003486 0.000000 0.003486 ( 30.001915) Ractor IO 0.003617 0.000003 0.003620 ( 30.029683) Thread IO 0.003583 0.000002 0.003585 ( 30.001737) Thread IO 0.004577 0.000005 0.004582 ( 30.002423) Thread IO 0.004311 0.000000 0.004311 ( 30.002121)
このように、IO処理の場合は、どちらもマルチコアを使用してスレッドが停止せずに実行されていることが確認出来ました。
"Ractor" reconsidered
そして今回のRubyKaigi 2023 にて行われた講演の内容です。
Ractorはあまり使用されなかった
RactorはRuby3.0から登場しましたが、あまり使用されることはありませんでした。 この原因を次のように述べられていました。
Koichi Sasada. 2023_rubykaigi2023.pdf p16 2023-05-13閲覧.
- エコシステムが育たない
- APIに対するフィードバックがない
なぜなら
- 誰も試していないから
なぜなら
- コードの品質とパフォーマンスに問題があるから
そのため、手始めにコードの品質とパフォーマンスの改善が行われているようです。
Koichi Sasada. 2023_rubykaigi2023.pdf p17 2023-05-13閲覧.
問題に関しては、それぞれ改善が行われているようですが、ここではさらなるパフォーマンスの改善として紹介されていたガベージコレクション(以下GC)の問題に注目していきます。
GC時にRactorが遅くなる問題
RactorではGCが行われている間、すべてのRactorが停止してしまう問題があり、パフォーマンスが悪くなることがあるそうです。
実際に確認してみます。
# GC.disable | GC.enable Benchmark.bm 10 do |r| r.report "Ractor" do array = 1000.times.map { |i| { name: "ユーザー#{i}" } } ractors = 10000.times.map do Ractor.new(array) do |array| while array.size > 0 array.pop # arrayのpopを行い、HashをGCの対象にする end end end ractors.each(&:take) end end
以下実行結果。
user system total real # GC.enable Ractor 13.172216 0.198747 13.370963 ( 12.767346) # GC.disable Ractor 6.743534 2.101403 8.844937 ( 7.113329)
なんとGCが有効な場合、無効な場合と比べてrealtimeが約1.7倍が遅い結果がでました。
次はThreadと比較してみます。 本来、計算処理のみの場合はRactorに分があるはずですが、GCによってRactorが停止するのであれば、 Threadとの差がそこまで生まれないであろうことが推測出来ます。
# GC.disable | GC.enable Benchmark.bm 10 do |r| r.report "Ractor" do ractors = 10000.times.map do Ractor.new do array = 1000.times.map { |i| { name: "ユーザー#{i}" } } while array.size > 0 array.pop end end end ractors.each(&:take) end end
# GC.disable | GC.enable Benchmark.bm 10 do |r| r.report "Thread" do threads = 10000.times.map do Thread.new do # 先ほどの例と違い、オブジェクトが共有されるため、スレッド内部で配列を生成するように統一 array = 1000.times.map { |i| { name: "ユーザー#{i}" } } while array.size > 0 array.pop end end end threads.each(&:join) end end
user system total real # Ractor GC.enable Ractor 12.738336 2.014264 14.752600 ( 8.334714) Ractor 12.454810 1.888897 14.343707 ( 7.873710) Ractor 13.093284 2.103435 15.196719 ( 8.617364) # Thread GC.enable Thread 5.636691 3.252349 8.889040 ( 8.136317) Thread 8.707594 4.816458 13.524052 ( 12.326214) Thread 5.729613 5.416915 11.146528 ( 10.281520) # Ractor GC.disable Ractor 6.096241 1.409719 7.505960 ( 2.162732) Ractor 6.180175 1.374718 7.554893 ( 2.193687) Ractor 6.255012 1.312275 7.567287 ( 2.188180) # Thread GC.disable Thread 5.067560 2.358393 7.425953 ( 6.526075) Thread 6.016626 2.496383 8.513009 ( 7.559076) Thread 4.351179 2.050537 6.401716 ( 5.606849)
この結果をまとめると、
GCが有効の場合、realtimeはRactorとThreadにほとんど差はなく、Ractorの方が少し高速でした。 RactorがGCによって処理が停止するため、本来のRactorの高速性が発揮されず差が生まれにくいことが分かります。
GCが無効の場合、realtimeはThreadよりもRactorの方が約3倍高速でした。 Ractorはマルチコアを使用して並列処理が行われるため、とても高速な結果が得られました!
Ractor local GC
この問題を解消するため、Ractorごとにほとんどのオブジェクトが分離されていることを利用し、 GCをRactorごとに並列して実行するというアプローチが考案されていました。
Koichi Sasada. 2023_rubykaigi2023.pdf p31 2023-05-13閲覧.
これにより、GC時、すべてのRactorを止めずに実行することが可能になります。
しかし、Ractorは一部のオブジェクトを共有可能であるため、図のようにRactorをまたいだオブジェクトが存在します。 そのため、ただ単にRactorごとにGCを動かすのではなく、影響がない程度に分散したGCが必要とのことです。
Koichi Sasada. 2023_rubykaigi2023.pdf p32 2023-05-13閲覧.
これが実現すると、Threadでは行えない利点が生まれるため、Ractorが積極的に採用されるようになるかもしれませんね!
おわりに
Ractorの改善のプロジェクトが進んでいるようなので、 手軽に並列処理の出来るRubyを速く製品開発でも使ってみたいですね!
OPTiMでは、そんな魅力的なRubyで開発している大型プロジェクトがございます。Ruby好きなメンバーで開発していますので、ご興味のある方は、ぜひ一度ご連絡ください。