こんにちは!株式会社Techouseのaaaa777です。 RubyKaigi 2025は興味深いセッションが目白押しでしたね。個人的には低レベルなトピックに関心があり、2日目はpicorubyやビルドシステムに関する話を中心に聞きました! その中でも自分が紹介したいのは、Alexander MomchilovさんによるFrom C extension to pure C: Migrating RBSというセッションです。本記事では、その内容をまとめてご紹介します。
背景: Sorbet, RBS, そしてパフォーマンスの課題
Shopifyでは現在、開発時の静的型チェックと実行時の動的型チェックに、Sorbetというツールを使用しています。Sorbetは型の記述に独自のsig
構文を採用しているのが特徴です。
class Point extend T::Sig sig { returns(Integer) } attr_reader :x sig { returns(Integer) } attr_reader :y sig { params(x: Integer, y: Integer).void } def initialize(x:, y:) @x = x @y = y end end
一方、Ruby 3からは標準の型定義言語としてRBSが導入されました。RBSは通常、型情報を.rbs
という拡張子のファイルに記述します。さらに2024年にはRBS::Inlineが登場し、Rubyコードのコメント内に直接RBSの構文で型を記述できるようになりました。
class Point attr_reader :x #: Integer attr_reader :y #: Integer #: (x: Integer, y: Integer) -> void def initialize(x:, y:) @x = x @y = y end end
RBS::Inline
の方がsig
構文よりも読みやすいです(と感じますよね?)。Sorbetチームもこの利点に着目し、SorbetでRBS::Inline
を扱えるようにする機能を試験的に追加しました。(参考: RBS Comments Support)
しかし、RBS::Inline
の型情報を解析する現在のRBSパーサーには、Shopifyのような大規模なコードベースで利用するには無視できないパフォーマンス上の課題がありました。
RBSパーサーのパフォーマンス問題
RBS::Inline
は内部で使われているRBSパーサーは、ruby/rbs
gemの一部として提供されており一部はCで実装され、Ruby C APIに依存しています。
Ruby C APIは、C言語からRubyの機能を呼び出すための関数群であり、その実行にはRuby VMへの依存があります。このため、パーサーはGVL(Global VM Lock)の影響を受けます。この制約により、RBSパーサーは複数のCPUコアを活用した並列処理を行うことができません。
一方で、Shopifyが利用しているSorbet本体はC++で実装されておりRuby C APIに依存せず、並列処理が可能な設計です。Shopifyが抱えるような巨大なモノリシックリポジトリでは、型チェックの速度は開発体験に直結するため、解析処理を高速化するための並列処理能力は不可欠です。
このパフォーマンス問題を解決するために取ったのが、RBSパーサーをPure C(純粋なC言語のみ)で再実装するという方法です。(参考: PR)
Pure Cとは、Ruby C APIに依存しない、C言語のみを使った実装を指します。これにより、GVLの制約を受けないRBSパーサーになります。加えて、Pure Cで実装することで、Ruby VMに依存しない独立したライブラリとしてRuby以外の言語から利用できるようにもなり、Sorbetに組み込むことも出来るようになります。
では既存のRuby C APIに依存したRBSパーサをPure Cで再実装するとき、どのような課題があったのでしょうか?ここではRuby C APIが提供する機能のうち、例外処理とメモリ管理をPure Cで実装する方法を取り上げます。
例外処理の実装
Ruby C APIにはRubyの例外を発生させるためのrb_raise()
や、それを捕捉するためのrb_rescue()
やrb_ensure()
などの後処理を行う関数が用意されています。Pure Cには、このような例外処理の仕組みは組み込まれていません。どのようにエラーを扱うのでしょうか?
セッションで紹介されたのは、関数の戻り値を使ってエラー情報を伝播させる方法です。Rubyの例外は、エラー発生地点からそれを捕捉する箇所まで、コールスタックを遡ってジャンプするような動作をします。これと同様の制御フローを、関数の戻り値で実現します。
以下はセッションで示された関数の例です。変更前のコードはrb_raise()を使って例外を発生させています。
変更前
rbs_node_t *parse_optional_tuple() { rbs_node_t *tuple = parse_tuple(); // ここで例外が発生する想定 return (next() == '?') ? rbs_optional_new(tuple) : tuple; } rbs_node_t *parse_tuple() { if (next() != '[') { rb_raise(rb_eSyntaxError, "Expected tuple to start with '['"); // 例外発生 } // ... }
変更後
例外のような仕組みを実装するにはまずrb_raise()
をreturn true;
に置き換え、true
で例外の発生を表現します。関数の戻り値を真偽値に変更し、本来の戻り値(パース結果など)は、呼び出し元から渡されたポインタ経由で設定します。
bool parse_optional_tuple(rbs_node_t **result) { rbs_node_t *tuple; if (!parse_tuple(&tuple)) { // 例外の判定 return false; // 呼び出し元にエラーを伝播 } return (next() == '?') ? rbs_optional_new(tuple) : tuple; } bool parse_tuple(rbs_node_t **result) { // 引数にポインタ、戻り値をboolに変更 if (next() != '[') { return false; // 例外の代わりにfalseを返す } // ... return true; // 処理に成功した場合はtrueを返す }
こうすることで、エラーが発生した場合はfalse
が帰り、呼び出し元は戻り値をチェックしてエラーをハンドリングするすることができます。
メモリ管理: Arena Allocationの活用
RubyではGC (ガベージコレクタ) が不要になったオブジェクトのメモリを自動的に検出し、解放してくれます。しかし、Pure CにはGCのような自動メモリ管理機構はありません。そのため、開発者が手動でメモリを確保し、不要になった時点で解放する必要があります。
手動でのメモリ管理は、特にエラー処理などで早期リターンが発生する場合に複雑になりがちです。以下の例を見てみましょう。
// 手動でのメモリ管理が必要な例 Foo *parse_foo() { A* a = parse_a(); // Aを確保 if (a == NULL) { return NULL; // a の確保に失敗したら早期リターン } B* b = parse_b(); // Bを確保 if (b == NULL) { // Bの確保に失敗したら、確保済みの a を解放してリターン free(a); return NULL; } C* c = parse_c(); // Cを確保 if (c == NULL) { // Cの確保に失敗したら、確保済みの a と b を解放してリターン free(a); free(b); return NULL; } // すべて成功した場合 Foo* foo = new_foo(a, b, c); return foo; }
この例のように、関数の出口が複数ある場合、それぞれの箇所で不要になったメモリを正確に解放するコードを書く必要があり、非常に煩雑です。解放漏れ(メモリリーク)や、すでに解放したメモリを再度解放してしまう二重解放といったバグの原因になりやすくなります。特に、トークンを次々に読み出すようなパーサーの実装では、少しの解放漏れが積み重なり、深刻なメモリリークを引き起こす可能性があります。
この問題を解決するために、セッションではArena Allocation (アリーナアロケーション) というメモリ管理手法が紹介されました。これでライフタイムが同じオブジェクト群をRubyのスコープのようにまとめて管理できます。
Arena Allocationは、まず比較的大きなメモリ領域を最初に確保しておき、個々のオブジェクトが必要になった際には、その大きな領域から必要な分だけを切り出して割り当てる方式です。そして割り当てられた全てのオブジェクトが不要になったタイミングで、全体を一度に解放します。
例えばlibcのmalloc()
/free()
を使う場合、確保したオブジェクトごとに解放処理が必要です。
// malloc/free を使ったメモリ管理 int *my_int = malloc(sizeof(int)); char *my_char = malloc(sizeof(char)); foo_t *my_foo = malloc(sizeof(foo_t)); do_something(my_int, my_char, my_foo); free(my_int); free(my_char); free(my_foo);
一方、Arena Allocationを使うと、解放処理が非常にシンプルになります。
// Arena Allocation を使ったメモリ管理 rbs_allocator arena; // アリーナ用の構造体 rbs_allocator_init(&arena, 4096); // 4KB のアリーナを初期化 int *my_int = rbs_alloc(&arena, sizeof(int)); char *my_char = rbs_alloc(&arena, sizeof(char)); foo_t *my_foo = rbs_alloc(&arena, sizeof(foo_t)); do_something(my_int, my_char, my_foo); rbs_allocator_free(&arena);
Arena Allocationには、ライフサイクルが似ているオブジェクト群のメモリ管理が容易になり、個々の解放処理を記述する必要がなくなるためコードが簡潔になる、といった利点があります。これにより実装ミスによるメモリリークのリスクも大幅に低減できます。Arena Allocationはlibcでは提供されないので、RBSパーサの再実装では自前で実装しています。
一方で注意点としてメモリを解放するとき、そこから割り当てたすべてのオブジェクトのメモリが無効になるため、解放後にオブジェクトにアクセスするとメモリアクセス違反(Dangling Pointer)が発生するため、オブジェクトのライフタイムをアリーナのライフタイムと一致させる必要があります。また、メモリ割り当てが必要なすべての関数にアロケータ(rbs_allocator
)を持って回る必要があり、関数のシグネチャが少し複雑になる可能性もあります。
まとめ
今回のセッションでは、RBSパーサーをC拡張からPure Cへ移行する取り組みについて、特にエラーハンドリングとメモリ管理という課題にについて解説されました。
C拡張として実装されているライブラリがGVLの影響で並列化できない場合がある、という点は(言われてみれば当然ですが)改めて意識させられました。Ruby C APIが提供する便利な機能がない中で、Pure Cで同等の機能を実現するための工夫は非常に興味深かったです。
そして最後に、なんとセッション終了後に発表者のAlexander Momchilovさんと2ショット写真を一緒に撮ってもらいました!ムキムキのイケメンで非常に聞き取りやすい英語を話す優しい方で、記事の執筆にあたり発表資料の提供も快くOKしていただき、本当に良くしていただきました。この場を借りてお礼申し上げます、ありがとうございました!
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。