RubyKaigi 2023 RactorとThread、Ractor local GCについて

はじめまして、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好きなメンバーで開発していますので、ご興味のある方は、ぜひ一度ご連絡ください。

www.optim.co.jp