Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

RubyのRactorとは一体何なのか(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

RubyのRactorとは一体何なのか(翻訳)

Shopify/pitchfork - GitHub

私がやりたいのは、Pitchforkに関する記事を書いて、これがどんな理由でできたのか、なぜ現在のような形になったのか、そして今後どうなるのかについて説明することです。しかしその前に解説しておく必要があることがいくつかあります。今回はRactorについてです。

4、5年前にRactorが発表されたとき、多くの人が、スレッドの代わりにRactorを使う、Puma的なRactorベースのWebサーバーがすぐにでも登場するだろうと期待を寄せていました。しかし、いくつかのおもちゃプロジェクトや実験を除けば、まだ実現していません。

このシリーズ記事の目的は、Ruby HTTPサーバーを設計するうえでの制約に関するコンテキストを提供することなので、Ractorがどの程度活用可能かに関して私の見解をここで共有しておくのは理にかなっていると思います。

🔗 Ractorは何を目指すのか

Ractorの中核にあるアイデアは比較的シンプルで、その目的は真のインプロセス並列処理(in-process parallelism)を実現可能にするプリミティブを、GVLを完全には削除しない形で提供することです。

RubyのGVLを消し去りたいあなたへ(翻訳)

前回の記事で詳しく言及したように、GVLなしの操作では、スレッド間で共有されるすべてのミュータブル(mutable: 改変可能な)オブジェクトの同期(ミューテックス)が必須となります。
Ractorは、ミュータブルなオブジェクトをRactor間で共有することを許さないことでこの問題を解決します。それと引き換えに、Ractor同士がオブジェクトのコピーを互いに送信したり、場合によっては別のRactorにオブジェクトを「移動」できるようになります(この場合Ractor自身はオブジェクトにアクセスできなくなります)。

これは何もRuby独自のものではありません。Ractorは、その名が示すようにアクターモデル(actor model)から多大なヒントを得ていますし、Rubyと同じカテゴリに含まれる多くの言語でも同様の構造が取り入れられていたり導入を試みていたりします。たとえば、JavaScriptにはWeb Worker APIがありますし、Pythonはここしばらくサブインタープリタに取り組んでいます。

言語の進化という観点では、Ractorのような機構が登場するのはまったくもって自然な流れです。長年にわたってインプロセス並列処理が禁じられてきた言語では、Ractor的なAPIを使うことで、ミューテックスをあちこちに追加しなくても、既存のコードを壊さずに(制限付きの)並列処理を導入できるようになります。

しかし、ミュータブルなステートを共有する並列処理は、たとえフリースレッド(free threading)をサポートしていたとしても、多くの人によって巨大な弱点であるとみなされており、たとえばGoのチャネルのようなメッセージパッシング式の並列処理の方が安全であると多くの場合考えられています。

これをRubyに適用すると、単一のグローバルVMロック(GVL)ですべてのスレッドを同期する方式ではなく、指定のRactorに属するすべてのスレッドを個別に同期するRactorロックを多数持つことになります。すなわち、Ractorが導入されたRuby 3.0以降は、(この後見ていくように)GVLが理論上なくなったということになります。これは実際にはもっと微妙な話です。

このことは、以下のシンプルなテストスクリプトで気軽に実験的に確かめられます。

require "benchmark"
Warning[:experimental] = false

def fibonacci(n)
  if n == 0 || n == 1
    n
  else
    fibonacci(n - 1) + fibonacci(n - 2)
  end
end

def synchronous_fib(concurrency, n)
  concurrency.times.map do
    fibonacci(n)
  end
end

def threaded_fib(concurrency, n)
  concurrency.times.map do
    Thread.new { fibonacci(n) }
  end.map(&:value)
end

def ractor_fib(concurrency, n)
  concurrency.times.map do
    Ractor.new(n) { |num| fibonacci(num) }
  end.map(&:take)
end

p [:sync, Benchmark.realtime { synchronous_fib(5, 38) }.round(2)]
p [:thread, Benchmark.realtime { threaded_fib(5, 38) }.round(2)]
p [:ractor, Benchmark.realtime { ractor_fib(5, 38) }.round(2)]

ここではフィボナッチ関数を古典的なCPU-boundの負荷として用い、3通りのベンチマークを取っています。1つ目はコンカレンシーなしの逐次実行、2つ目はコンカレンシーありの5スレッド、3つ目は5つのRactorをコンカレントに実行しています。

私のコンピュータでこのスクリプトを実行してみると、以下の結果が得られます。

[:sync, 2.26]
[:thread, 2.29]
[:ractor, 0.68]

既にご存知のとおり、CPU-boundの負荷に対してスレッドを用いてもGVLのせいでまったく速くなりませんが、Ractorを使うと並列処理のメリットが効いてくるようになります。
つまり、このスクリプトはRuby VMがコードを少なくともある程度は並列に実行可能であること、ひいてはGVLがもはやグローバルではなくなったことを証明しています。

しかし悪魔は細部に宿るのが定番です。
イミュータブルな整数のみを扱うfibonacciのような純粋な関数を並列実行することと、数百ものgemと多数のグローバルなステートを持つ完全なWebアプリケーションを並列実行するのは別の話です。

🔗 共有可能オブジェクト

RubyのRactorが他のほとんどの言語における類似機能と大きく異なるのは、Ractorがグローバル名前空間を他のRactorと共有することです。

JavaScript でWebWorkerを作成するには、以下のようにエントリスクリプトを渡す必要があります。

myWorker = new Worker("worker.js")

WebWorkerは白紙の状態から作成され、独自の名前空間を持ちますが、呼び出し元によって定義されたすべての定数を自動的に継承するわけではありません。

同様に、PEP734で定義されているPythonのサブインタープリタも白紙の状態から開始されます。

したがって、JavaScriptのWebWorkerとPythonのサブインタープリタはどちらも共有機能が非常に限られており、軽量のサブプロセスに似ていますが、互いのオブジェクトをシリアライズなしで渡せるAPIを備えています。

RubyのRactorはそれよりも野心的です。セカンダリRactorからは、メインRactorで定義されたすべての定数とメソッドを参照できます。

INT = 1

Ractor.new do
  p INT # 1を出力する
end.take

しかしRubyはミュータブルなオブジェクトへのコンカレントアクセスを許してはならないため、何らかの方法でこれを制限する必要があります。

HASH = {}

Ractor.new do
  p HASH # Ractor::IsolationError
  # メインでないRactorによるObject::HASH定数内のオブジェクトは共有不可なのでアクセスできない
end.take

したがって、すべてのオブジェクトは「共有可能オブジェクト」と「共有不可オブジェクト」のどちらかに2分され、セカンダリRactorからアクセス可能なのは共有可能オブジェクトだけとなります。一般に、"frozen"オブジェクトや本質的にイミュータブルなオブジェクトは、共有不可オブジェクトを参照しない限り共有可能です。

さらに、クラスインスタンス変数の割り当てのような他の操作は、メインRactor以外のRactorでは許されません。

Ractor.new do
  class Foo
    class << self
      attr_accessor :bar
    end
  end
  Foo.bar = 1 # Ractor::IsolationError
  # クラスやモジュールのインスタンス変数は非メインRactorから設定できない
end.take

そういうわけで、Ractorの設計は両刃の剣であると言えます。
読み込まれたすべての定数とメソッドにアクセス可能なので、同じコードを何度も読み込む必要がなくなり、あるRactorから複雑なオブジェクトを別のRactorに渡すのは楽ですが、その一方で、どんなコードでもセカンダリRactorから実行できるとは限らないということでもあります。
実際、既存のRubyコードの大半(ほとんどとまでいかなくても)は、セカンダリRactorから実行できません。文字列やハッシュのような技術的にミュータブルな定数にアクセスすることはざらにありますが、たとえ変更を試みなくてもIsolationErrorが発生します。

以下のように定数に何らかのデフォルト値を指定するのは、ごく普通に見かけるRubyらしい書き方ですが、これはRactorとの互換性を失わせるのに十分です。

class Something
  DEFAULTS = { config: 1 } # このハッシュは明示的にfreezeさせる必要がある

  def initialize(options = {})
    @options = DEFAULTS.merge(options) # => Ractor::IsolationError
  end
end

これが、ある程度以上の規模のアプリケーションでRactorベースのWebサーバーが実用的でない主な理由の1つです。

Railsを例にとると、「ルーティング」「データベーススキーマのキャッシュ」「ロガー」のような、グローバルではあるが正当なステートがかなり多く存在しています。ものによってはfreezeさせることでセカンダリRactorからアクセス可能になるかもしれませんが、「Active Recordのコネクションプール」や多くのキャッシュ機構では難しくなります。

正直に申し上げると、現在のAPIでRactor安全なコネクションプールを実装する方法は私には見当が付きませんが、もしかすると何か見落としているかもしれません。それでは、Ractor互換のコネクションプールの実装を試してみることにしましょう。実際、この問題を明らかにするのによい例だと思います。

🔗 Ractor対応のコネクションプール

最初の課題は、以下のように、あるRactorから別のRactorにコネクションを移動可能にする必要があることです。

require "trilogy"

db_client = Trilogy.new
ractor = Ractor.new { receive.query("SELECT 1") }
ractor.send(db_client, move: true)
p ractor.take

上を試してみると、can not move Trilogy object. (Ractor::Error)エラーが発生します。これは、私が知る限り、Cで実装されたクラスを別のRactorに移動可能であることを定義する方法がないためです。TimeクラスのようなRubyコアで定義されているクラスでも移動できません。

Ractor.new{}.send(Time.now, move: true) # can not move Time object. (Ractor::Error)

C拡張でできるのは、型をfrozenにすればRactor間で共有可能になることをRUBY_TYPED_FROZEN_SHAREABLEフラグで定義することだけです。しかし、これはデータベースコネクションでは無意味です。

この問題を回避するには、そのオブジェクトを独自のRactor内にカプセル化します。

require "trilogy"

class RactorConnection
  def initialize
    @ractor = Ractor.new do
      client = Trilogy.new
      while args = Ractor.receive
        ractor, method, *args = args
        ractor.send client.public_send(method, *args)
      end
    end
  end

  def query(sql)
    @ractor.send([Ractor.current, :query, sql], move: true)
    Ractor.receive
  end
end

オブジェクトに対して何らかの操作を実行する必要が生じたら、何をすべきかを伝えるメッセージを送信し、独自のRactorを渡して結果を送り返せるようにします。

これは本当に大きなハックです。他に適切な方法があるかもしれませんが、私は知りません。

データベースコネクションをRactor間で渡す「方法」ができたので、コネクションプールを実装する必要があります。ここでも、定義上プールのデータ構造がミュータブルなために複数のRactorから参照できず、実装は困難です。

そのため、同じハックをもう一度使う必要があります。

class RactorConnectionPool
  def initialize
    @ractor = Ractor.new do
      pool = []
      while args = Ractor.receive
        ractor, method, *args = args
        case method
        when :checkout
          ractor.send(pool.pop || RactorConnection.new)
        when :checkin
          pool << args.first
        end
      end
    end
    freeze # これで共有可能になる
  end

  def checkout
    @ractor.send([Ractor.current, :checkout], move: true)
    Ractor.receive
  end

  def checkin(connection)
    @ractor.send([Ractor.current, :checkin, connection], move: true)
  end
end

CONNECTION_POOL = RactorConnectionPool.new

ractor = Ractor.new do
  db_client = CONNECTION_POOL.checkout
  result = db_client.query("SELECT 1")
  CONNECTION_POOL.checkin(db_client)
  result
end
p ractor.take.to_a # => [[1]]

実装が相当バカバカしくなってきたのでこの辺でやめておきますが、私の言いたいことは十分伝わると思います。

Ractorで完全なアプリケーションを実行可能にするには、少なくともRactor間で共有可能な基本データ構造をRubyがいくつか提供して、コネクションプールなどの便利な構造を実装可能にする必要があります。

おそらくRactor::Queue的なものと、場合によってはRactor::ConcurrentMap的なものも提供する必要があるでしょう。
おそらくさらに重要なのは、その型をC拡張機能で移動可能にする必要もあることでしょう。

🔗 Ractorはどんな場面で有用になりそうか

Ractor内でアプリケーションを丸ごと実行しようとして頑張るのは無意味だと思いますが、それでも、Ractorに現在の制約があったとしても非常に便利に使える場面があるだろうと考えています。

たとえば、以前のGVL記事で、一部のgemにはバックグラウンドスレッドが存在していることを指摘しました。1つの例はstatsd-instrumentですが、opentelemetryなど他にもあります。

Shopify/statsd-instrument - GitHub
open-telemetry/opentelemetry-ruby - GitHub

これらのgemはどれも、メモリ内の情報を収集して定期的にシリアライズしてネットワークに送信するというパターンが共通しています。現在、この処理はスレッドで行われますが、シリアライズ部分がGVLを掴むことで着信トラフィックに応答するスレッドの速度が低下する可能性があるという問題が発生することがあります。

このパターンはRactorととてもよい相性となります。メインのRactorのGVLを保持せずに同じことを実行でき、ほとんどは処理が終われば結果を保持する必要がないからです。

これは私がよく知っている例として挙げただけですが、他にももっとあると思います。重要な点は、現在の形式のRactorはメインの実行プリミティブとして使うのはほぼ無理ですが、ライブラリ内の低レベル関数を並列化することは確実に可能です。

しかし残念ながら、実際には、今の時代にそれを行うのはあまり良い考えとは言えません。

🔗 実装上の問題が多数ある

RubyでRactorを使おうとすると、以下の警告が表示されます。

warning: Ractor is experimental, and the behavior may change in future versions of Ruby!
Also there are many implementation issues.

警告: Ractorは実験的機能であり、今後のRubyバージョンで振る舞いが変更される可能性があります。
また、実装上の問題点が多数あります。

これは誇張ではありません。本記事執筆時点でRactorのissueは74件オープンされていて、いくつかは機能リクエストや小さめの問題ですが、かなりの部分がセグメンテーションエラーやデッドロックなどの重大なバグです。そのため、小規模な実験に使う以上の用途には適していません。

Ractorが完璧に向いているケースであってもRactorを使わない理由は、多くの場合、期待通りに並列化されないことです。

🔗 新たなグローバルロック

上述の通り、Ruby 3.0でRactorが導入されたことで真のグローバルVMロックは理論上消え、個別のRactorが独自の"GVL"を持つようになったはずです。しかし実際は、その通りではありません。

RubyのVMには、全Ractorをロックするルーチンが相当な数あります。例をお見せしましょう。

5百万個の小さなJSONドキュメントを解析する状況を考えてみます。

# frozen_string_literal: true
require 'json'

document = <<~JSON
  {"a": 1, "b": 2, "c": 3, "d": 4}
JSON

5_000_000.times do
  JSON.parse(document)
end

私のコンピュータで逐次実行すると1.3秒ほどかかりました。

$ time ruby --yjit /tmp/j.rb

real    0m1.292s
user    0m1.251s
sys 0m0.018s

このスクリプトは現実離れしているように見えるかもしれませんが、Ractorの完璧なユースケースであるはずです。理論上は、Ractorを5つ生成して、それぞれに100万個のドキュメントを解析させれば、5分の1の時間で完了することになります。

# frozen_string_literal: true
require 'json'

DOCUMENT = <<~JSON
  {"a": 1, "b": 2, "c": 3, "d": 4}
JSON

ractors = 5.times.map do
  Ractor.new do
    1_000_000.times do
      JSON.parse(DOCUMENT)
    end
  end
end
ractors.each(&:take)

しかしどういうわけか、逐次実行の倍以上遅くなります。

/tmp/jr.rb:9: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.

real    0m3.191s
user    0m3.055s
sys 0m6.755s

この例で何が起きているかというと、JSONはJSONドキュメント内の各キーに対して、残存しているVMロックを取得する必要があります。4つのキーを100万回ずつ処理する場合、個別のRactorはロックの取得と解放を400万回実行する必要があります。これに3秒しかかからないのは、むしろ驚きです。

キーについては、文字列キーをハッシュに挿入するためにGVLを取得する必要があります。過去記事『RubyのJSONの最適化パート 6』で説明したように、これを行うと、Rubyはインターンされた文字列テーブル内を調べて、既にインターンされた(interned: 最適化された)同等の内部文字列を検索します。

以下の疑似Rubyコードで説明します。

class Hash
  def []=(key, value)
    if entry = find_entry(key)
      entry.value = value
    else
      if key.is_a?(String) && !key.interned?
        if interned_str = ::RubyVM::INTERNED_STRING_TABLE[key]
          key = interned_str
        elsif !key.frozen?
          key = key.dup.freeze
        end
      end

      self << Entry.new(key, value)
    end
  end
end

上の例の::RubyVM::INTERNED_STRING_TABLEは通常のハッシュであり、同時にアクセスするとクラッシュする可能性があるため、Rubyは探索のために引き続きGVLを取得します。

string.cのregister_fstringfstring はインターンされた文字列の内部名)を見ると、RB_VM_LOCK_ENTER()RB_VM_LOCK_LEAVE()を非常に明示的に呼び出していることがわかります。

本記事執筆時点で、Ruby VMにはRB_VM_LOCK_ENTER()呼び出しが42箇所残っています。大半はめったに実行されないので大きな問題にはなりませんが、この例は、Ractorの完璧なユースケースであっても、Ractorの制約に加えて、まだRactorを利用するメリットがない可能性があることを示しています。

🔗 まとめ

Ractor推進の中心人物であるKoichi Sasada氏がRactorの現状についてRubyKaigi 2023で講演した際、Ractorは ある種の「タマゴが先かニワトリが先か」問題に悩まされていると述べていました。

彼自身も認めているように、Ractorには多くのバグがあり、期待通りのパフォーマンスを実際に提供できないことも多く、そのためAPIに関するフィードバックを提供できるほどRactorを使い倒している人がなかなかおらず、2年近く経った今でも、バグとパフォーマンスに関する私の評価が変わっていない点が惜しまれます。

Ractorのバグやパフォーマンスの問題が修正されれば、いくつかのフィードバックのおかげで制限の一部がそのうち解除されるかもしれません。個人的には、Ractor内でアプリケーションを実用的な形で丸ごと実行できるほど制限が緩和されることはまずないと思います。したがって、RactorベースのWebサーバーが良いものとなる可能性はなくもありませんが、誰にもわかりません。私が間違っていることが証明されれば喜ばしいのですが。

結局のところ、「RubyはRactorの改善にリソースを注ぎ込むよりもGVLを削除すべき」だと信じている人にとっても、インターンされた文字列を扱うコンカレントハッシュマップなど、Ractorのパフォーマンスを強化するうえで必要な作業の大部分は、いずれにしてもフリースレッドを有効にするために必要となる作業であるため、決して無駄にはならないと申し上げておきます。

関連記事

Rubyのスケール時にGVLの特性を効果的に活用する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。