メモリ レーン

忘れ去られた技術の再発見: マネージ コードのメモリ最適化

Erik Brown


翻訳元: Memory Lane: Rediscover the Lost Art of Memory Optimization in Your Managed Code (英語)

この記事で使用する技術: .NET Framework, C#

サンプルコードのダウンロード: MemoryOptimization.exe (英語) (136KB)

この記事で取り上げる話題:

  • オブジェクト型がメモリ使用量に与える影響
  • オブジェクト プーリングがガベージ コレクションに与える影響
  • 大量なデータにアクセスする際のデータ ストリーミング
  • メモリ使用率の分析

目次

  1. データ型のサイジング
  2. シングルトン
  3. プーリング
  4. データ ストリーミング
  5. パフォーマンス モニタ
  6. CLR Profiler
  7. まとめ

メモリはあらゆるアプリケーションから必要とされるリソースです。それにもかかわらず、メモリの高度な利用法は忘れ去られた技術となりつつあります。Microsoft .NET Framework 向けに作成されたマネージ アプリケーションは、メモリの割り当てとクリーンアップをガベージ コレクタに依存しています。 CPU 時間の 3 ~ 5% がガベージ コレクション (GC) によって使用されますが、メモリ管理について考慮する必要がなくなることを考えると、多くのアプリケーションにとってこれは適正なトレード オフと言えます。

ただし、CPU 時間とメモリが貴重なリソースとなるアプリケーションでは、ガベージ コレクションが使用する CPU 時間の最小化がパフォーマンスと堅牢性の大幅な向上につながることがあります。 アプリケーションがより効率的にメモリを使用できるようになれば、ガベージ コレクタの実行頻度が少なくなり、また実行時間も短くなるのは当然です。 したがって、ガベージ コレクタがアプリケーション内で何を実行し何を実行しないかについて説明するのではなく、メモリ使用のしくみについて直接見ていくことにします。

実稼動マシンの大部分は大量のメモリを備えています。そのため、通常の整数型の代わりに単整数型を使用するなどの最適化を行っても、大局的にはやや無意味に思えるかもしれません。 この記事を読めば、その考えが変わることでしょう。 この記事では、データ型のサイジング、さまざまな設計手法、およびプログラムのメモリ使用率の分析方法について説明します。 ここでは C# の例を用いて説明しますが、この記事で解説する内容は、Visual Basic .NET やマネージ C++ など、他のどのような NET 向け言語に対しても同様に適用できます。

この記事では、ジェネレーション、処理パターン、弱い参照などの関連概念も含めて、ガベージ コレクションのしくみについて読者が理解していることを前提にしています。 これらの概念について十分に理解していない場合は、ガベージ コレクションについて詳しく説明した Jeffrey Richter の記事 (ガベージコレクション入門: Microsoft .NET Framework の自動メモリ管理 Part I) を参照してください。

1. データ型のサイジング

メモリの使用量は、最終的にはプログラムのアセンブリによって定義および使用されるデータ型に依存します。そのため、まずはシステムにおけるさまざまなデータ型のサイズについて検証することにしましょう。

System の型 C# の型 マネージ サイズ (バイト) 既定のアンマネージ サイズ (バイト)
System.Boolean bool 1 4
System.Byte byte 1 1
System.Char char 2 1
System.Decimal decimal 16 16
System.Double double 8 8
System.Single float 4 4
System.Int16 short 2 2
System.Int32 int 4 4
System.Int64 long 8 8
System.SByte sbyte 1 1
System.UInt16 ushort 2 2
System.UInt32 uint 4 4
System.UInt64 ulong 8 8

図 1 .NET の値型

図 1 は、System 名前空間で定義される .NET の主要な値型、およびそれに相当する C# のデータ型について、そのサイズをバイト単位で示しています。 各値型のマネージ メモリ内でのサイズを確認するため、unsafe コードで C# の sizeof 演算子を使用しています。 sizeof 演算子の代わりに Marshal.SizeOf メソッドを使用すると、bool 型や char 型など一部のデータ型について異なる値になる場合があります。これは、Marshal.SizeOf メソッドがマーシャル型のアンマネージ サイズを計算するのに対して、bool 型や char 型などのデータ型は非 blittable 型 (マネージ コードとアンマネージ コードの間の受け渡しで変換が必要になるデータ型) であるためです。 この点についてもう少し説明します。

構造体 (値型) のサイズは、フィールドの合計サイズと、フィールドを自然領域に配置する際のオーバーヘッドとを足し合わせたものになります。 参照型のサイズは、フィールドの合計サイズを次の 4 バイト領域に切り上げ、それにオーバーヘッドの 8 バイトを加えたものに等しくなります (割り当てたときのヒープ サイズの変化を計算するか、または、後で説明する CLR Profiler ツールを使用すると、参照型で使用されているメモリ使用量がわかります)。 つまり、すべての参照型は、少なくとも 12 バイトのメモリを占有することになります。そのため、16 バイトに満たない長さのオブジェクトについては、C# の構造体を使用するほうが効率的な場合があります。 もちろん、データ型への参照を構造体に格納する場合には問題が発生しやすくなります。ボックス化が頻繁に発生してメモリと CPU サイクルの両方を使い果たす場合があるからです。 したがって、構造体を使用する場合は注意する必要があります。

フィールドの配置はデータ型のサイズに影響を及ぼす場合があるため、データ型内部のフィールドの並びが最終的なサイズ決定に大きく関係します。 データ型のレイアウトと、そのレイアウトでのフィールドの並びは、データ型に適用される StructLayoutAttribute に影響されます。 既定では、C#、Visual Basic .NET、および C++ のコンパイラのすべてが、レイアウトを Sequential に指定した StructLayoutAttribute を構造体に適用します。 つまり、フィールドは、ソース ファイルに記述された順序に従ってデータ型内にレイアウトされることになります。 ただし、.NET Framework 1.x では Sequential レイアウトがジャストインタイム コンパイラ (JIT) に考慮されません (マーシャラでは考慮されます)。 .NET Framework 2.0 の JIT は、値型のマネージ レイアウトに対して Sequential レイアウト (指定されている場合) を強制しますが、これはフィールド メンバに参照型が含まれない場合だけです。 したがって次期バージョンの .NET Framework では、おそらく、データ型のサイジングがさらに重要なものとなります。 全バージョンにおいて、Explicit レイアウト (この場合、開発者としてすべてのフィールドに対してフィールド オフセットを指定します) の要求は JIT およびマーシャラの両方で考慮されます。

この違いについて述べるのは、通常、データ型のマーシャル レイアウトがスタック レイアウトまたは GC ヒープ レイアウトとは異なるためです。 データ型のマーシャル レイアウトは、対応するアンマネージ レイアウトに一致する必要があります。 ただし、マネージ レイアウトは、JIT でコンパイルされたマネージ コードでのみ使用できます。 そのため、JIT は、外部の依存関係にかかわらず、使用中のプラットフォームに基づいてマネージ レイアウトの最適化を実行できます。

以下の C# 構造体について考えてみましょう (単純化のため、各メンバのアクセス修飾子は省略しています)。

struct BadValueType
{
    char c1;
    int i;
    char c2;
}

アンマネージ C++ の既定のパッキングと同様に、整数型は 4 バイトの領域にレイアウトされます。そのため、最初の文字列型が使用するメモリは 2 バイトですが (マネージ コードの char 型は Unicode 文字列なので占有するメモリは 2 バイトになります)、整数型が次の 4 バイト領域に移動することになります。さらに、2 番目の文字列型が次の 2 バイトを使用します。 その結果、Marshal.SizeOf メソッドで計算すると、この構造体のサイズは 12 バイトになります (筆者の 32 ビット マシンで試したところ、.NET Framework 2.0 の sizeof 演算子で計算しても 12 バイトとなりました)。 これを以下のように並べ替えると、配置が思ったとおりに機能して、8 バイトのサイズの構造体となります。

struct GoodValueType
{
    int i;
    char c1;
    char c2;
}

もう 1 つの注目点は、データ型が小さくなるほどメモリ使用量も少なくなるということです。 これは自明のことのように思えるかもしれませんが、必要性がないにもかかわらず、数多くのプロジェクトで通常の整数型や 10 進値が使用されています。 GoodValueType の例では、整数値が -37768 以上 32767 以下の範囲にあることを前提としています。以下に示すように、短整数型を使用すると、このデータ型のサイズをさらに小さくすることができます。

struct GoodValueType2
{
    short i;
    char c1;
    char c2;
}

データ型の並べ替えとサイズ調整を適切に行うことで、この構造体のサイズが 12 バイトから 6 バイトへと縮小されました (Marshal.SizeOf で GoodValueType2 のサイズを計算すると 4 バイトとなりますが、これは既定のマーシャリングで char 型が 1 バイトとして処理されるためです)。 注意を払いさえすれば構造体やクラスのサイズをこれほど縮小できることに驚かれるでしょう。

前述のように、重要なのは、特に .NET Framework 1.x においては構造体のマネージ レイアウトがアンマネージ レイアウトとは大きく異なるという点を認識することです。 マーシャル レイアウトは内部レイアウトと異なる場合があります。そのため、今まで説明してきたデータ型で sizeof 演算子を使用すると、異なる値が報告される可能性があります (実際、十分に起こりうることです)。 その例として、これまでに示してきた 3 つの構造体のマネージ サイズは、.NET Framework 1.x では 8 バイトです。 以下で示すように、これら 3 つのいずれかの構造体のレイアウトについて、JIT でアンセーフ コードとポインタ演算を使用することでそのサイズを確認できます。

unsafe
{
    BadValueType t = new BadValueType();
    Console.WriteLine("Size of t: {0}", sizeof(BadValueType));
    Console.WriteLine("Offset of i:  {0}", (byte*)&t.i - (byte*)&t);
    Console.WriteLine("Offset of c1: {0}", (byte*)&t.c1 - (byte*)&t);
    Console.WriteLine("Offset of c2: {0}", (byte*)&t.c2 - (byte*)&t);
}

NET Framework 1.x でこのコードを実行すると、結果は以下のようになります。

Size of BadValueType: 8
Offset of i: 0
Offset of c1: 4
Offset of c2: 6

一方、NET Framework 2.0 で同じコードを実行すると、結果は以下のようになります。

Size of BadValueType: 12
Offset of i: 4
Offset of c1: 0
Offset of c2: 8

新しいバージョンの Framework のほうが構造体のサイズが大きいために、一見したところ退化しているように思えるかもしれませんが、実際には期待通りの動作であり、指定されたレイアウトが JIT で考慮されるのは良いことです。 最適なレイアウトを JIT が自動的に決定できるようにする場合 (上記の1.x JIT と同じ結果が得られます)、構造体の StructLayoutAttribute に LayoutKind.Auto を明示的に指定します。.NET Framework 1.x 上で動作する純粋なマネージ アプリケーションはアンマネージ コードとの相互運用を行いません。そのため、フィールドを手作業で並べ替えて配置を改善し、メモリの使用量を節約しようとしても、うまくいかない場合があるということを覚えておいてください。

図 2 は、その他の考慮事項を示しています。 図の Address クラスは米国内の住所を表しています。 構造体の各メンバが 4 バイト、さらに参照型のオーバーヘッドが 8 バイトのメモリを使用するため、この構造体の合計サイズは 36 バイト長となります。C# の sizeof 演算子は値型に対してのみ有効なため、また Marshal.SizeOf で報告される値に依存していることに注意してください。 医師および病院への支払いを管理する大規模な医療アプリケーションでは、何千もの住所の同時処理が必要になることがあります。 この場合、このクラスのサイズを最小化することが重要になります。 構造体のフィールドの順序は問題ありませんが、AddressType については考慮が必要です (図 2 参照)。

図 2 AddressType

enum AddressType
{
  Home,
  Secondary,
  Office
}

class Address
{
  public bool IsPayTo;
  public AddressType 
    AddressType;
  public string Address1;
  public string Address2;
  public string City;
  public string State;
  public string Zip;
}

列挙型は既定では整数型として格納されますが、整数基本型を使用するように指定できます。 図 3 では、AddressType 列挙体を short 型として定義しています。 さらに IsPayTo フィールドを byte 型に変更することで、各 Address インスタンスのアンマネージ サイズを 36 バイトから 32 バイトへと 10% 以上縮小しています。また、マネージ サイズについては少なくとも 2 バイト縮小しています。

string 型は参照型であるため、すべての string インスタンスは、実際の文字データが格納される追加的なメモリ ブロックを参照することになります。 準州を無視した場合、Address クラスの state フィールドに指定可能な値の数は 50 になります。 ここには列挙型を考慮してみるのもよいかもしれません。参照型を使用する必要がなくなり、値をクラスに直接格納できるからです。 列挙型の基本型に既定の int 型ではなく byte 型を指定すると、フィールド サイズが 4 バイトから 1 バイトに縮小されます。これは実行可能な選択肢ですが、データの表示と格納が複雑になります。なぜなら、データへのアクセスまたはデータの格納処理が発生するたびに、フィールドの整数値をユーザーまたはストレージ メカニズムが理解できる形式に変換する必要があるからです。 この状況は、処理速度対メモリ使用量という、コンピュータ処理におけるより一般的なトレードオフの 1 つを明らかにしています。 CPU サイクルを犠牲にしてメモリ使用量を最適化する状況 (またはその逆) は頻繁に発生します。

ここでの代替オプションは、インターン文字列を使用することです。 CLR はインターン プールと呼ばれるテーブルを保持しており、そこにはプログラムのリテラル文字列が格納されます。 この機能によって、コード内で文字列定数を繰り返し使用する場合、同一の文字列参照を利用できるようになります。 System.String クラスが提供する Intern メソッドは、ある文字列がインターン プールにあることを保障し、その文字列に対する参照を返します。 これは図 3 で説明されています。

図 3 型のサイズの縮小

enum AddressType : short
{
    Home, 
    Secondary, 
    Office
}

class Address
{
    byte _isPayTo;
    AddressType _addrType;
    string _address1;
    string _address2;
    string _city;
    string _state;
    string _zip;

    public bool IsPayTo
    {
        get { return (_isPayTo == 1); }
        set { _isPayTo = (byte)(value ? 1 : 0); }
    }

    public string State
    {
        get { return _state; }
        set
        {
            if (value == null) _state = null;
            else _state = String.Intern(value.ToUpper());
        }
    }

    public AddressType AddressType 
    {
        get { return _addrType; } set { _addrType = value; }
    }

    public string Address1
    {
        get { return _address1; } set { _address1 = value; }
    }

    public string Address2 
    {
        get { return _address2; } set { _address2 = value; }
    }
    
    public string City { ... } 
    public string Zip { ... }
}

データ型のサイジングについての説明を終える前に、基本クラスについても触れておきたいと思います。 派生クラスのサイズは、基本クラスのサイズに派生インスタンスで定義された追加メンバのサイズを足し合わせたものに等しくなります (前述のように、配置に必要であればスペースを追加します)。 その結果、派生クラスで使用されない基本クラスのフィールドは貴重なメモリの浪費となります。 基本クラスは共通の機能を定義する場合に効果的ですが、定義される各データ要素が本当に必要かどうか確認する必要があります。

次に、メモリ管理を効率的に行うための設計および実装の手法について説明します。 アセンブリが必要とするメモリ量はアセンブリの処理内容に大きく依存しますが、実際に使用されるメモリは、アプリケーションがどのようにしてさまざまなタスクを実行するかということに影響されます。 この違いは重要ですので、アプリケーションの設計および実装を行うときには留意するようにしてください。 以下では、シングルトン、メモリ プーリング、およびデータ ストリーミングについて検討します。

ページのトップへ


2. シングルトン

ワーキング セットとは、アプリケーションが RAM 上で使用可能な一連のメモリ ページのことです。 初期ワーキング セットとは、アプリケーションが起動時に使用するメモリ ページのことです。 アプリケーション起動時に実行されるタスクと割り当てられるメモリが多ければ多いほど、アプリケーションが起動処理を終えるまでにかかる時間が長くなり、必要な初期ワーキング セットのサイズも大きくなります。 この問題はデスクトップ アプリケーションで特に重要となります。起動完了を待つ間、ユーザーがスプラッシュ スクリーンを見続けることになるからです。

シングルトン パターンを使用すると、オブジェクトの初期化をできる限り遅らせることができます。 以下のコードは、C# でのシングルトン パターンのコード例を示しています。 GetInstance メソッドで返されるシングルトン インスタンスが static フィールドに格納されます。 以下のコードで示すように、クラスのメンバが最初にアクセスされる前に、必ずこの static コンストラクタ (すべての static フィールド初期化子を実行するために C# のコンパイラによって暗黙的に生成される) が実行され、static インスタンスを初期化します。

public class Singleton
{
    private static Singleton _instance = new Singleton();
    public static Singleton GetInstance()
    {
        return _instance;
    }
}

シングルトン パターンでは、あるクラスに対してインスタンスは通常 1 つしかアプリケーションで使用されないことが保証されます。ただし、必要に応じて別のインスタンスを作成することもできます。 これによってアプリケーションが 1 つのインスタンスを共有できるようになるため、各コンポーネントに独自のプライベート インスタンスを割り当てるよりもメモリの使用量を節約できます。 static コンストラクタを使用すると、アプリケーションの一部の機能で共有インスタンスが必要とされるまで、インスタンスのメモリ割り当てを遅らせることができます。 これは、さまざまな機能をサポートする大規模アプリケーションで重要になることがあります。クラスが実際に使用される場合にのみ、オブジェクトのメモリが割り当てられるようになるからです。

シングルトン パターンおよびこれと同種の手法は、実際に必要になるまで初期化が行われないことから、遅延初期化と呼ばれることがあります。 オブジェクトの最初の要求時に初期化が行われるようなシナリオでは、多くの場合、こうした遅延初期化が非常に有用です。 ただし、static メソッドで十分な場面で使用するべきではありません。 言い換えると、クラスにシングルトンを作成して一連のインスタンス メンバにアクセスしようとしている場合に、同じ機能を static メンバによって公開するほうがより賢明かどうかを考えてみてください。static メンバを使用すれば、シングルトンをインスタンス化する必要はなくなります。

ページのトップへ


3. プーリング

実行中のアプリケーションのメモリ使用率は、システムが必要とするオブジェクトの個数とサイズに影響されます。 オブジェクト プーリングを使用すると、アプリケーションが必要とするオブジェクトのメモリ割り当てを減らすことができます。したがって、ガベージ コレクションの数も減ることになります。 プーリングは非常に単純で、オブジェクトをガベージ コレクタで回収するのではなく再利用します。 オブジェクトは、プールと呼ばれるある種のリストまたは配列に格納され、要求があるとクライアントに渡されます。 この機能が特に有用となるのは、オブジェクトのインスタンスが繰り返し使用される場合、またはオブジェクトの初期化コストが大きく、オブジェクトを破棄して最初から再作成するよりも、既存のオブジェクトを再利用したほうが効率的な場合などです。

オブジェクト プーリングが有用となるシナリオについて考えてみましょう。 大きな保険会社向けに患者情報をアーカイブするシステムを作成すると仮定します。 医師はその日に集めた情報を中央の管理場所に毎晩転送します。 作成するコードにはおそらく以下のようなループが含まれます。

while (IsRecordAvailable())
{
    PatientRecord record = GetNextRecord();
    ... // レコードの処理
}

このループでは、ループが実行されるたびに新しい PatientRecord が返されます。 GetNextRecord メソッドの実装に関して最も目に付く点は、メソッドの呼び出しのたびに新しいオブジェクトが作成されることです。そのため、オブジェクトのメモリへの割り当て、初期化、およびガベージ コレクト (オブジェクトのファイナライザが用意されている場合はファイナライゼーション) が毎回行われることになります。 オブジェクト プールを使用すると、割り当て、初期化、ガベージ コレクション、およびファイナリゼーションの実行は 1 回だけとなり、必要なメモリ使用量と処理時間が節約されます。

PatientRecord record = new PatientRecord();
while (IsRecordAvailable())
{
    record.Clear();
    FillNextRecord(record);
    ... // レコードの処理
}

このコードでは、PatientRecord オブジェクトは 1 つ作成され、Clear メソッドによってその内容がリセットされるため、ループ内で再利用できます。 FillNextRecord メソッドは既存のオブジェクトを使用するため、オブジェクトのメモリ割り当てが繰り返されることはありません。 もちろん、このコードが実行されるたびに割り当て、初期化、およびガベージ コレクションが 1 回行われるため、そのためのメモリは依然として消費されます (ただし、ループのたびにこうした処理が行われることに比べればその消費は小さなものです)。 オブジェクトの初期化コストが大きい場合、または複数のスレッドからコードが同時に呼び出される場合は、こうしたオブジェクトの反復作成の影響がまだ問題となることがあります。

オブジェクト プーリングの基本パターンは以下のようになります。

while (IsRecordAvailable())
{
    PatientRecord record = Pool.GetObject();

    record.Clear();
    FillNextRecord(record);
    ... // レコードの処理

    Pool.ReleaseObject(record);
}

アプリケーションの起動時に PatientRecord インスタンスまたはインスタンス プールが作成されます。 このコードでは、インスタンスをプールから取得することによって、メモリ割り当て、コンストラクション、およびガベージ コレクションの実行を回避しています。 これによって時間とメモリが大幅に節約されることになります。ただし、プール内のオブジェクトをプログラマが明示的に管理する必要があります。

.NET Framework は、エンタープライズ サービス サポートの一環として、COM+ アセンブリ向けにオブジェクト プーリングを提供しています。 この機能にアクセスするには、System.EnterpriseServices.ObjectPoolingAttribute クラスを使用します。 オブジェクト プーリングについては、Rocky Lhotka の記事 Everyone Into the Pool で詳しく説明されています。 COM+ ではオブジェクトの自動プーリングがサポートされているため、ユーザーがオブジェクトの取得と返却を明示的に行う必要はありません。 その一方で、アセンブリは COM+ 内で動作する必要があります。

あらゆる種類の .NET オブジェクトをプーリングできるようにするため、汎用的なオブジェクト プールを作成しました。この記事が興味深いものになるだろうと考えたからです。図 4 にこのクラスのインターフェイスを示します。 ObjectPool クラスが提供するプーリングには、あらゆる .NET オブジェクトを格納できます。

図 4 .NET の ObjectPool

public class ObjectPool
{
    // ObjectPool はシングルトンとして実装されます
    public static ObjectPool GetInstance() { ... }

    // インターフェイスによって使用されるデリゲート
    public delegate object CreateObject();
    public delegate void UseObject(object obj, object [] args);

    // 指定された型のプーリングを開始します
    public void RegisterType(Type t, CreateObject createDelegate,
        short minPoolSize, short maxPoolSize, int creationTimeout) { ... }

    // 指定された型のプーリングを終了します
    public void UnregisterType(Type t) { ... }

    // プール内のオブジェクトを取得および解放します
    public object GetObject(Type t) { ... }
    public void ReleaseObject(object obj) { ... }

    // プール内のオブジェクトを使用して、指定されたメソッドを実行します
    public void ExecuteFromPool(Type t,
        UseObject executeDelegate, object [] args) { ... }
}

オブジェクトをプールするには、まずそのオブジェクトを登録する必要があります。 登録によって作成デリゲートが特定されます。このデリゲートは、オブジェクトの新しいインスタンスが必要になったときに呼び出されます。 このデリゲートは新しくインスタンス化されたオブジェクトを返すだけで、コンストラクションのロジックはデリゲートを提供するクライアント側に委ねられます。 Enterprise Services の ObjectPooling 属性と同様に、このデリゲートは、プール内のアクティブなオブジェクトの最少数、プール内のオブジェクトの最大数、およびオブジェクトが有効になるまで待つ時間のタイムアウト値を受け取ります。 タイムアウト値がゼロの場合、呼び出し元はオブジェクトが利用できるようになるまで常に待機します。 リアルタイム処理、またはオブジェクトが利用できない場合に他の代替手段が求められるような状況においては、ゼロ以外の値をタイムアウトに指定するのが有効です。 登録呼び出しが返されると、要求された最少数のオブジェクトがプール内で利用できるようになります。 指定したオブジェクトのプーリングを終了するには、UnregisterType メソッドを使用します。

登録終了後、プールからオブジェクトを取得する場合は GetObject メソッドを、プールにオブジェクトを返却する場合は ReleaseObject メソッドをそれぞれ使用します。 ExecuteFromPool メソッドは、必要なオブジェクトに加えて、デリゲート、および引数を受け取ります。 ExecuteFromPool メソッドは、指定されたオブジェクトのデリゲートをプールから呼び出し、デリゲート完了後にオブジェクトがプールに返却されることを保障します。 これによってデリゲートの呼び出しというオーバーヘッドが生じますが、プールを手作業で管理する必要がなくなります。

内部的に、このクラスはすべてのプールされたオブジェクトのハッシュ テーブルを保持します。 このクラスは、各オブジェクトに関連した内部データを保持するために ObjectData クラスを定義しています。 ここではこのクラスは紹介されていませんが、オブジェクトの登録情報と利用情報、およびプールされたオブジェクトのキューを保持します。

図 5に示すように、ReleaseObject メソッドはプライベートな ReturnToPool メソッドを内部的に使用して、指定されたオブジェクトをプールに再度格納します。Monitor クラスは処理をロックします。 利用可能なオブジェクトが最少数より少ない場合、オブジェクトへの参照がキューに入れられます。 最少数のオブジェクトが既に割り当てられている場合、弱い参照がキューに入れられます。 必要であれば、新たにキューに入れられたオブジェクトを取得するように待機中のスレッドが指示されます。

図 5 ReleaseObject メソッド

private void ReturnToPool(object obj, ObjectData data)
{
    Monitor.Enter(data);
    try
    {
        data.inUse—;

        int size = data.inUse + data.inPool;
        if (size < data.minPoolSize)
        {
            // 実際のオブジェクトをプールに返却します
            data.pool.Enqueue(obj);
            data.inPool++;
        }
        else
        {
            // 最少数のオブジェクトが利用可能なため、弱い参照をキューに入れます
            WeakReference weakRef = new WeakReference(obj);
            data.pool.Enqueue(weakRef);
        }

        // 待機中のスレッドに通知します
        if (data.inWait > 0) Monitor.Pulse(data);
    }
    finally { Monitor.Exit(data); }
}

ここで弱い参照を使用すると、最少数を超えるオブジェクトをできる限り保持しておくことができます。ただし、GC は必要に応じてこれらのオブジェクトを利用できます。 ObjectData の inUse フィールドはアプリケーションに渡されたオブジェクトを追跡します。一方、inPool フィールドはプール内の実際の参照数を保持します。 inPool フィールドは弱い参照をすべて無視します。

プール作成にあたり最も重要な実装の 1 つは、オブジェクトに適切なライフタイム ポリシーを適用することです。 こうしたポリシーの基礎となるのが弱い参照です。ただし、他にも適用可能なポリシーがあり、どのポリシーを採用するかは環境に依存します。

GetObject メソッドは、図 6 に示す RetrieveFromPool メソッドを内部的に使用します。 Monitor.TryEnter メソッドを使用すると、アプリケーションがロック待ちで長時間待機することがなくなります。 タイムアウトの時間内にロックが取得できない場合、null が呼び出し元に返されます。 ロックを取得すると、DequeueFromPool メソッドがプールからオブジェクトを取り出します。 キューに入れられた弱い参照を do-while ループでこのメソッドがどのように処理するかに注意してください。

図 6 RetrieveFromPool メソッド

private object AllocateObject(ObjectData data)
{
    return data.createDelegate();
}

private object DequeueFromPool(ObjectData data)
{
    object result;
    do
    {
        // プールが空でないことを前提とします
        result = data.pool.Dequeue();
        if (result is WeakReference)
            result = ((WeakReference)result).Target;
        else
            data.inPool—;

    } while (result == null && data.pool.Count > 0);

    return result;
}

private object RetrieveFromPool(ObjectData data)
{
    object result = null;
    int waitTime = (data.creationTimeout > 0) ?
        data.creationTimeout : Timeout.Infinite;

    try
    {
        // ロックを取得します
        int startTick = Environment.TickCount;
        if (Monitor.TryEnter(data, waitTime) == false) return null;

        if (data.pool.Count > 0) result = DequeueFromPool(data);

        if (result == null)
        {
            // プールが空か、すべて弱い参照です
            if (data.maxPoolSize == 0 || data.inUse < data.maxPoolSize)
                result = AllocateObject(data);
            else
            {
                if (waitTime != Timeout.Infinite)
                    waitTime -= (Environment.TickCount - startTick);
                result = WaitForObject(data, waitTime);
            }
        }

       // inUse カウンタを更新します
       if (result != null) data.inUse++;
  } 
  finally { Monitor.Exit(data); }

  return result;
}

RetrieveFromPool メソッドのコードに戻ります。キューにエントリが見つからない場合、利用可能なオブジェクト数が最大数に達しない限り、AllocateObject メソッドによって新しいオブジェクトがメモリに割り当てられます。 最大数に達すると、WaitForObject メソッドがオブジェクトの作成をタイムアウトまで待つことになります。 WaitForObject の呼び出し前に待機時間がどのように調整されてロック取得に費やされる時間が計上されるかに注意してください。 ここでは WaitForObject のコードを紹介していませんが、この記事のために用意されたダウンロードが利用できます。

オブジェクトの取得タイムアウトが発生した場合、null が返されるかまたは例外がスローされるか、その2つのどちらかの選択となります。 null を返すことの欠点は、呼び出し元がオブジェクトをプールから取得するたびに、それが null かどうかチェックする必要があることです。 例外のスローではこのチェックの必要はありませんが、タイムアウトのコストがより大きくなります。 タイムアウトが予期されない場合は、例外のスローを選択したほうが良いでしょう。 ここでは null を返すようにしています。これは、タイムアウトが予期されない場合でも、このチェックを省略できるからです。 タイムアウトが予期される場合でも、null かどうかチェックすることのコストは、例外をキャッチするコストに比べると小さなものです。

図 7 は ExecuteFromPool メソッドのコードを示しています。ただし、エラー処理とコメントは除いてあります。 このコードはプライベート メソッドを使用してプールからオブジェクトを取得し、指定されたデリゲートを呼び出しています。 例外が発生した場合でも、オブジェクトがプールに返却されることが finally ブロックによって保障されます。

図 7 ExecuteFromPool メソッド

private ObjectData GetObjectData(Type t)
{
    // private ObjectData クラスは、それぞれの型の状態を保持します
    ObjectData data = Table[t.FullName] as ObjectData;
    if (data == null) throw new ArgumentException(...);
    return data;
}

public void ExecuteFromPool(Type t, 
    UseObject executeDelegate, object [] args)
{
    ObjectData data = GetObjectData(t);

    object obj = null;
    try
    {
        // プールからオブジェクトを取り出します
        obj = RetrieveFromPool(data);
        if (obj == null) throw new ObjectPoolException(...);

        // プールされているオブジェクトを使って、指定されたデリゲートを実行します
        executeDelegate(obj, args);
    }
    finally
    {
        // 取り出したオブジェクトをプールに返却します
        if (obj != null) ReturnToPool(obj, data);
    }
}

オブジェクト プールを使用すると、アプリケーション内の最も一般的なオブジェクトをプーリングできるようになり、ヒープに割り当てられるオブジェクト数を平均化できます。 これによって、.NET ベース アプリケーションのマネージ ヒープ サイズによく見られる鋸歯パターンが除去されます。また、アプリケーションがガベージ コレクションの実行に費やす時間も短縮されます。 ObjectPool クラスを使用したサンプル プログラムについては後で見ていきます。

マネージ ヒープがオブジェクトの新規割り当てを非常に効率的に実行できること、およびガベージ コレクタが存続期間の短い多数の小さなオブジェクトを非常に効率的に回収できることにも注意してください。 オブジェクトをあまり頻繁に使用しない場合、またはオブジェクトの作成や破棄のコストが大きくない場合、オブジェクト プーリングは必ずしも正しい戦略ではありません。 パフォーマンスにかかわる決定と同様に、コード内の真のボトルネックに対処する最良の手段は、アプリケーションをプロファイリングすることです。

ページのトップへ


4. データ ストリーミング

アプリケーションが大きなデータを処理するとき、単純に大量のメモリが必要となることがあります。 オブジェクト プーリングでは、クラスの割り当てに必要なメモリとオブジェクトの作成および破棄に必要な時間を節約することしかできません。 実行時に大量のデータ処理が必要なプログラムがあるという問題が根本的に解決されるわけではないのです。

大量のデータが継続的に必要になる場合にまず実行できることは、できる限り効率的なメモリ管理を行うことと、圧縮などの方法でデータをできる限りコンパクトにすることです (ここでもまた、メモリ容量対処理速度という古くからの問題が発生します。圧縮によってメモリの消費量が減少する一方で、より多くの CPU サイクルが必要となるからです)。 データが一時的に必要となる場合、データ ストリーミングを使用してメモリの消費量を抑えることができます。 データ ストリーミングは、すべてまたは大部分のデータを一度に処理するのではなく、データを少しずつ処理することによって実現されます。 System.Data 名前空間の DataSet クラスと DataReader クラスを比較してみましょう。 クエリの結果は DataSet オブジェクトに直接ロードできますが、結果が大きいと大量のメモリが消費されることになります。 また、DataSet では、メモリに 2 回アクセスする必要もあります。つまり、テーブルへデータを挿入するときと、その後でテーブルからデータを読み取るときです。 DataReader クラスを使用すると、同じクエリの結果を一定の分量ずつロードして、アプリケーションには 1 行分ずつデータを渡すことができます。 結果セット全体が実際には必要とされない場合、メモリをより効率的に使用できるため、これは理想的な方法と言えます。

String クラスを使用する際には、そうとは知らずに大量のメモリを消費することが多々あります。 最も単純な例は文字列の連結です。 4 つの文字を追加的に連結する (新しい文字列に文字を 1 つずつ追加する) と、内部的に 7 つの string オブジェクトが作成されることになります。文字を追加するたびに新しい string オブジェクトが作成されるからです。 System.Text 名前空間の StringBuilder クラスを使用すると、毎回新しい string インスタンスを割り当てることなく文字列を結合できます。これはメモリ使用率を大幅に改善します。 この点については、C# コンパイラも役立ちます。なぜなら、C# コンパイラは同じコード内にある一連の文字列連結を単一の String.Concat 呼び出しに変換するからです。

もう 1 つの例が String.Replace メソッドです。 外部ソースから送られる多数の入力ファイルを読み込んで処理するシステムについて考えてみましょう。 これらの入力ファイルは、適切なフォーマットに変換するために前処理が必要な場合があります。 説明のため、ファイル内の単語 "nation" を "country" に、"liberty" を "freedom" に毎回置換するシステムがあるものと仮定してください。 この置換は以下のコードで簡単に実現できます。

using(StreamReader sr = new StreamReader(inPath))
{
    string contents = sr.ReadToEnd();
    string result = contents.Replace("nation", "country");
    result = result.Replace("liberty", "freedom");

    using(StreamWriter sw = new StreamWriter(outPath))
    {
        sw.Write(result)
    }
}

このコードは完全に動作しますが、ファイル長に等しい 3 つの string オブジェクトを作成するというコストが伴います。 Gettysburg Address はおよそ 2,400 バイトの Unicode テキストです。 また、合衆国憲法は 50,000 バイトを超える Unicode テキストです。 どうなるか想像がつくでしょう。

それぞれがおよそ 1MB の文字データからなるファイルを 10 個同時に処理するものとします。 先ほどのコードでこれら 10 ファイルを読み込んで処理すると、約 10MB のメモリが消費されることになります。 ガベージ コレクタが割り当てとクリーンアップを継続的に行うには、かなり大きなメモリ サイズと言えます。

ファイル ストリーミングを使用すると、一度に少しずつ文字列を処理できます。 N または L が見つかるたびに、単語を探して必要に応じて置換します。 図 8 にサンプルコードを示します。 このコードでは、FileStream クラスを使用してバイト単位でのデータ処理について説明しています。 必要に応じて StreamReader クラスや StreamWriter クラスを使用するように、このコードを書き換えることができます。

図 8 ファイル ストリーミング

static void ProcessFile(FileStream fin, FileStream fout)
{
    int next;
    while ((next = fin.ReadByte()) != -1)
    {
        byte b = (byte)next;
        if (b == 'n' || b == 'N')
          CheckForWordAndWrite(fin, fout, "nation", "country", b);
        else if (b == 'l' || b == 'L')
          CheckForWordAndWrite(fin, fout, "liberty", "freedom", b);
        else
          fout.WriteByte(b);
    }
}

static void CheckForWordAndWrite(Stream si, Stream so,
    string word, string replace, byte first)
{
    int len = word.Length;

    long pos = si.Position;
    byte[] buf = new byte[len];
    buf[0] = first;
    si.Read(buf, 1, word.Length-1);

    string data = Encoding.ASCII.GetString(buf);
    if (String.Compare(word, data, true) == 0)
        so.Write(Encoding.ASCII.GetBytes(replace), 0, replace.Length);
    else
    {
        si.Position = pos;    // ストリームをリセットします
        so.WriteByte(first);  // 元のバイトを書き込みます
    }
}

このコードでは、ProcessFile メソッドが 2 つのストリームを受け取り、一度に 1 バイトずつデータを読み込みながら N または L を探します。該当する文字が見つかると、CheckForWordAndWrite メソッドがストリームを調べて、それ以降の文字列が指定した単語に一致するかどうか確認します。 一致した場合、置換文字列を出力ストリームに書き出します。 一致しない場合、元の文字が出力ストリームに入れられ、入力ストリームが元の位置にリセットされます。

CheckForWordAndWrite メソッドは、FileStream クラスを使用して入力ファイルと出力ファイルをバッファに適切に格納します。そのため、コードで 1 バイトずつ必要な処理を行うことができます。 各 FileStream は既定で 8KB のバッファを使用します。ファイル全体を読み込んで処理する前述のコードと比較すると、このコードの実装ではメモリ使用量がはるかに少なくなります。 メモリ使用量は確かに少なくなりますが、入力ストリーム内のほとんどの文字に対して、FileStream.ReadByte および FileStream.WriteByte の関数呼び出しが発生します。 一度に複数バイトをバッファに読み込んでメソッド呼び出しの回数を減らすなど、中間的な方法を選択することでより適切に処理できる場合もあります。 このケースでもまたプロファイラが役立ちます。

.NET のストリーミング クラスは、共通の基本ストリーム上で複数のストリームが連携できるように設計されています。 Stream の派生クラスの多くでは、既存の Stream オブジェクトを受け取るコンストラクタが用意されています。そのため、入力データ上で一連の Stream オブジェクトを処理し、ストリームに対する修正や変換を連続的に実行できます。 たとえば、CryptoStream クラスに関する .NET Framework のドキュメントを参照してください。このドキュメントは、入力 FileStream オブジェクトからのバイト配列を暗号化する方法について説明しています。

これまでは、メモリ使用率に関連した設計および実装の問題について検討してきました。次に、アプリケーションのテストとチューニングについて簡単に説明します。 ほとんどのアプリケーションは、パフォーマンスやメモリに関連したさまざまな問題を抱えています。 こうした問題を発見する最良の方法は、それらの項目に対して十分な測定を行い、そこで明らかになった問題を追跡することです。 この目的を達成するために非常に役立つ 2 つのツールが、Windows パフォーマンス カウンタと .NET CLR Profiler (またはその他のプロファイラ) です。

ページのトップへ


5. パフォーマンス モニタ

Windows パフォーマンス モニタによってパフォーマンスの問題を解決することはできませんが、問題を解決するための糸口をつかむことができます。 メモリ使用率やその他のパフォーマンス メトリックに関連したパフォーマンス カウンタの網羅的なリストについては、Chapter 15 - Measuring .NET Application Performance (英語)を参照してください。

パフォーマンス チューニングは繰り返し行うのが理想的です。 一連のパフォーマンス メトリックを特定し、これらのメトリックを適用可能なテスト環境を構築して、アプリケーションのテストを実行します。 パフォーマンス モニタを使用してパフォーマンス情報を収集します。 その結果を分析して改善提案を作成します。 この改善提案に基づいてアプリケーションまたはシステム構成の修正を行います。そして再度このプロセスを最初から繰り返します。

このような、システムのテスト、情報収集、分析、および修正のプロセスは、メモリ使用率をはじめとするパフォーマンスのあらゆる側面に同様に適用できます。 システムの修正には、コードの一部書き換え、システム内の構成変更やシステム内のアプリケーションの分散などの変更が含まれます。

ページのトップへ


6. CLR Profiler

TCLR Profiler ツールはメモリ使用率の分析に非常に役立ちます。 このツールは実行中のアプリケーションの動作を分析し、メモリに割り当てられたデータ型、割り当て期間、各ガベージ コレクションの詳細、およびその他メモリ関連の情報について詳細なレポートを提供します。 CLR Profiler は Tools & Utilities から無料でダウンロードできます。

このツールは操作方法が非常に煩わしいため、汎用的なパフォーマンス分析には向いていません。 ただし、マネージ ヒープの分析に関しては、非常に強力な機能を提供します。 この機能について簡単な例を示すため、PoolingDemo プログラムを作成しました。このプログラムでは、前述した ObjectPool クラスを使用しています。 プーリングは単に大きくてコストの高いオブジェクトだと誤解されないように、このデモ プログラムでは MyClass オブジェクトを以下のように定義しています。

class MyClass {
    Random r = new Random();

    public void DoWork() {
        int x = r.Next(0, 100);
    }
}

このプログラムでは、プーリングを使用するテストと使用しないテストを選択できます。 プーリングを使用しないコードは以下の処理を実行します。

public static void BasicNoPooling()
{
    for (int i = 0; i < Iterations; i++)
    {
        MyClass c = new MyClass();
        c.DoWork();
    }
}

筆者のデスクトップ マシンでこのコードを 100 万回反復実行したところ、処理終了までにおよそ 12 秒かかりました。 プーリングを使用するコードでは、次のようにループ内で MyClass オブジェクトの割り当てを行いません。

public static void BasicPooling()
{
    // MyClass オブジェクトの登録
    Pool.RegisterType(typeof(MyClass), ...);

    for (int i = 0; i < Iterations; i++)
    {
        MyClass c = (MyClass)Pool.GetObject(typeof(MyClass));
        c.DoWork();

        Pool.ReleaseObject(c);
    }

    Pool.UnregisterType(typeof(MyClass));
}

このコードでは、static な Pool プロパティを使用して ObjectPool.GetInstance を呼び出しています。100 万回の反復処理を実行したところ、処理終了までにかかった時間はおよそ 1.2 秒でした。プーリングを使用しない場合と比較して、約 10 倍の速度です。 もちろんこの例は、オブジェクト インスタンスへの参照の取得と解放に必要なコストを強調するために意図的に作成したものです。 MyClass.DoWork は JIT コンパイラによってほぼ確実にインライン化されており、反復 1 回あたりの節約時間 (100 万回の反復で 10 秒) はかなり小さくなります。 それでも、この例はオブジェクト プーリングによってオーバー ヘッドをある程度除去できることを示しています。 このようなオーバーヘッドが重要となる、またはオブジェクトの作成やファイナライゼーションに大きなコストがかかる状況では、オブジェクト プーリングが有用な場合があります。

図 9 オブジェクト プーリングを使用した場合のタイムライン ビュー

図 9 オブジェクト プーリングを使用した場合のタイムライン ビュー

反復回数を 10 万回に減らし、このコードに対して CLR Profiler を実行すると、興味深い結果が得られます。図 9 は、オブジェクト プーリングを使用した場合のタイムライン ビューを示しています。また、図 10 は、オブジェクト プーリングを使用しない場合のタイムライン ビューを示しています。 この画面は、各オブジェクトを色別に表したマネージ ヒープの時系列変化を示しています。また、各ガベージ コレクションが実行されるタイミングも表示されています。図 9では、オブジェクト プーリングが使用されているため、ヒープは適切なレベルで、アプリケーション終了時にガベージ コレクションが 1 回実行されます。図 10, では、オブジェクト プーリングが使用されていないため、各 Random クラスが割り当てたデータをヒープが復元する必要があります。 赤色は integer 配列を表しており、データの大部分を占めています。 オブジェクト プーリングを使用しない場合のタイムライン ビューは、プーリングを使用しないテストによってガベージ コレクションが 11 回実行されたことを示しています。

図 10 オブジェクト プーリングを使用しない場合のタイムライン ビュー

図 10 オブジェクト プーリングを使用しない場合のタイムライン ビュー

CLR Profiler では、オブジェクト別または時系列の割り当て画面の表示、各メソッドによって割り当てられたバイト数の特定、テスト中に実行されたメソッドのシーケンス表示なども実行できます。 CLR Profiler は、「MSDN Magazine」 Web サイトからダウンロードできます。プログラムには詳細なドキュメントが同梱されており、ガベージ コレクションの一般的な問題を説明するサンプル コードや、こうした問題が CLR Profiler 画面でどのように表示されるかなどの情報が含まれています。

ページのトップへ


7. まとめ

この記事をお読みいただいて、コードのメモリ使用率、つまりどの部分が適切でどの部分が改善が見込めるかについて、これまでとは異なる考えを持つようになったと思います。 この記事では、データ型のサイジングからメモリ関連の問題発見に役立つツールまで、さまざまな問題を扱いました。 頻繁に使用するオブジェクトについて、.NET ランタイムの割り当てやガベージコレクトに依存するのではなく、オブジェクト プーリングを使用することから得られるパフォーマンス面およびメモリ面での利点について説明しました。また、大きなオブジェクトを処理する際に、ストリーミングを使用してメモリ使用量を節約する方法についても見てきました。 後は皆さんにお任せします。


Erik Brown は、Microsoft Gold Certified Partner である Unisys Corporation のシニア開発者およびアーキテクトです。 彼は Windows Forms Programming with C# (Manning Publications Company、2002) の著者です。


この記事は、MSDN マガジン - 2005 年 1 月号からの翻訳です。

ページのトップへ