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

Rubyをマスターする:メモリリークを効率的に検出・修正する方法

メモリリークとは、意図せず制御不能に、際限なくメモリ使用量が増え続けてしまう現象です。どれほど小さなリークであっても、最終的にはプロセスがメモリを使い果たしてクラッシュを引き起こします。クラッシュを避けるためにアプリを定期的に再起動している場合でも(責めるつもりはありません、筆者もやったことがあります!)、メモリリークによるパフォーマンスへの悪影響は残ったままです。

この記事は、メモリリークをテーマにした2部構成シリーズの第1回目です。まずRubyがどのようにメモリを管理しているのか、ガベージコレクション(GC)がどのように動作するのか、そしてリークを見つける方法について見ていきます。

第2回では、リークの追跡方法をさらに深く掘り下げます。

それでは始めましょう!

Rubyのメモリ管理

Rubyのオブジェクトはヒープ上に格納され、各オブジェクトはヒープ上の1つのスロットを占有します。

Ruby 3.1以前では、ヒープ上のすべてのスロットは同じサイズ(正確には40バイト)でした。スロットに収まりきらない大きさのオブジェクトはヒープの外側に保存され、各スロットには移動先への参照が含まれていました。

Ruby 3.1では、Stringオブジェクトに対する可変幅アロケーション(variable width allocation)が導入されました。今後は、すべてのオブジェクトタイプに対して可変幅アロケーションが標準になっていく予定です。

可変幅アロケーションは、キャッシュの局所性(cache locality)を高めることでパフォーマンス改善を目指しています。つまり、オブジェクトの情報が2つのメモリ領域に分散されるのではなく、1つの場所にまとめて格納されるようになります。

また、メモリ管理の一部も簡素化されるはずです。現時点では、2種類の「ヒープ」が存在します:

  • 小さなRubyオブジェクトを格納するRubyヒープ(GCヒープ)
  • 大きなオブジェクトを格納するCヒープ(malloc/トランジェントヒープ)

可変幅アロケーションが標準化されれば、後者のヒープは不要になるはずです。

ヒープは指定された初期サイズ(デフォルトで10,000スロット)で開始され、オブジェクトが作成されると空きスロットへ割り当てられていきます。Rubyがオブジェクトを作成しようとしたときに空きスロットがない場合、ガベージコレクション(GC)が発生して空きスロットを確保します。

GC後も空きスロットが少なすぎる場合は、ヒープが拡張されます(詳細は後述します)。

以下は、環境変数とともに制御できる主な要素です:

  • ヒープの初期サイズ - RUBY_GC_HEAP_INIT_SLOTS
  • GC実行後に確保しておくべき空きスロット数 - RUBY_GC_HEAP_FREE_SLOTS
  • ヒープの拡張率 - RUBY_GC_HEAP_GROWTH_FACTOR

Rubyにおけるガベージコレクション

Rubyのガベージコレクションは「ストップ・ザ・ワールド(stop the world)」方式です。GCが実行されている間、他の処理は一切行われません。また、Ruby 2.1以降のGCは世代別(generational)であり、ガベージコレクタには2つのモードがあります:

  • マイナーGC - 「若い」オブジェクト(最近作成されたオブジェクト)を検査します
  • メジャーGC - 「古い」オブジェクトと「若い」オブジェクトの両方、つまりすべてのオブジェクトを検査します

注:「古い」オブジェクトとは、メジャーかマイナーかを問わず、3回のGCを生き延びたオブジェクトのことです。

ヒープがいっぱいになると、まずマイナーGCが呼び出されます。それでも必要な空きスロットを確保できない場合、メジャーGCが呼び出されます。それでもまだ足りない場合にのみ、ヒープが拡張されます。

メジャーGCは、より多くのオブジェクトを検査するため、マイナーGCよりもコストが高くなります。

世代別GCが高性能だとされる理論的背景は、オブジェクトは通常、次の2つのカテゴリに分類されるからです:

  1. 割り当て後、すぐにスコープ外になるオブジェクト。Railsアプリでは、ページ描画のためにDBから取得したモデルは、リクエスト終了とともにスコープ外になります。
  2. 割り当て後、長期間保持され続けるオブジェクト。クラスやキャッシュは、アプリのライフサイクル全体を通じて使い続けられる可能性が高いでしょう。

また、空きスロットが十分にあっても、古いオブジェクトの数が一定のしきい値を超えると、マイナーGCの後にメジャーGCが実行されます。このしきい値はヒープの拡大に伴って増加し、RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR環境変数で制御できます。

メモリリークが発生すると、クリーンアップできないオブジェクト、つまり古い(old)オブジェクトがどんどん蓄積していきます。その結果、コストの高いメジャーGCが必要以上に頻繁に実行されることになります。GCの実行中は他の処理が一切行われないため、これは無駄に費やされる時間だと言えます。

Rubyのメモリレイアウトやガベージコレクタについてさらに学びたい方は、記事末尾の参考リンクをご覧ください。

Rubyにおけるメモリリークの見分け方

Unix系システムであればどこでも使えるシンプルなツールで、メモリリークを確認できます。次のコードを例にしてみましょう。


このコードを「リークしている」と表現するのは少し不公平かもしれません。何しろ、していることがリークだけですから!しかし、検証目的には十分役立ちます。

このリークは、コマンドラインから非常に簡単に観察できます。1つのターミナルでプログラムを実行しながら、別のターミナルでwatchpsを組み合わせ、時間の経過とともにメモリが増加していく様子を監視するのです。


pgrep -f "ruby ./leaky.rb"は対象プロセスのプロセスIDを特定してくれるため、psの出力を関心のあるプロセスだけに絞り込めます。お察しの通り、これはいわばプロセス版のgrepです。

watchツールを使うと、指定したコマンドの出力を定期的にポーリングして画面内で更新できるため、ターミナル内にライブダッシュボードのような環境を作れます。

実行すると、数秒ごとに更新される次のような出力が得られます。


%MEMRSSが増加していくのが確認できるはずです。それぞれ以下を意味します:

  • %MEM - プロセスが使用するメモリの、ホストマシンの全メモリに占める割合(%)
  • RSS(resident set size:常駐セットサイズ)- プロセスが使用するRAMの量(バイト単位)

このOSレベルの基本的な情報だけで、リークの有無を判断できます。メモリ使用量が増え続けているなら、それはリークが存在する証拠です!

GCモジュールでRubyのリークを検出する

Rubyコードの中からも、GCモジュールを使ってリークを検出できます。


GC.statメソッドは、多くの有用な情報を含むハッシュを返します。ここで注目したいのは:heap_live_slots、つまり使用中のヒープスロット数です。これは:heap_free_slots(空きスロット数)の対となる指標です。ループの終了時に強制的にメジャーGCを実行し、使用中のスロット数、すなわちGC後に残存するオブジェクトの数を出力しています。

この小さなプログラムを実行すると、数値が無限に増え続けるのがわかります。リークが発生しているのです!同様の効果はGC.stat(:old_objects)を使っても得られます。

GCモジュールは、リークがあるかどうかの確認や(puts文を工夫すれば)リーク発生箇所の絞り込みに役立ちますが、ObjectSpaceモジュールを使えば、リークしている可能性のあるオブジェクトの種類まで把握できます。


ObjectSpace.count_objectsメソッドは、生存中のオブジェクトの数を含むハッシュを返します。たとえばT_STRINGは、メモリ上に存在する文字列オブジェクトの数です。このリークだらけのサンプルプログラムでは、GC後であってもループのたびにこの値が増加していきます。つまり、文字列オブジェクトがリークしていることがわかるのです。

AppSignalによる本番環境でのアプリケーションパフォーマンス監視

psGCを使った調査は、お試しプロジェクトには理にかなったアプローチですし(楽しくて勉強にもなります!)、しかし本番アプリのメモリリーク検出ソリューションとして推奨はしません

そこで活躍するのが、アプリケーションパフォーマンス監視(APM)ツールです。大規模企業であれば自社開発も可能ですが、中小規模の組織なら既製のAPMを採用するのが賢明です。月額サブスクリプションの費用はかかりますが、得られる情報の価値はコストを十分に上回ります。

メモリリークを検出するには、サーバーまたはプロセスのメモリ使用量(RSSと呼ばれることもあります)の推移グラフを確認します。以下は、AppSignalの「プロセスメモリ使用量」ダッシュボードに表示された、デプロイ直後の健全なアプリのスクリーンショット例です:

Rubyをマスターする:メモリリークを効率的に検出・修正する方法

そしてこちらは、デプロイ後の不健全なアプリの例です:

Rubyをマスターする:メモリリークを効率的に検出・修正する方法

AppSignalは、GCやヒープスロットといったRuby VMの統計情報まで表示してくれるため、メモリリークのより明確なシグナルを捉えられます。生存スロットの数が増え続けているなら、それはリークの兆候です!

Rubyをマスターする:メモリリークを効率的に検出・修正する方法

AppSignal for Rubyの詳細については、公式ドキュメントをご覧ください。

まとめと参考文献

この記事では、Rubyのメモリ管理とガベージコレクタの仕組みを概説しました。続いて、UnixツールとRubyのGCモジュールを使ってメモリリークを発見する方法を紹介しました。

次回は、memory_profilerderailed_benchmarksを使ってリークを特定し、修正する方法を見ていきます。

それまでの間に、今回使用したツールについて詳しく知りたい方は、以下をご覧ください:

  • watch
  • ps
  • pgrep

その他のおすすめの参考文献:

  • GCモジュールドキュメント
  • ObjectSpaceモジュールドキュメント
  • Garbage Collection Deep Dive(ガベージコレクション徹底解説)
  • Variable Width Allocation(可変幅アロケーション)

それでは、Happy Coding!次回お会いしましょう!

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

  1. RubyによるWeb開発:堅牢なWebサイトを構築するための実績ある選択肢

    Web開発に興味があるなら、まず最初に取り組むべきはプログラミング言語の選択です。HTMLやCSSだけでは不十分で、Webサイトのさまざまな機能を実現するためには、プログラミング言語が必要になります。その選択肢のひとつが「Ruby」によるWeb開発です。 Rubyは1995年に登場した比較的新しいプログラミング言語です。この言語の目的は、プログラミングを実用的なものにしながら、より自然に書けるようにすることにあります。 Rubyには他の言語とは異なる特徴が数多くあり、初心者にとっては他のプログラミング言語よりもコードを書きやすく、失敗しにくいという魅力があります。人間が話す言葉に近い形でコード

  2. TrixとTurbo Framesを使ってRailsで動的テーブルエディタを作成する方法

    この記事では、Railsアプリケーション向けに基本的なActionTextテーブルエディタを実装する方法を解説します。以下の内容を学ぶことができます。 ActionTextとTrixがアタッチメントをどのように扱うのか 独自のAttachable型を実装し、それを活用して基本的なテーブルエディタを構築する方法 Turbo Framesを使ってテーブルを編集する方法 Turboが役に立つ一方で、同時に障害にもなり得るという事実 本記事は、2020年の優れたブログ記事「Adding Tables to ActionText With Stimulus.js」にインスピレーションを得ています。た