torutkのブログ

ソフトウェア・エンジニアのブログ

.NET Frameworkのメモリ管理と断片化問題(.NETアプリケーションを長期連続実行するのは要注意)

C#Javaの言語選定にあたり、実行環境の比較をするため、ガベージコレクタについて調べていました。

.NET Frameworkガベージコレクション方式は世代別GCですが、Javaとは随分異なっています。特に顕著に異なっているのがLOH(Large Object Heap)と呼ばれる大きなサイズのオブジェクトを格納する専用ヒープ領域がある点です。現在のバージョンでは、85KB以上のサイズのオブジェクトは世代別管理のヒープ(generation 0)ではなく、このLOHに割り当てられます。

この仕組みについては、MSDNマガジン(オンライン)の記事に詳しくあります(以下URL)。

LOHは、第2世代(Javaで言えばOld世代)のGCと同じタイミングでGCがかかります。LOHでは、オブジェクトか回収された後、コンパクションを実施しないため、LOHは断片化(フラグメント)が発生します。
また、断片化したLOHに対して新たなオブジェクトを割り当てるとき、断片化した中ではなく、LOHのおしりに追加するように割り当てる動きをするので、LOHが肥大化する要因があるようです。

英語ですが、以下URLにLOHの断片化や肥大化が生じるメカニズムについて図解、サンプルコードとともに紹介されています。

断片化が起きると、懸念すべき問題が発生します。それは、メモリに空き領域があっても有効に使えず、メモリ不足になるということです。

断片化の状況を調査する方法が、同じくMSDNマガジン(オンライン)の記事に掲載されています(以下URL)。

デスクトップアプリケーションでは

一般的なデスクトップアプリケーションは、通常大きなオブジェクト(単独で85KB以上)は扱わないだろう、また、支障をきたすほど断片化が進行する前に大抵プロセスを終了するだろう、の点から、この問題によりメモリ不足でアボートすることは稀だと考えられます。

ただし、通信やファイルI/Oを頻繁に伴う場合は、LOHの使用頻度が多くなると予想されます。

サーバーアプリケーションでは

サーバーアプリケーションは、通常連続稼動しているので、断片化の進行による支障がでる可能性があります。とくに、大きなデータの通信やI/Oを行う場合、この問題について何らかの対処を行う必要があります。

対処といっても、定期的にプロセスを再起動するとか、もしC#ガベージコレクションされない領域を使う機能があれば(アンマネージメモリ? よく分からないけれど)、サイズの大きな(典型的には通信データを入れるためのbyte配列等)データは、アンマネージメモリ(?)に確保するとか、でしょうか。

ミッション・クリティカル・システムでは

24時間365日連続稼動し、システム停止が致命的な世界では、ちょっと大変でしょうね。

以下URLに、ミッション・クリティカルなWindowsサービスでLOHのフラグメント問題にどう対処すればよいかの議論があります。根本的な解決策は出ていませんが。

Pinning(メモリ配置の固定化)

たとえばC#のfixedでアロケートされたメモリは、マネージドヒープ中にGCによりコンパクションできない領域として存在します。これもメモリ断片化の要因となるので、fixedでアロケートしたメモリは可及的速やかに解放するか、85KB以上の大きなバッファとしてアロケートしLOHに配置されるようにする、といった考慮が必要になります。

(参考).NET 4.0での改善?

上記URLの一連の投稿に、Microsoft.NET Framework ガベージコレクタ担当マネージャのコメントがあります。大まかな内容は以下です。

.NET 4のガベージコレクタではLOHの問題に対処をしいる。報告のあったケースでは、LOHの断片化に対して.NET 4.0では.NET 3.5に対して23倍の改善が現れている。ただし、これは断片化の最終解決ではないので、この問題には継続して取り組む。但し、.NET 4のリリースにおいてはレイテンシーを優先している。

実験プログラムでは、.NET 2.0では断片化が進んだ状況では、LOHが1.8GBまで拡張してOutOfMemoryとなったが実際に使用しているLOHのメモリは僅か26MBという悲惨な状況でしたが、同実験プログラムでは.NET 4.0では600MB位となりました。

推定ですが、改善前のLOHは断片化したとき、フリーリストの途中を探してアロケートするのではなく、最後尾にどんどん追加してアロケートするという高速だがメモリ使用効率が極めて低い方法を取っていたが、.NET 4.0ではフリーリストをまじめに探索して空きメモリがあればアロケートするという方法に変えたのではないかと思います。

(参考)Javaでは?

Javaではこのようなメモリの断片化が発生するのか調べてみました。

SunのHotSpot JVMでは、複数のガベージコレクションを選択して使用することができますが、この中で、Concurrent Mark and Sweep を使用すると、旧世代についてコンパクションを行われません行いますが、シングルスレッドで行うので停止時間が長くなります。そのほかは、コンパクションを行っています。
なお、Concurrent Mark and Sweepは、スループットよりもレイテンシを重視したGC方式で、ユーザーインタフェースやソフトリアルタイムなアプリケーション向きです。デフォルトでは選択されませんが、連続稼動するサーバーアプリケーションには不向きです。名前につられて(ConcurrentだからCPUがたくさんあるサーバーにはよいかも? なんて安易に判断してはならない)使ったりしないように注意します。

SunのHotSpot JVMGCでは、ConcurrentとParallelを以下のように使い分けしています。

  • Concurrentは、アプリケーションスレッドとGCスレッドが同時並行で進む(GCを複数スレッドで行うかどうかではない)、すなわちStop-the-worldでない
  • Parallelは、GC処理を複数スレッドで同時並行で行う、このときアプリケーションスレッドは停止するので、Stop-the-world

なお、ConcurrentでParallelなものがあるかもしれませんが、未確認です。