Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

C拡張機能からRubyメソッドを呼び出す際のメモリリークを防ぐ方法

メモリリークはgemユーザーにとって頭痛の種です。原因の追跡が難しく、高額なインフラコストにつながることもあります。

C拡張内で発生するメモリリークはさらに厄介です。Rubyにおけるリーク発見のためのツールや解説記事は豊富にありますが、Cでは内部構造に同じようにはアクセスできません。

rb_funcallの安易な使用はメモリリークを引き起こす可能性があります。代わりにrb_protectを使う方がはるかに安全です。C拡張の開発者の方は、あなたのgemを使う開発者のために、ぜひこの記事を読んでみてください。

それでは始めましょう!

rb_funcallとCの問題点

rb_funcallは、ライブラリのRuby部分とC部分を連携させたいものの、記述するCコードを最小限に抑えたい場合に非常に便利なツールです。

しかし、rb_funcallを実行すると、すべてが単純明快なCの世界から一歩外に出ることになります。呼び出した先の関数が以下のような挙動をする場合、泥沼にはまりかねません。

  1. 実行時に定義が完全に変わってしまう
  2. 例外(raise)を発生させる

1つ目は最も容易に検出できます。セグメンテーションフォルトが発生するでしょうし、テストスイートが十分に整備されていれば、公開前に気づけるはずです。

しかし、2つ目はメモリリークを引き起こし、コードベースの可読性を大きく損なう可能性があります。詳しく見ていきましょう。

Rubyのraiseが引き起こすC側のメモリリーク

Rubyのraiseの仕組みは、エラーをキャッチする最初の親スコープへと、コード間をジャンプする形で動作します。これはMRIにおいてlongjmpsetjmpを使って実装されています。

この仕組みの詳細に興味がある方は、Ruby Hacking Guideの「Evaluator」章を参照してください。簡単に説明すると、begin..ensureブロックに入るときにsetjmp()が呼ばれ、ブロック内で例外が発生すると、保存しておいた位置へlongjmp()でジャンプします。

つまり、rb_funcallで呼び出した関数が例外を発生させると、その後に続くCコードは一切実行されません。

以下の例は、潜在的なリークを示しています。json_parseが例外を発生させると、メモリが解放されないままリークします。


もちろん、上記の例は少し極端です。メモリ解放とRuby処理の順序を入れ替えれば回避できます。しかし、それが常に可能とは限らず、関数本体が長くなるほど処理が絡み合ってしまいます。

Rubyでのbegin..ensureの活用

Ruby側であれば、上記の例をbegin..ensureを使って次のように書けます。


このAPIはCでもrb_rescuerb_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..ensurerb_protectを活用する方法を解説しました。

それでは、楽しいコーディングを!

P.S. Ruby Magicの最新記事をいち早く読みたい方は、Ruby Magicニュースレターをご購読ください。記事を見逃すことはありません!

C拡張機能からRubyメソッドを呼び出す際のメモリリークを防ぐ方法

Ulysse Buonomo(著者紹介)

ゲスト執筆者のUlysseは元業界のRuby開発者で、現在は世界各地を旅しながら暮らしています。余暇はRGeoとRubyの開発に捧げており、Rubyの内部実装をいじるのが大好きです。

  1. Railsのセキュリティ脅威に立ち向かう:認証と機密データ露出への対策

    本シリーズの第1回では、インジェクション攻撃について解説しました。 OWASP Top 10 Webアプリケーションセキュリティリスクをテーマにした本シリーズの第2回となる今回は、認証の不備と機密データの露出という脅威の世界を深く掘り下げていきます。 具体的には、ハッカーがどれほど簡単にあなたが書いたコードを欺き、ユーザーのデータを盗み出すことができるのか、以下のような攻撃手法を見ていきます。 ユーザー列挙(User Enumeration):ログインページを悪用し、候補となるユーザー名のリストを総当たりで試して、データベース内にそのユーザーが存在するかどうかを確認する攻撃。 弱いパスワード

  2. 【後編】AppSignalでRuby on Railsアプリのパフォーマンス・ログ・アップタイムを効率的に監視する

    はじめに 本記事シリーズの前編では、シンプルなRuby on RailsアプリケーションをDigitalOceanのApp Platformにデプロイし、AppSignalと連携させることで、エラーがどのようにトラッキングされ、Errorsダッシュボードに表示されるかを確認しました。 後編となる本記事では、AppSignalを使ってRuby on Railsアプリケーションに以下の機能をセットアップする方法を詳しく解説します。 パフォーマンス監視 Railsのバックグラウンドジョブ監視(シンプルなAPI呼び出しの監視方法も含む) ロギング 通知アラート それでは始めましょう! AppSig