はじめに
Ruby 4.0 が最近リリースされ、パフォーマンスと並列化に関する多くの改善がもたらされました。その中でも Ractor は CRuby における「真の並列処理」の中心的なテーマとして位置づけられています。Ruby 3 の頃は Ractor がまだ実験的機能として扱われ、多くの開発者が「試しに使ってみる」程度に留まっていましたが、Ruby 4 では Ractor の API が刷新され、より安定し、IPC(プロセス間通信)の仕組みに似た設計になりました。このポストでは、Ractor とは何か、Ruby 4 での新機能、シンプルなコード例、そして実際のシステムでいつ Ractor を使うべきか(あるいは使うべきでないか)について紹介します。
Ractor とは何か(Thread との違い)?
従来の Ruby の Thread は Global VM Lock(GVL)によって制限されており、特に CPU バウンドなタスクでは「並列」は幻想に過ぎません。一方、Ractor は独立したアクターとして設計されており、各 Ractor は独自のヒープを持ち、他の Ractor のミュータブルなオブジェクトに自由にアクセスできません。代わりに、開発者はメッセージパッシングを通じて通信を行う必要があります。このアプローチにより、Ruby は複数のコア上で真の並列実行を実現できる一方、スレッド安全性を維持しますが、データ共有に関する多くの制約が生じます。
要点:
- Ractor はアクターモデルに基づいた抽象化で、各 Ractor は独自の「世界」を持ち、他の Ractor とミュータブルなオブジェクトを共有しません。
- 目的:Thread + GVL で発生するデータレースを心配することなく、複数のコアで Ruby を真に並列実行させることができます。
- Ractor 間の通信はメッセージパッシング(共有可能なオブジェクトの送受信またはコピー/移動)を通じて行われ、ミューテックスや共有状態ではありません。
Ruby 4 での Ractor の変更点
Ruby 3 → Ruby 4 で注目すべき点:
パフォーマンスの向上
- シンボルテーブルとフローズン文字列が lock-free ハッシュセットを使用し、ロック競合を減らします。
- メソッドキャッシュの検索、インスタンス変数(クラス/gen ivar)へのアクセスのロック削減、キャッシュ競合を回避するための Ractor ごとのカウンターを使用した割り当て。
API の再設計
-
Ractor::Portがメインの通信チャネルとして登場します。 - Ruby 4 では旧来の
Ractor.yieldとRactor#takeが削除され、新しい API は IPC セマンティクスに似た設計になっています。
実験的ステータスの進展
- コアチームは、Ractor が実験的ラベルの削除に近づいていることを明確に述べており、残りのバグは主に GC に関連しています(Ruby 4.0 には Ractor ローカルGC がまだありません)。
Ruby 4 での最も目立つ変更は、Ractor::Port を中心とした新しい API です。これにより、Ractor のメッセージパッシングモデルが OS の IPC プリミティブに似た見た目になりました。同時に、シンボルテーブルの lock-free ハッシュ、メソッドキャッシュのロック削減、オブジェクト割り当て時の CPU キャッシュ競合の回避などのエンジン層での最適化により、Ractor は複数のコアでより良くスケールします。
デモコード − シングルスレッドから Ractor へ
とてもシンプルな例を構築できます。CPU バウンドな計算(例えば Fibonacci の計算)を比較します。
シンプルな CPU バウンドな関数
def fib(n)
return n if n < 2
fib(n - 1) + fib(n - 2)
end
順序実行
require_relative "./fib"
require "benchmark"
N = 35
COUNT = 4
puts Benchmark.measure {
COUNT.times { fib(N) }
}
このコードはベースラインを示しており、すべて 1 つのスレッドで順序実行されます。
Thread(GVL の制限を思い出すために)
require_relative "./fib"
require "benchmark"
N = 35
COUNT = 4
puts Benchmark.measure {
threads = COUNT.times.map do
Thread.new { fib(N) }
end
threads.each(&:join)
}
多くの環境では、GVL がまだ「喉を握っている」ため、実行時間がコア数に比例して短縮されないことに気づくでしょう。
Ruby 4 の Ractor(Ractor::Port を使用)
require_relative "./fib"
require "benchmark"
N = 35
COUNT = 4
puts Benchmark.measure {
port = Ractor::Port.new
workers = COUNT.times.map do
Ractor.new(port) do |port|
while (n = port.receive)
break if n == :stop
fib(n)
end
end
end
COUNT.times { port.send(N) }
workers.size.times { port.send(:stop) }
workers.each(&:join)
}
説明:
- Ruby 3 のように
Ractor.send/receive/yield/takeを直接呼び出すのではなく、Ruby 4 は Port を通信チャネルとして使用することを開発者に促しています。 - 各ワーカーは独立した Ractor で、ポートからジョブ(数値 N)を受け取り、処理し、別のポート経由で結果を返すか yield できます。
- ベンチマークは、CPU バウンドなワークロードがマシンと GC に応じてより良くスケールすることを示しています。
Ractor を使用するときの重要なルール
これは非常に重要なセクションであり、ドキュメントだけではなく「実戦経験」を反映した記事にするのに役立ちます:
共有可能なオブジェクトのみ自由に送信可能
- フローズン文字列、シンボル、数字、モジュール/クラスなどのイミュータブルなものは通常共有可能です。
- Ruby 4 は
Ractor.shareable_procを追加して、Proc の共有を容易にします。 - ミュータブルなオブジェクトを送信する場合、Ruby はコピーまたは移動を実行し、スレッド内の共有オブジェクトとは異なるオーバーヘッドと動作につながります。
Gem と標準ライブラリの互換性
- 多くの Gem と標準ライブラリの一部は、Ractor との完全な互換性がまだありません。
- 実際のレポートでは、IO、Proc、ミュータブルなstructなど、多くの制限があるため、Ractor で concurrent-ruby のバックエンドを置き換えるのは困難です。
- 多くの分析では、メイン Web プロセスに即座に導入するのではなく、バックグラウンドジョブ、バッチ処理、または内部マイクロサービス用に Ractor を使用することを推奨しています。
GC とパフォーマンス
- コアチームは、Ractor ローカルGC が Ruby 4 では準備完了していないことを認めており、パフォーマンスの一部はグローバル GC によって制限されています。
- 本番環境に適用する前に、十分にベンチマークを取ってください。
Rails/実際のアプリケーションに対する推奨ユースケース
- CPU バウンドなバッチ処理:イメージ変換、ビデオエンコーディング、軽量な AI 推論を仕事キューで処理し、メインプロセスから完全に分離します。
- 大規模データセットの分散処理:ETL およびレポートを複数のチャンクに分割し、複数の Ractor に投げ、結果を集約します。
- アクターベースのシステムのプロトタイピング:Elixir/Go に移動せずに Ruby から出ずに Ractor の強力なコアを活用したい場合。
重要な注意事項:
- システム全体を Ractor に書き換えるべきではなく、分離されたモジュールを選択し、本番環境で「experiments in production」として Ractor を使用してください。
- Ractor ベースのアーキテクチャにコミットする前に、チームの実際のワークロードでベンチマークを取ってください。
結論
Ruby 4 は Ractor にとって大きな進歩を示しており、実験的なツールから Ruby での並列処理の実用的な選択肢へと変わりました。Ractor ローカルGC や Gem 互換性に関する制限は依然として存在しますが、パフォーマンスは大幅に向上し、新しい API(Port)はより理解しやすくなっています。Ruby で複数のコアを活用したいが、言語を離れたくない場合、Ractor は検討する価値があります。ただし、シンプルなユースケース、バッチ処理、またはジョブキューから始めて、本番環境に導入する前に常に十分にベンチマークを取ってください。