こんにちは、株式会社Techouse でエンジニアインターンをしている ez0momonga と申します。 RubyKaigi 2025に参加し、その 3日目に行われた笹田 耕一(@ko1)さんによるセッション「Toward Ractor local GC, 2025」を聴講いたしました。
この記事では、発表の背景知識を振り返りつつ、セッションの内容をまとめさせていただきます。
あらまし
かつてシングルコアCPUが主流だった時代は、毎年のようにCPUのクロック周波数の向上やトランジスタ数の増加が続き、ソフトウェアはその性能向上の恩恵を容易に享受できていました。 しかし、物理的な限界からCPUのシングルコア性能の向上は頭打ちとなり、代わって複数のコアを搭載するマルチコアCPUが主流となりました。 このマルチコアアーキテクチャの恩恵を最大限に引き出すためには、並行・並列処理を実現する必要があります。
直列 (Serial) と並行 (Concurrent)、並列 (Parallel) の違いを図に表すと、以下のようになります。
最近のコンピュータでは8コアや12コアなど非常に多くのCPUコアが搭載されていますが、プログラマが意識的に並列処理に対応したプログラムを実装しない限り、マルチコアCPUの恩恵を受けることはできません。
そこで並行処理・並列処理を実現する際によく用いられているテクニックが「マルチスレッド」です。 スレッドとは、プロセス内における軽量な実行単位のことです。スレッドの実装にはさまざまなものがあり、UNIX Like な環境下では pthreads (POSIXスレッド) が広く一般的に用いられています。pthreads を用いて複数のスレッドを起動すると、カーネルのスケジューラがそれらのスレッドをそれぞれCPUコアに割り当てて同時に実行します。これを「マルチスレッド」といいます。
Ruby の内部でも、この pthreads が用いられており、Thread クラスを使用することで pthreads を(Threadクラスを介して)間接的に利用できます。しかし、注意点がございます。
Rubyにおける並行・並列プログラミングの課題
ただし Thread クラスを利用するだけで自動的にマルチコア対応のプログラムに仕上がるというわけではありません。 Thread クラスのドキュメントにも記載されている通り、CRuby では GVL (Giant VM Lock) という機構があり、同時に動作できるスレッドが1つに制限されているためです。 ディスクやネットワークの IO 処理、タイマの待ち受けなどでブロックする処理が発生した際にのみ GVL が解放され、別のスレッドの処理が開始されます。これにより、少なくともIOをメインとするプログラムでは並列処理が実現できます。
なぜ GVL のような機構が存在するのか、疑問に思われるかもしれません。
実際のところ、マルチスレッドプログラミングは非常に難しい技術です。 複数のスレッド間で共有されているデータへ同時に読み書きを行うと、データの不整合が発生し、プログラムの誤動作を引き起こす可能性があります。これをレースコンディションといいます。 レースコンディションを防ぐためにミューテックスやセマフォといった排他制御の仕組みを慎重に実装し、共有リソースへのアクセスを厳密に管理しなければなりません。しかし、この点が実装の複雑化やバグの温床となりやすい要因です。さらに、再現が困難なバグとなる場合が多く、その修正も非常に難しいという課題があります。 このような複雑な対応を、アプリケーションコード内のみならず、ライブラリやフレームワークの利用方法も含めて慎重に行う必要がある点が、マルチスレッドプログラミングの難しさの一つです。もともとマルチスレッドを意識して作られていなかったコードをマルチスレッド化するとなると、いったいどこをどうしたらいいのか把握するところから始めなければならないため、難しいだけでなく極めて複雑な作業が求められます。
GVL の存在により、複数のスレッドが同時にRubyのコードを実行することを防ぎ、ほとんどの場合において開発者が煩雑な排他制御を細かく意識せずとも済みます。 しかし、この制約により、複数のCPUコアを最大限に活用するようなプログラムの実装は困難になります。GVL には恩恵がある一方で、制約としての側面も持ち合わせているのです。 このため Ruby において、真の並行処理を実現するには、プロセスを複数起動するアプローチが必要とされてきました。
Ractor の登場
そこで登場したのが Ractor です。 Ractor は、今回のセッションの発表者でもある笹田さんによって開発が進められている、Rubyにおける並列プログラミングのためのAPIです。 この Ractor を使用すると GVL の影響を受けずに、スレッドセーフな並列プログラミングを実現できます。1
では、Ractor はどのようにしてマルチスレッドプログラミングの課題に対処したのでしょうか。
実は、Ractor では共有変数への書き込みを制限することにより、マルチスレッドプログラミング特有の共有変数の取り扱いを大幅にシンプルにしているのです。 前述の通り、マルチスレッドプログラミングの難しさは共有変数の読み書きにありますが、Ractor ではそもそも共有変数への書き込みを原則として禁止するというアプローチにより、この問題を回避しているのです。
より具体的には、Ractor 間でデータをやり取りする際には、共有可能なごく一部のオブジェクト(immutableオブジェクトや Class、Module オブジェクトなど)を除き、オブジェクトのメッセージパッシングを介して行われます。共有可能な変数を絞り、さらに書き込みを制限することで、共有変数に起因するバグを防ぐことができたのです。
この設計思想は並行・並列処理のための計算モデルの一つであるアクターモデルに影響を受けています。 アクターモデルでは、アクターと呼ばれる独立した実行単位がそれぞれメールボックスを持ち、自身宛てのメッセージを受信した際に、その内容に応じた処理を実行します。アクター間のやり取りは、メモリを直接共有するのではなく、メッセージとしてオブジェクトのコピーを送信し合うメッセージパッシングによって行われます。
このアクターモデルの考え方を踏襲し、各 Ractor は原則として独立した Object Space を持ち、他のRactor が持つオブジェクトには直接アクセスできないようになっています。
しかし、Ractor にはパフォーマンス上の課題がいくつか残されています。 Ractor ではガベージコレクション( GC )を並列化できないという問題があり、パフォーマンスの大きなボトルネックとなっていました。この点は昨年の RubyKaigi 2024 のセッション「Ractor Enhancements, 2024」でも言及されており、今年の講演では、このRactorにおけるGCのパフォーマンス改善に焦点が当てられました。
Ruby におけるガベージコレクション(GC)のおさらい
セッションの内容の理解を深めるため、Ruby における GC の仕組みを簡単に説明します。
ガベージコレクション(Garbage Collection, GC)とは、ヒープ領域から、参照されず不要になったオブジェクトを自動的に回収し解放する仕組みです。
RubyにおけるGCでは主にマーク&スイープというアルゴリズムを使用しており2、以下のように実行されます。
まず、始点となるルートオブジェクトから、参照されている(生きている)オブジェクトを探索し、使用中のオブジェクトとしてマークを付けます。3 次に、マークされたオブジェクトからさらに参照されているオブジェクトを探索し、同様にマークを付ける処理を再帰的に行います。
マーキングが完了したら、ヒープ上の全てのオブジェクトを探索し、マークされていないオブジェクトを参照されていない(死んでいる)ものとして回収し解放(スイープ)します。 スイープされた領域はそれ以降に生成されたオブジェクトに対して割り当てられ、再利用されます。
Rubyは GC を実行する際、意図しない書き換えを防止するため、Rubyコードの実行を一時停止させます(Stop-the-World)。
Ruby の GC について、より詳しくは Peter Zhu 氏による同日のセッション「Modular Garbage Collectors in Ruby」や こちらの資料をご参照ください。
Ractor ローカル GC の提案
現在の Ruby 3.4 では、全ての Ractor がグローバル Object Space と呼ばれる単一のヒープを共有しています。 このため GC 完了まで全ての Ractor の処理を停止させる必要があります。つまり、GCが実行されている間は、並列処理の恩恵を受けられないという課題があります。
理想的には、各Ractorのローカルヒープに対して独立して GC が実行される仕組みが望まれます。それがローカル GC です。 ただしローカル GC の導入には、いくつかの課題が存在します。
共有可能オブジェクトによる課題
ローカル GC の導入の課題となっているのが、共有可能オブジェクト (shareable objects) です。
共有可能オブジェクト は、その名前の通りコピーなしで Ractor 間で共有されます。つまり共有変数です。代表例としては以下のものが挙げられます。
- ClassやModuleオブジェクト
- immutable オブジェクト(共有可能なオブジェクトのみを参照するfrozenオブジェクト)
- Ractorオブジェクト自身などの特別なオブジェクト
具体的には、ある Ractor(R1)に属する共有可能オブジェクト(sh1)が他の Ractor(R2)から参照されている状況で、R1のローカルGCがその参照を感知できずにsh1を誤って回収してしまうというケースが考えられます。これにより、プログラムがクラッシュするなどの深刻な問題を引き起こす可能性があります。
このようにして、共有可能オブジェクトの存在が、ローカル GC の実現を困難にしています。
解決策の検討と課題
解決策(1): 共有可能オブジェクトへの参照を追跡する
この問題に対して、まず検討されたのがどの Ractor がどの共有可能オブジェクトを参照しているかを常に追跡するという方法です。 先程の例に倣えば、Ractor(R1)のローカルGCが共有可能オブジェクト(sh1)に関して、別の Ractor(R2)からの参照を感知できれば、不正な回収を避けることができます。
しかし、実際にはこの方法の実装は非常に困難です。 Ractorは並行して動作するため、共有可能オブジェクトへの参照は随時更新される可能性があり、全ての Ractor 間で正確な参照状態を追跡することは非常に困難なためです。
不正確な追跡に基づいて ローカルGC を実行すると、マーキング漏れによる早期回収といった問題を引き起こす可能性があります。また、参照を追跡することによる処理のオーバーヘッドやメモリ使用量の増加も懸念されます。
引用:発表スライド, 15ページ目
解決策(2): グローバル Object Space (現在の実装)
そこで、現在の実装では、グローバル Object Space とグローバル GC を採用することで、この問題を回避しています。
この方法では、全てのオブジェクトが単一の Object Space で管理され、GC 時には全ての Ractor を停止させることで、全てのオブジェクトを正確にマーク&スイープします。
この方法は正確性が保証される一方で、前述の通りGC中は並列処理の恩恵を受けられず、パフォーマンス上の課題が残ります。
引用:発表スライド, 20ページ目
提案法
今回の講演で提案されたのは、より保守的なアプローチとして、共有可能オブジェクトのライフサイクルを厳密に追跡することを諦め、常に「生きている」とみなすという方法です。
前述の通り、ローカルGC実現における課題は、共有可能オブジェクトのライフサイクルを正確に追跡することにありました。そこで、提案された方法では、完全なローカル GC ではなく、グローバル GC と併用する形で課題に対処します。
具体的には、ローカル GC を実行する際に、その Ractor に属する共有可能オブジェクトを常にルートオブジェクトとしてマークし回収の対象外とします。これら共有可能オブジェクトの回収は、これまで通りグローバル GC に委ねられます。
引用:発表スライド, 25ページ目
これにより、ローカル GC は共有されていない Ractor ローカルなオブジェクトのみを対象とし、並列に実行できます。共有可能オブジェクトはローカル GC の対象外となるため、誤って回収される心配はありません。少なくとも、現在のグローバル GC のみの実装と同等の安全性は確保したまま、パフォーマンスの課題となるグローバル GC の実行頻度を低減することが期待されます。
このアプローチのデメリットは、不要になって本来回収されるべき共有可能オブジェクトがあったとしても、次回のグローバル GC まで回収されないため、メモリ使用量を増加させる可能性がある点です。
しかし、その影響は限定的であると考えられます。実際、オブジェクト全体に占める共有可能オブジェクトの割合は比較的小さく、加えて、先に挙げた共有可能オブジェクトの多くは一般的にライフタイムが長い傾向にあると想定されます。
- Class や Module オブジェクト: 一般的にライフタイムは長い。
- Ractor オブジェクト: アプリケーション依存だが大半は短寿命ではないと考えられる。
- (共有可能な) immutable オブジェクト: アプリケーション依存だが定数に代入される場合は長寿命になる傾向がある。
そのため、この保守的なローカル GC による回収漏れを考慮しても、メモリ使用量の増加は限定的であると考えられます。
講演では、今回の提案法を「Done is better than Perfect」という、Zuckerberg氏の格言としてもお馴染みの言葉で表現されていました。完全なローカル GC の理想を追い求めるあまり具体的な改善が進まなかった状況に対し、今回の提案法は完全ではないものの、少なくとも現在の実装よりは改善している、として現実的な解決策の重要性を示唆されていました。
パフォーマンス評価
講演では、提案されたRactorローカルGCの性能を評価するマイクロベンチマークの結果が示されました。
ベンチマークの内容は、単純なタスクをN回繰り返すもので、並列実行するRactorの数をRNとして、各Ractorは TN = N / RN 回のタスクを実行します。ベンチマークのスクリプトはGitHubで公開されています。
セッションでは次の3つのベンチマーク結果が示されました。
短い生存期間のオブジェクトを大量に生成するタスク
Ractor の数が増えるにつれて、既存の実装(master)は実行時間が変化しない一方で、提案法(modified)は大幅なスピードアップを達成し、Ractorが1つのみの場合と比較して最大で約5倍の速度向上が見られました。
これは、ローカル GC が各 Ractor で並列に動作し、短い生存期間のオブジェクトを効率的に回収できるようになった結果と考えられます。
引用:発表スライド, 33ページ目
長い生存期間のオブジェクトを大量に生成するタスク
既存の実装(master)では Ractor の数が増えるにつれて実行時間が増加する傾向が見られましたが、提案法(modified)では Ractor の数に依存せず、安定したパフォーマンスを示しました。
引用:発表スライド, 35ページ目
正規表現のマッチングタスク
上記のオブジェクトの生成ほど顕著ではありませんが、正規表現マッチングにおいても、提案法(modified)は既存の実装(master)と比較してより短い実行時間を示しました。
引用:発表スライド, 37ページ目
これらの結果から、提案された保守的なローカル GC のアプローチが、グローバル GC のみに頼る従来法と比較して、高いスケーラビリティを示すことが明らかになりました。
今後の展望
講演の最後には、今後の作業予定と展望が示されました。
今後の作業予定として、2025年12月に予定される Ruby 3.5 でこの機能を完成させることを目指しているとのことです。軽量なオブジェクト送信の仕組みの導入や、callcache のような共有可能オブジェクトの削減なども検討中と述べられていました。
また、 グローバル GC における並列マーキングの導入も将来的な目標として挙げられていました。これにより、グローバル GC の効率向上も期待されます。
さらに、理想的な方法ではあるものの実装が難しいとされた解決策(1)についても、Rohit 氏が良いアイデアと実装を持っている可能性に言及がありました。Rohit 氏に関しては、講演者である笹田さんが、以前 Google Summer of Code でのメンターを務めており、同様にRactor の GC に関する研究を行っていることから、そちらの進展も期待されます。
参考: Erlang におけるローカル GC
Ractor の GC を考える上で、同様にアクターモデルを採用する言語として、参考までに Erlang のケースをご紹介します。
Erlangは言語設計レベルから並列処理を強く意識した言語です。Erlang は、BEAM という仮想マシン上で実行される関数型言語であり、「プロセス」という並列処理機構を持っています。4
Erlang のプロセスは、OSの機能としてのプロセスと異なり、BEAM上で動作する軽量なものです。Erlang のプロセスは各々独立したスタックとヒープを持ち、他のプロセスのメモリ領域に直接アクセスできない構造になっています。
このため、プロセス間で共有される変数が原則として存在せず5、ErlangのGCはプロセスごとに独立して並列実行が可能です。
感想
今回の講演では、数年前から課題として挙げられていた Ractor の GC に関する問題が、具体的な解決策とともに提案されており、非常に興味深い内容でした。
Ractor は導入以来様々な課題が指摘されてきましたが、毎年改善が続けられ、一歩一歩課題が解決されている様子がうかがえました。Ruby の並列処理の可能性を広げる重要な機能であると改めて感じ、今後の進展が非常に楽しみです。
また、3日目のセッション後には、STORES様のブースにて笹田さんと直接お話しし、セッションの内容について質問させていただく機会にも恵まれました。その際は、拙い質問にも丁寧にお答えいただき、非常に感謝しております。
改めて、講演者の笹田さんをはじめ、発表者の皆様、そして RubyKaigi 2025 の運営に関わった全ての方々に感謝申し上げます。
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。
- 同じ Ractor に所属する Ruby スレッド同士は GVL によって排他制御されます。↩
- RubyにおけるGCでは、その他にもGenerational GCなど、様々な手法が組み合わさって実装されていますが、ここではマーク&スイープに焦点を当てて説明します。↩
-
Array
やHash
ならそれらに格納された要素、一般的なクラスならそのインスタンス変数などが参照の代表例です。↩ - Erlang のプロセスは OS におけるプロセスとは別物で、BEAM 上で動作する軽量なスレッドのようなものです。↩
- 例外的に Erlang Term Storage (ETS) というプロセス間でのデータ共有機構も存在するようですが、プロセスのヒープとは別に管理されます。参考資料↩