CLR 徹底解剖

.NET ガベージ コレクション ヒープをプロファイリングする

S. RamaswamyV. Morrison

2009 年 6 月号の MSDN Magazine の記事「.NET アプリケーションのメモリ使用量の監査」では、タスク マネージャー、PerfMon、VADump などのツールを使用してメモリの使用量を監視する方法について説明しました。これらのツールは、アプリケーションの全体的なメモリ使用量を監視するのに役立ちます。通常、.NET アプリケーションで大量のメモリを消費している場合、その原因は、アプリケーションで多数の DLL を読み込んでいるか、アプリケーションによって .NET ガベージ コレクション (GC) ヒープに多数の長続きするオブジェクトが作成されているかのどちらかです。アプリケーションで多数の DLL が読み込まれている場合、この状況を回避する唯一の方法は実行するコード量の削減です (ただし、不要な依存関係をなくすことで状況を改善することはできます)。一方、アプリケーションによる GC ヒープの使用量が多い場合は、6 月号の記事で説明したように GC ヒープがメモリ使用量に大きく影響している可能性があります。この記事では、.NET アプリケーションによる GC ヒープ メモリ使用量の調査に CLR Profiler を使用する詳細な手順について説明し、GC ヒープに関するメモリの問題の詳細を紹介します。

要約: すべてのアプリケーションを監査する

.NET ランタイムでは、特定の操作 (メモリの割り当て、大きなクラス ライブラリの使用など) を抽象化するので、このような操作がアプリケーションのパフォーマンスに与える影響を予測するのは簡単なことではありません。このことは、特にメモリに当てはまります。前回の記事の要点は、すべてのアプリケーションについてメモリの使用量を定期的に監査すべきだということでした。この記事では、デモンストレーションではなく、サンプルに実際のプログラムを使用して、この手順を詳しく説明します。単純なプログラムなので、メモリ使用量を監査する必要はなかったかもしれません。ですが、メモリ監査を行った結果、メモリが非効率的に使用されていたことを発見できたので、監査を実施した甲斐がありました。

この記事では XMLView というプログラムを使用します。これは、XML ファイルの簡易ビューアーですが、高いスケーラビリティを備えています。XMLView は、メモリの使用効率を考慮してデザインされているので、数 GB ものサイズの XML ファイルも難なく処理できます (このようなファイルを IE で開いてみて、どうなるか確認してください)。入念にデザインされているにもかかわらず、タスク マネージャーなどのツールを使用すると、一部のシナリオでは、このアプリケーションのメモリ使用量は、私たちが予想していたよりも、はるかに多くなっていることが判明しました。この記事では、パフォーマンス上の問題を特定した調査方法を紹介します。

大局的見地から見たメモリ使用量

メモリ使用量を監査するには、まず、アプリケーションを起動して、OS のタスク マネージャーで、アプリケーションの全体的なメモリ使用量を確認する必要があります。前回の記事で説明したように、最も重要なメトリックは "メモリ - プライベート ワーキング セット" 列の値です。この値は、コンピューターで実行中の他のプロセスと共有できないアプリケーション メモリです (詳細については、前回の記事を参照してください)。

タスク マネージャーを起動すると、アプリケーションのプライベート メモリ使用量をリアルタイムで監視し、さまざまなユーザー シナリオを実施して、メモリ使用量に影響があるかどうかを確認できます。このとき、注意しなければならない状態が 2 つあります。1 つは実行中のシナリオやタスクに対して、メモリの使用量が不相応な場合です。たとえば、メニューをクリックしたり、単純な操作を実行したりするような操作では、メモリ使用量に大きな変化が見られるべきではありません。もう 1 つはメモリ リークです。新しいファイルを開くなど、一部の操作においては、操作を実行するたびに新しいデータ構造が作成されるので、メモリの使用量が多くなることが予想されます。ただし、検索など、多くの操作では、論理的に長続きするメモリを新たに割り当てる必要はありません。通常、このような状態を持たない操作では、実行時にメモリを割り当てることは珍しくありません (レイジー データ構造にデータが設定されます)。シナリオで操作を繰り返すことでメモリの使用量が増加し続ける場合は、メモリ リークについて調査を行う必要があります。タスク マネージャーを使用したメモリ リークの調査では、顕著なメモリの問題しか特定できないので、大まかな調査になります。ですが、多くのアプリケーションにとって追跡する価値があるのは、顕著なメモリの問題だけでしょう。

XMLView の場合、シナリオは、XMLView でサイズの大きな XML ファイルを開いて、さまざまなナビゲーション操作 (ツリー ノードの展開、検索など) を実行することです。当初、このシナリオでは、メモリの使用量が一定で、メモリ リークは特定されませんでした (図 1 参照)。ですが、検索機能を使用すると、メモリの使用量に比較的大きな変化が見られ、プライベート ワーキング セットは 16,812 KB から 25,648 KB まで (約 8 MB) 増加しました (図 2 参照)。これは、注意しなければならない状態の 1 つです。検索には付随するデータ構造はなく、追加のメモリを消費する必要がないので、リソースの使用量は、プログラムのメモリ動作に不相応でした。これは解決しなければならない不可解な問題でした。


図 1 検索操作を実行する前の XMLView のメモリ使用量 (タスク マネージャー)


図 2 検索操作を実行した後の XMLView のメモリ使用量 (タスク マネージャー)

CLR Profiler

.NET の GC ヒープを分析するサードパーティ製のツールは多数ありますが、この記事では、マイクロソフトが無料で提供している CLR Profiler を使用することにしました (このツールは Web からダウンロードして、ご利用いただけます)。Web で「CLR Profiler」を検索すると、ダウンロード サイトにアクセスできます。また、このツールは、ファイルを解凍するだけでインストールできます (一般的なインストール手順を実行する必要はありません)。パッケージには、このツールの使い方を記載した CLR Profiler.doc が含まれています。この記事では、一般的に使用する CLR Profiler の機能を取り上げます。

CLR Profiler のしくみについて

CLR Profiler を適切に使用するには、このツールの制限事項とデータがどのように収集されるのかを理解する必要があります。CLR Profiler では、ランタイムで特定のイベントが発生したときにコールバックを受け取れるランタイムへの特殊なインターフェイスを使用しています (詳細については、msdn.microsoft.com/ja-jp/library/ms404511.aspx を参照してください)。現在、このインターフェイスは、プロファイリング対象のプロセスが、ランタイムに対して CLR Profiler への接続メカニズムを通知する環境変数を設定した状態で開始された場合にのみ使用できます。そのため、既存のプロセスには使えないなどの制限事項があります。CLR Profiler でプロファイリングするには、適切な環境が設定された状態でプロセスを開始する必要があります。通常 OS によって直接開始されるサービス (ASP.NET など) をプロファイリングするには、特殊な手順を実行する必要があります (詳細については、ダウンロード パッケージに含まれている CLR Profiler.doc を参照してください)。余談ですが、一部のシナリオのために、.NET Framework 4 ではアタッチに API のサポートを追加しました (追加したのは API のサポートのみで、CLR Profiler ツールのアタッチについてのサポートについては今後対応します)。

CLR Profiler でアプリケーションを起動するときには、このツールで収集する情報の種類についてのオプションが提示されます。既定では、マネージ メソッドを呼び出したり、呼び出しから制御が戻ったり、GC ヒープの割り当てが発生したりするたびにコールバックが行われます。多くのシナリオでは、このようなコールバックは頻繁に行われるので、プログラムの処理速度が大幅に低下し、100 MB を超える大きなログ ファイルが作成されます。CLR Profiler では、実行時間は計測しません。また、GC ヒープの割り当てと各メソッドが呼び出された回数のみを追跡するように制限できます。CLR Profiler では、method-call イベントと method-return イベントを使用して、スタックで現在アクティブなメソッドを追跡し、各メソッドの呼び出しとヒープの割り当ては、イベントが発生する完全な呼び出しスタックに関連付けられます。これにより、オブジェクトが作成された場所を特定できます。

残念ながら、CLR Profiler の既定の設定を使用すると、詳細な情報が収集されるので、処理には数分もの時間がかかります。ただし、詳細な情報が必要になることはめったにありません。また、最も有効な技法には、通常の実行時に追加の情報を収集する必要がないものもあります。メモリ割り当てが発生した場所を特定するのは重要ですが、多くの問題は、メモリを保持しているものを特定すれば解決できます。メモリを保持しているものを特定するために、コール スタックや割り当てイベントに関する情報は必要ありません。この場合に必要なのは、GC ヒープのスナップショットだけです。そのため、多くの調査においては、通常のイベントのログ記録は無効にして、パフォーマンスを尊重できます (プロファイリング時に適度なパフォーマンスを維持しながら、問題を特定できます)。次のセクションでは、この調査方法について説明します。

CLR Profiler を使用する

Web から CLR Profiler をダウンロードして、既定の場所に展開すると、プログラムは C:\CLR Profiler\Binaries\x86\ClrProfiler.exe にあります (CLR Profiler には x64 版もあります)。CLR Profiler を起動すると、図 3 のような画面が表示されます。


図 3 CLR Profiler 起動時の画面

この起動時の画面では、収集するプロファイル データを制御します。既定の設定では、アプリケーションの実行時にプロファイリングが有効になり、割り当てと呼び出しのデータが収集されます (この設定では、ログ ファイルに詳細な情報が記録されます)。ここで必要なのはヒープのスナップショットだけなので、[Profiling active] チェック ボックスのみをオンにします ([Allocations] チェック ボックスと [Calls] チェック ボックスはオフにします)。CLR Profiler では、プログラムの実行時に最低限の情報のみをログに記録し、応答性を維持して、ログ ファイルのサイズを抑えます。

プロファイリング対象のアプリケーションの実行時に、コマンド ライン引数や特殊なスタートアップ ディレクトリが必要でなければ、[Start Application] をクリックして [ファイルを開く] ダイアログ ボックスを表示して、実行するアプリケーションを選択します。コマンド ライン引数やカレント ディレクトリを設定する必要がある場合は、[File] メニューの [Set Parameters] をクリックして必要な情報を設定してから、[Start Application] をクリックします。

ヒープのスナップショットをキャプチャする

アプリケーションの実行可能ファイルを選択すると、CLR Profiler によって、すぐにアプリケーションが起動されます。割り当てと呼び出しのプロファイリングは無効にしたので、アプリケーションは、通常と同じくらいの処理速度で実行されます。CLR Profiler はアプリケーションにアタッチされているので、[Show Heap now] をクリックすると、いつでもヒープのスナップショットをキャプチャできます。XMLView アプリケーションのヒープのスナップショットは、図 4 のようになりました。

これは GC ヒープ全体を表しています (アクティブなオブジェクトのみが表示されています)。実際、GC ヒープは特定の規則に従っていないグラフですが、CLR Profiler によってリンクが特定され、ツリー構造が形成されています (ルートは root という擬似ノードです)。各子ノードのサイズは、親ノードに属すると見なされ、ルートでは、すべてのアクティブなオブジェクトのサイズが計上されるようになっています。この例では、GC ヒープに 5.4 MB のオブジェクトがあることがわかりました。この 5.4 MB には、未割り当ての GC ヒープの空き領域は含まれていません。そのため、ヒープに割り当てられている仮想メモリの量は、これより確実に大きくなります (厳密なサイズについては一概には言えませんが、前回の記事で説明したように、約 1.6 倍と考えるのが妥当です)。

ルートから特定のオブジェクトまでの経路は 1 つとは限りません。CLR Profiler では、ツリーの表示に最短パスを使用しています。ただし、同じ長さのパスが複数ある場合、表示されるパスは、その都度異なります。また、既定で、CLR Profiler では、個別のオブジェクトは表示せず、同じ種類の親と子を共有しているオブジェクトは同じノードに属していると見なします。通常、この技法は、リンクされたノードや再帰的なデータ構造を打破して、ビューをシンプルな状態にするのに役立ちます。このようなデータの集約が必要でない場合は、ノードを右クリックし、[Show Individual Instances] をクリックして、この機能を無効にできます。

[Heap Graph] ウィンドウの上部には 2 つのグループ ボックスがあります。1 つ目は Scale です。ここでは、グラフ ビューの各ボックスの高さを指定します。2 つ目は Details です。ここでは、表示するノードの大きさを指定します。この設定では少し大きな値を指定して、グラフを見やすい状態に保つことをお勧めします。特定の種類のオブジェクトのみに関する情報のグラフが必要な場合もあります (たとえば、XmlPositions という種類のオブジェクトのみについてのグラフが必要な場合があります)。CLR Profiler では、条件を指定して、情報をフィルター選択できます (これには、グラフを右クリックして、[Filter] をクリックします)。

図 4 の XMLView の例では、GC ヒープ (5.1 MB) の大部分は、XmlView という種類のインスタンスの子です。XmlView インスタンスには、XmlPositions という種類の子があり、この子には 5 MB の XmlElementPoritionInfo 配列があります。XmlElementPositionInfo は、各 XML タグのファイル内の開始位置を追跡する大きな配列で、そのサイズは読み取っている XML ファイルのサイズに比例します (ここでは非常に大きな XML ファイルを使用しているので、この配列のサイズは大きくなることが予想されます)。そのため、ヒープのサイズは適度なものだと判断しました。


図 4 検索操作を実行する前のスナップショット (CLR Profiler)

GC ヒープの成長を追跡する

XMLView の例では、問題は GC ヒープの初期サイズではなく、検索を実行したときに、そのサイズが大幅に増えることにありました。そのため、2 つのヒープのスナップショットの変化に着目しました。CLR Profiler には、このような調査を行うための特別な機能が用意されています。

ヒープのスナップショットをキャプチャしたら、(追加のメモリが割り当てられるように) 検索操作を実行して、再度ヒープのスナップショットをキャプチャしました。その結果を、次の図に示します。

XmlView オブジェクトと関連付けられているメモリは、5.1 MB から 8.5 MB に増加しました。ノードは 2 色で構成されています。薄い赤は、1 つ目のスナップショットをキャプチャしたときに割り当てられていたメモリを表しています。濃い赤は、2 つ目のスナップショットをキャプチャしたときに新たに割り当てられたメモリを表しています。このスナップショットから、XmlFind という種類によって新たにメモリが割り当てられていたことが判明しました (図 5 参照)。右方向にスクロールすると、この新たに割り当てられたすべてのメモリは、LineStream という種類に属する Int64 配列に関連付けられていることがわかりました (図 6 参照)。

これが問題の特定に必要な情報でした。.NET の XML パーサーには、XML タグの行番号と列番号を取得する機能はありますが、ファイルの位置を取得する機能はありません。この問題を解決するには、プログラムでは、行番号からファイル位置へのマッピングが必要です。Int64 配列は、この用途に使用しています。ただし、このマッピングを必要としているコードは、現在の XML 要素タグのみです。このタグは複数行にまたがっている場合もありますが、通常は数行です。このマッピングは、検索コードでリセットされないので、マッピングの配列は不必要に増大していました。この問題は、簡単に修正できましたが、メモリの使用量を監査しなければ解決されることはなかったでしょう。CLR Profiler を使用すると、この問題を修正するのに必要な情報をたった数分で入手できました。


図 5 検索操作を実行した後のスナップショット 1 (CLR Profiler)


図 6 検索操作を実行した後のスナップショット 2 (CLR Profiler)

新しいオブジェクトだけを表示する

上記の例では、最新のスナップショットに固有のオブジェクトを色で簡単に見分けることができました。ただし、大きなオブジェクトのグラフで同じことをするのは困難で、グラフから古いオブジェクト自体を削除することをお勧めします。この処理は CLR Profiler を使用して行えます。この機能では、割り当てのイベントを使用しています。1 つ目のスナップショットをキャプチャしたら、[Allocations] チェック ボックスをオンにする必要があります ([Calls] チェック ボックスの設定は、変更する必要はないので、オフのままにしておきます)。その後、より多くのメモリが割り当てられるようになるまでアプリケーションで操作を実行し、[Show Heap now] を再度クリックします。先ほどと同じように 2 つの赤色を使ったヒープが表示されます。ウィンドウを右クリックし、[Show New Objects] をクリックすると、新しいウィンドウが表示され、1 つ目のスナップショットをキャプチャしてから 2 つ目のスナップショットをキャプチャするまでに割り当てられた GC ヒープのみが表示されます。

オブジェクトのすべてのルートを追跡する

ガベージ コレクターによって不要なメモリがクリーン アップされることを考慮すると、マネージ コードでメモリ リークがどのように発生するのかを不思議に思う方がいらっしゃるかもしれません。ただし、メモリが解放されていると思っていたアイテムが、ずっと後までアクティブになっている場合があります。たとえば、意図していない参照によって、マネージ オブジェクトのアクティブな状態が維持されることがあります。

メモリ リークを追跡する技法は、不相応なメモリ消費を理解する方法とよく似ています。実質的な違いは、不相応なメモリ消費の場合には、オブジェクトが (そのサイズは大きくないにせよ) メモリ内に存在することを予想していますが、メモリ リークの場合は、GC ヒープにオブジェクトが存在していることは予想していません。ヒープのスナップショットと Show New Object 機能を使用すると、どのオブジェクトが不要にアクティブな状態になっているのかを簡単に特定できます (これには、シナリオを理解している必要があります)。オブジェクトがヒープ内に存在する場合、そのオブジェクトへの参照が存在しているということになります。ただし、複数箇所で参照されている場合、1 つのリンクを削除しても、他の箇所で参照されているため問題は解決されません。そのため、GC ヒープで特定のオブジェクトをアクティブにしているルートからのパスをすべて特定することをお勧めします。CLR Profiler では、この処理を行うための特別なサポートを提供しています。まず、調査対象のノードを選択します。ノードを右クリックし、[Show References] をクリックすると、ルートから問題のオブジェクトまでの全パスを示す新しいグラフが表示されます。このグラフでは、GC ヒープでオブジェクトをアクティブな状態にしているリファレンスを特定するのに必要な情報を提供します。オブジェクトへのリンクを削除するとメモリ リークの問題を解決できます。

割り当てのコール スタックを取得する

多くの場合、GC ヒープを参照すれば、メモリの使用量が多いという問題を解決できます。ただし、割り当てが発生したコール スタックを特定することが問題解決に役立つ場合もあります。この情報を取得するには、プログラムを実行する前に [Allocations] チェック ボックスをオンにする必要があります。この設定を使用すると、CLR Profiler では、割り当てが発生するたびにスタック トレースを記録します。この情報を収集するとログ ファイルのサイズは大きくなりますが、それほど複雑でないアプリケーションであれば、一般的にファイルのサイズは 50 MB 以下です。[Calls] チェック ボックスをオンにすることはお勧めしません。このチェック ボックスをオンにすると、すべてのメソッド エントリが記録されるため、ログが冗長になります (ログ ファイルのサイズが 100 MB を超えることも珍しくありません)。このメソッド呼び出しの情報は、CLR Profiler を使用して、頻繁に呼び出されるメソッドを特定する場合にのみ必要で、メモリの調査では、あまり有益な情報ではありません。

ヒープのグラフで調査対象のノードを右クリックして、[Show Who Allocated] をクリックします。このノードにオブジェクトを割り当てた全コール スタックを示す新しいウィンドウが表示されます。[View] メニューの [Allocation Graph] をクリックして、スタック トレースを表示することもできます。コール スタックに関連付けられているプログラムの実行中に行われた割り当てが表示されます。メモリの調査では、このビューは、それほど有益ではありません。というのも、このような割り当ては、長続きしないオブジェクトについて行われたもので、GC によってすぐ再要求されるので、全体的なメモリ使用量には影響しないからです。ただし、このような割り当ては CPU の使用率に影響するので、アプリケーションで CPU に関する問題が発生していて、CPU プロファイラーにより、CPU が GC ヒープで長期に渡って使用されているという結果が出ている場合、このビューは役立ちます。GC ヒープで CPU を使用する時間を短縮するには割り当てを減らすのが明白な方法です。このビューではコール スタックごとに割り当てが表示されます。

開発ライフサイクルに導入する

この記事では、.NET の GC ヒープによるメモリ使用量が多いという一般的なメモリのパフォーマンスを調査する方法について説明しました。無料でダウンロードできる CLR Profiler を使用すると、アプリケーションを実行中に GC ヒープのスナップショットを随時キャプチャして、キャプチャしたスナップショットを比較できました。CLR Profiler を使用すると、簡単に監査を実施できるので (実施に必要な時間は数分です)、すべてのアプリケーションの開発ライフサイクルで導入することをお勧めします。

Subramanian Ramaswamy は、マイクロソフトの CLR Performance のプログラム マネージャーです。彼は、ジョージア工科大学で電気およびコンピュータ エンジニアリングの博士号を取得しています。

Vance Morrison は、マイクロソフトの CLR Performance でパートナー アーキテクトとグループ マネージャーを兼任しています。彼は .NET 中間言語の設計を推進したほか、開発当初から .NET に携わっています。