Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

RubyKaigi 2024 - Ractor Enhancements, 2024 (Day1)

ogp

こんにちは、株式会社Techouse バックエンドエンジニアの @nodematerial です。

今回は、RubyKaigi 2024 1日目のセッション「Ractor Enhancements, 2024」を聴講したので、その内容をブログにまとめさせていただきます。

Ractor とは

Ractor は Ruby 3.0 で導入された並列(parallel)プログラミングを行うための機能です。Actor モデル*1をベースに設計されており、並行処理の干渉を防ぐために、オブジェクト空間を複数の単位に「だいたい」分割することで、スレッドセーフに並列処理を行うことができます。

同時に、この並行処理に用いる分割単位のことも、Ractor と呼ばれています。*2

複数 Ractor を実行する際には、Ruby 基本機能の一部が制限されます。

例えば、Ractor 間で共有できるオブジェクトは、immutable オブジェクトなどのごく一部のオブジェクトに限られ、それ以外のオブジェクトは Ractor 間の通信を介してのみ共有可能となります。

この強い制約を導入することで、GVL (Giant VM Lock) がなくても Ractor 間の並列処理を、スレッドセーフに保つことができます。*3

また、Ractor は Ruby 3.3.0 で導入された M:Nスレッドスケジューラを使用することができ(参考)、ネイティブスレッドの数を制御しながら、マルチコアを活用した並列処理を行うことができます。*4

Ractor Enhancements, 2024 での発表内容

本発表では Ractor の機能強化について、具体的には現在の Ractor における require, timeout, GC の課題点を取り上げ、それらの課題に対する解決策の提案が行われました。

require

現在の課題

child Ractor*5 で require(Kernel.#require) を行うことは許可されません。require は、$LOAD_PATH などに定義されたファイルを順番に探し、引数に合致したファイルを読み込みますが、そのファイル内で、共有不可能オブジェクトが定数に代入される可能性があるためです。

定数参照時に使用される定数テーブルは Ractor 間で共有されるので、共有不可能オブジェクトを定数に代入する操作は、main Ractor 以外で禁止されます。(参考)

main Ractor のみで require を行えるという制約がある中、child Ractor で require に近い機能をどのように実現するか課題となっていました。

この解決策として、新しいAPI Ractor#interrupt_exec および、同 API を活用した Ractor.require(feature) メソッドが提案されました。

Ractor#interrupt_exec

Ractor#interrupt_exec{expr} を実行すると、レシーバに指定された Ractor 内の main スレッドの処理を中断させ、ブロック内の処理を実行させることができます。この処理は非同期で行われるため、interrupt_exec の呼び出し元のスレッドは、依頼先スレッドの処理完了を待つ必要がありません。(具体的な処理の流れが、発表スライドの9ページ目に記載されています。)

これを活用し、main Ractor をレシーバとしたメソッド呼び出し Ractor.main.interrupt_exec{expr} を実行することで、 main Ractor の main スレッドに、ブロック内の処理を実行させることができます。これは、main Ractor でしかアクセスできないリソースを用いる処理を実行する、画期的な手法です。

Ractor.require

Ractor.require は、Ractor#interrupt_exec を活用し、child Ractor から main Ractor に対し require を依頼することで、ライブラリfeature のロードを達成するメソッドです。

Ractor.require を実行した際の処理の流れが、発表スライドの11ページ目に記載されています。interrupt_exec は依頼先のスレッドを待機する必要のない非同期な処理である一方で、Ractor.require の場合は依頼先スレッドの処理完了を待つように設計されているのがポイントだと思いました。(そうでないと、feature ロードが完了していない状態でその feature 呼び出してしまい、エラーが発生する可能性があります)

Kernel#require の内部処理変更

さらに、通常の require Kernel#require に分岐を追加する計画も説明されていました。
以下のような条件分岐の導入によって、Ractor における require のインターフェースが統一されるという嬉しさがあります。

  • main ractor で require が行われた場合: 通常の処理を実行する
  • child ractor で require が行われた場合: Ractor.require を呼び出す

懸念される問題

Rubygems や Bundle など、Kernel#require をオーバーライドするライブラリに対して、child Ractor からの require を想定した「保護処理」を追加する必要があります。
それら全てに対して、その対応を依頼することは可能なのか?という懸念や、Module#prependを利用して Ractor 上の require を含むモジュールを、継承チェーン上でclass Kernelの前段に配置させるというアイデアなど、現在の検討事項について説明されていました。

timeout

現在の課題

timeout ライブラリはある処理が指定時間内に完了しない場合に、その処理を中断するための非同期的な例外を発生させます。このとき、タイムアウトの監視を行うスレッド(Timeout monitor thread)が作成されることになりますが。Ractor の制約上、あるRactor に所属する Timeout monitor thread は別の Ractor に所属するスレッドを監視することができません。この制約の中 Timeout を実現するにはどうすれば良いでしょうか?

解決策1 Ractor ごとに Timeout monitor thread を作成する

それぞれの Ractor に Timeout monitor thread を作成するという解決策は、Ractor を跨いだ処理が発生しないため、実装が簡単であるというメリットがあります。

しかし一方で、Ractor ごとに Timeout monitor thread が作成されるため、スレッドの総数が増えてしまうというデメリットがあります。

解決策2 一つの Ruby タイマースレッドに追加のコミュニケーションパスを生やす

次の解決策として、新しいコミュニケーションパスを用いた通知機構が提案されました。新しいパスを用いた情報の授受によって、 main Ractor で作成された 唯一の Timeout monitor thread が、別 Ractor 上スレッドの監視も行えるようになります。

この方法は、モニタースレッドの数をたった1つに抑えることができるメリットがある一方、スレッド間の API の設計が難しいというデメリットがあります。

解決策3 Native timer thread を使用する

最後に、M:N スレッドスケジューラ で使用されるタイマースレッドを timeout の監視にも利用する方法です。

この方法は、Ruby スレッドとして、タイマースレッドを用意する必要がない上、ネイティブのタイマースレッドをC言語で実装することで、高いパフォーマンスを期待できるという絶大なメリットがあります。

一方で、M:N スレッドモデルに対応していないプラットフォームには、別途サポートが必要である点がデメリットとなります。

手法間の比較

N.times{timeout(1){null_task}} を実行した際の、各手法のスレッド数の比較が行われました。 発表スライドの22ページに記載されています。

解決策3 の実装は、他の実装に比べて実行時間が短いことが明らかとなったものの、それほど大きな差にはなりませんでした。何故でしょうか?

perf によるプロファイリングの結果、ハードウェアタイマーがボトルネックとなっていて、大きな性能差が出なかったということが明らかとなりました。
この結果を踏まえ、タイマーとして若干精度の低い CLOCK_MONOTONIC_COARSE を使用するように変更したところ、手法1と比較して x2.3 の高速化が達成されました。これは素晴らしい結果です。

GC パフォーマンスの問題

Ring example を通じた GC パフォーマンス検証

「50000 Ractor を使用し、n番目の Ractor が n+1 番目の Ractor にメッセージを送信する」というケースを使用して Ractor における GC のパフォーマンス検証が行われました。(これを Ring example と呼びます)

計測の結果、GC が走ると、Ractor の作成時間が大幅に遅くなることが確認されました(発表スライド28ページ目)

GCを挟んだ際に、極端な速度低下が起きる原因は何でしょうか?考えられる理由として、以下のようなものが挙げられます。

  1. To many GCs because of not enough pages (ページの不足による、GCの大量実行)
  2. Stopping active Ractors (アクティブな Ractor の停止)

仮説1 「ページの不足による、GCの大量実行」 について検証

N 個の Ractor を作成した場合と、N 個の Thread を作成した場合のそれぞれのケースで、要素数が100万である配列作成を実行した際の GC 回数と実行時間の計測が行われました。

結果として、Ractor で実行した場合は Thread 実行時に比べ、GC 回数が多い上に、GC 自体のパフォーマンスも悪くなることが確認されました。

Ractorを新規に作成し、オブジェクトが割り当てられるたびに、各Ractorはヒープページ*6 を事前に確保しようとします。
この場合、Ractor に割り当て済みのヒープページに空き領域が多い状態でも、ページの数が足りていない場合には、非効率な GC が発生してしまうことがあります。

仮説2 「アクティブな Ractor の停止」について

GCがヒープ全体を探索しマーキングしている際の、意図しない書き換えを防止するため、Ractor での GC 実行時にはバリア同期という仕組みを利用しています。

バリア同期の最中、全ての Ractor は処理を停止して GC 完了まで待機します。このことが GC 実行時の処理時間増大につながると説明されました。

GC に関する将来の計画

最後に、GC パフォーマンスの改善に向けての構想が紹介されました。
分散 GC は画期的な手法だと感じる一方、何度聞いても難しい問題なのだなと感じました。

まとめ

このセッションでは、require, timeout, GC を中心にRactor の既存課題に対する解決策の提案が行われました。今後、以下のようなAPIが導入されることで、Ractor がより使いやすくなることが期待されます。

  • Ractor#interrupt_exec (and Thread#interrupt_exec)
  • Ractor#main?
  • Ractor.require(feature)
  • Ractor::Channel.new
  • RubyVM.timeout_exec(sec, proc)

感想

Ractor はまだまだ細かい課題が残っているものの、その解決策が提案され、改善に向かうのだろうなという希望を感じました。
特に、数年前から課題となっていた分散GCは、やはり重要かつ困難な課題として、今後も取り組まれ続けるのだろうと感じています。

Ruby における並列化技術の最前線を知ることができ、とても興味深いセッションでした。

また、一日目のセッション後のオフィシャルパーティーにて、笹田さんと直接お話しさせていただきました。ありがとうございました。

./images/offical_party.png


Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。
ご応募お待ちしております。 jp.techouse.com

*1:Actor モデルは、並行処理を行うためのモデルの1つです。並列処理を実行する各要素はそれぞれ 'Mailbox' と呼ばれるメッセージキューを持ち、Mailbox を活用した非同期的メッセージ送受信を行うことで、情報を交換します。

*2:そのため Ractor という言葉が出てくる際は、機能としての Ractor を指すのか、分割単位としての Ractor を指すのか、その文脈に注意する必要があります。

*3:ただしこれとは別に、同じ Ractor に所属するRuby スレッド同士は Ractor 内の GVL によって排他制御されます

*4:ただし、C拡張ライブラリの互換性に問題が生じる可能性が考慮され、Ruby 3.3 のデフォルト設定では M:N スレッドスケジューラが無効化されています。

*5:Ractor は最初に作成される main Ractor とそれ以外のchild Ractor(non-main Ractor)という分類がされており。main Ractor と child Ractor は操作可能なリソースなどの点が異なります。

*6:一般的に、「ヒープ領域」という言葉は、命令によって動的に確保されるメモリ領域を指しますが、Rubyの「ヒープページ」は、より多くの機能と役割を有しています。16kb のサイズを持つヒープページはスロットと呼ばれる 40 byte ごとの小区画に分割され、各スロットには RVALUE と呼ばれる Ruby オブジェクトの実体が格納されます。参考: https://techracho.bpsinc.jp/hachi8833/2022_06_02/118259