C拡張機能からRubyメソッドを呼び出す際のメモリリークを防ぐ方法
メモリリークはgemユーザーにとって頭痛の種です。原因の追跡が難しく、高額なインフラコストにつながることもあります。
C拡張内で発生するメモリリークはさらに厄介です。Rubyにおけるリーク発見のためのツールや解説記事は豊富にありますが、Cでは内部構造に同じようにはアクセスできません。
rb_funcallの安易な使用はメモリリークを引き起こす可能性があります。代わりにrb_protectを使う方がはるかに安全です。C拡張の開発者の方は、あなたのgemを使う開発者のために、ぜひこの記事を読んでみてください。
それでは始めましょう!
rb_funcallとCの問題点
rb_funcallは、ライブラリのRuby部分とC部分を連携させたいものの、記述するCコードを最小限に抑えたい場合に非常に便利なツールです。
しかし、rb_funcallを実行すると、すべてが単純明快なCの世界から一歩外に出ることになります。呼び出した先の関数が以下のような挙動をする場合、泥沼にはまりかねません。
- 実行時に定義が完全に変わってしまう
- 例外(raise)を発生させる
1つ目は最も容易に検出できます。セグメンテーションフォルトが発生するでしょうし、テストスイートが十分に整備されていれば、公開前に気づけるはずです。
しかし、2つ目はメモリリークを引き起こし、コードベースの可読性を大きく損なう可能性があります。詳しく見ていきましょう。
Rubyのraiseが引き起こすC側のメモリリーク
Rubyのraiseの仕組みは、エラーをキャッチする最初の親スコープへと、コード間をジャンプする形で動作します。これはMRIにおいてlongjmpとsetjmpを使って実装されています。
この仕組みの詳細に興味がある方は、Ruby Hacking Guideの「Evaluator」章を参照してください。簡単に説明すると、begin..ensureブロックに入るときにsetjmp()が呼ばれ、ブロック内で例外が発生すると、保存しておいた位置へlongjmp()でジャンプします。
つまり、rb_funcallで呼び出した関数が例外を発生させると、その後に続くCコードは一切実行されません。
以下の例は、潜在的なリークを示しています。json_parseが例外を発生させると、メモリが解放されないままリークします。
もちろん、上記の例は少し極端です。メモリ解放とRuby処理の順序を入れ替えれば回避できます。しかし、それが常に可能とは限らず、関数本体が長くなるほど処理が絡み合ってしまいます。
Rubyでのbegin..ensureの活用
Ruby側であれば、上記の例をbegin..ensureを使って次のように書けます。
このAPIはCでもrb_rescueとrb_ensureとして利用可能です。
ただし、この書き方はやや煩雑で、さらにrescueブロックを追加しようとすると可読性が大きく低下します。Cでbegin..rescue..ensure..end相当のAPIを使いたい場合は、Peter Zhu氏の「A Rubyist's Walk Along the C-side (Part 8): Exceptions & Error Handling」を読むことをおすすめします。
C向けのrb_protectの活用
もう一つの選択肢があります。まず、Rubyで書くとどのようになるか見てみましょう。
Rubyとしてはやや奇妙に見えますが、Cとの相性が非常に良いワークフローです。MRIにはこれに対応するAPIとしてrb_protectが用意されており、C関数は次のように書けます。
上記のメソッドは、すべてのリソースを解放した後、Rubyのエラーを再送出します。
また、Rubyで空のrescueブロックを使うことで、エラーを意図的に無視することも可能です。
警告: エラーを再送出しない場合は、rb_set_errinfo(Qnil)のステップが重要です。これを怠ると、ユーザーが知る必要のないエラー情報が内部に残ってしまうためです。
あるいは、rescue My::Errorのように、条件に応じてエラーを発生させることもできます。
rb_errinfo()は、$!グローバル変数と同じ役割だと考えて差し支えありません。
ここまでは良いのですが、対象がrb_funcallだけなら、このAPIをもっとシンプルにできます。
rb_protect APIを使用する根本的な考え方は、例外を発生させ得る関数に対して可読性を高めることにあります。その関数が例外を投げるかどうかを逐一確認する必要はなく、「投げ得る」と仮定して状態(state)を扱えばよいのです。
rb_protect_funcallの提案
「危険な」メソッドはrb_funcallだけなので、これだけを切り出してみましょう。それを実現するAPIがこちらです。
このAPIはrb_funcallと同一で、rb_protect由来のstateが加わっています。したがって、使い方は非常にシンプルです。
このAPIは現在のRubyには搭載されておらず、将来的にも追加されるかは不明です。RGeo(MITライセンス)からコードを入手できます。
実際の使用例
実際の事例を見たい方は、RGeoのコードベースを参照してください。最近、全面的にrb_protectへ移行しました。さらに、rgeo_convert_to_geos_geometryのように、stateを伝播させて扱いやすくした関数もあります。この関数はコードを読み解く良い出発点となるでしょう。
私たちが下した設計判断についてさらに議論したい場合は、遠慮なくRGeoでissueを立ててください。
まとめ
この記事では、Cからrb_funcallを使用するとメモリリークを引き起こす恐れがある点を指摘し、代わりにbegin..ensureやrb_protectを活用する方法を解説しました。
それでは、楽しいコーディングを!
P.S. Ruby Magicの最新記事をいち早く読みたい方は、Ruby Magicニュースレターをご購読ください。記事を見逃すことはありません!
Ulysse Buonomo(著者紹介)
ゲスト執筆者のUlysseは元業界のRuby開発者で、現在は世界各地を旅しながら暮らしています。余暇はRGeoとRubyの開発に捧げており、Rubyの内部実装をいじるのが大好きです。
-
Railsのセキュリティ脅威に立ち向かう:認証と機密データ露出への対策
本シリーズの第1回では、インジェクション攻撃について解説しました。 OWASP Top 10 Webアプリケーションセキュリティリスクをテーマにした本シリーズの第2回となる今回は、認証の不備と機密データの露出という脅威の世界を深く掘り下げていきます。 具体的には、ハッカーがどれほど簡単にあなたが書いたコードを欺き、ユーザーのデータを盗み出すことができるのか、以下のような攻撃手法を見ていきます。 ユーザー列挙(User Enumeration):ログインページを悪用し、候補となるユーザー名のリストを総当たりで試して、データベース内にそのユーザーが存在するかどうかを確認する攻撃。 弱いパスワード
-
【後編】AppSignalでRuby on Railsアプリのパフォーマンス・ログ・アップタイムを効率的に監視する
はじめに 本記事シリーズの前編では、シンプルなRuby on RailsアプリケーションをDigitalOceanのApp Platformにデプロイし、AppSignalと連携させることで、エラーがどのようにトラッキングされ、Errorsダッシュボードに表示されるかを確認しました。 後編となる本記事では、AppSignalを使ってRuby on Railsアプリケーションに以下の機能をセットアップする方法を詳しく解説します。 パフォーマンス監視 Railsのバックグラウンドジョブ監視(シンプルなAPI呼び出しの監視方法も含む) ロギング 通知アラート それでは始めましょう! AppSig