RubyのRactorとは一体何なのか(翻訳)
私がやりたいのは、Pitchforkに関する記事を書いて、これがどんな理由でできたのか、なぜ現在のような形になったのか、そして今後どうなるのかについて説明することです。しかしその前に解説しておく必要があることがいくつかあります。今回はRactorについてです。
4、5年前にRactorが発表されたとき、多くの人が、スレッドの代わりにRactorを使う、Puma的なRactorベースのWebサーバーがすぐにでも登場するだろうと期待を寄せていました。しかし、いくつかのおもちゃプロジェクトや実験を除けば、まだ実現していません。
このシリーズ記事の目的は、Ruby HTTPサーバーを設計するうえでの制約に関するコンテキストを提供することなので、Ractorがどの程度活用可能かに関して私の見解をここで共有しておくのは理にかなっていると思います。
🔗 Ractorは何を目指すのか
Ractorの中核にあるアイデアは比較的シンプルで、その目的は真のインプロセス並列処理(in-process parallelism)を実現可能にするプリミティブを、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など他にもあります。
これらの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_fstring
(fstring
はインターンされた文字列の内部名)を見ると、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のパフォーマンスを強化するうえで必要な作業の大部分は、いずれにしてもフリースレッドを有効にするために必要となる作業であるため、決して無駄にはならないと申し上げておきます。
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。