ASP.NET Core のメモリ管理とガベージコレクション (GC)

作成者:Sébastien RosRick Anderson

メモリ管理は、.NET などのマネージフレームワークでも複雑です。 メモリの問題を分析して理解することは困難な場合があります。 この記事の内容は次のとおりです。

  • 多くの メモリリークが発生 し、GC が動作して いない 問題が発生しました。 これらの問題のほとんどは、.NET Core でのメモリ消費のしくみを理解していないか、測定方法を理解していないことによって発生しました。
  • 問題のあるメモリ使用方法を示し、別のアプローチを提案します。

.NET Core でのガベージコレクション (GC) のしくみ

GC は、各セグメントが連続するメモリ範囲であるヒープセグメントを割り当てます。 ヒープに配置されたオブジェクトは、0、1、または2の3つのジェネレーションに分類されます。 生成によって、アプリケーションによって参照されなくなったマネージオブジェクトのメモリを GC が解放する頻度が決まります。 下位の番号付きジェネレーションは、GC の方が頻繁に行われます。

オブジェクトは、有効期間に基づいて、ある世代から別の世代に移動されます。 オブジェクトが長くなると、オブジェクトは上位世代に移動されます。 既に説明したように、より高い世代は GC の方が頻繁に発生します。 短期有効期間オブジェクトは常にジェネレーション0に残ります。 たとえば、web 要求の有効期間中に参照されるオブジェクトは、短時間で終了します。 一般に、アプリケーションレベル シングルトン は第2世代に移行します。

ASP.NET Core アプリが開始されると、GC は次のようになります。

  • 初期ヒープセグメント用にメモリを予約します。
  • ランタイムが読み込まれるときに、メモリの一部をコミットします。

前のメモリ割り当ては、パフォーマンス上の理由から実行されます。 パフォーマンス上の利点は、連続したメモリのヒープセグメントから取得されます。

GC.Collect の注意事項

一般的に、運用中の ASP.NET Core アプリでは GC.Collect を明示的に使用すべきではありません。 最適でない時間にガベージ コレクションを誘導すると、パフォーマンスが大幅に低下する可能性があります。

GC.Collect は、メモリ リークを調査するときに便利です。 GC.Collect() を呼び出すと、マネージド コードからアクセスできないすべてのオブジェクトを回収しようとするブロッキング ガベージ コレクション サイクルがトリガーされます。 これは、ヒープ内の到達可能なライブ オブジェクトのサイズを把握し、時間の経過に伴うメモリ サイズの増加を追跡する便利な方法です。

アプリのメモリ使用量の分析

専用ツールは、メモリ使用量の分析に役立ちます。

  • オブジェクト参照
  • GC が CPU 使用率に与える影響を測定する
  • 各世代に使用されるメモリ領域の測定

メモリ使用量を分析するには、次のツールを使用します。

メモリの問題の検出

タスクマネージャーを使用して、ASP.NET アプリが使用しているメモリの量を把握できます。 タスクマネージャーのメモリ値:

  • ASP.NET プロセスによって使用されるメモリの量を表します。
  • アプリの生きたオブジェクトや、ネイティブメモリ使用量などの他のメモリコンシューマーを含みます。

タスクマネージャーのメモリ値が無制限に増加し、フラット化されない場合、アプリにはメモリリークが発生します。 次のセクションでは、いくつかのメモリ使用パターンについて説明し、説明します。

ディスプレイメモリ使用量アプリのサンプル

Memoryleak サンプルアプリは GitHub で入手できます。 MemoryLeak アプリ:

  • には、アプリのリアルタイムメモリおよび GC データを収集する診断コントローラーが含まれています。
  • には、メモリおよび GC データを表示するインデックスページがあります。 インデックスページは、1秒ごとに更新されます。
  • には、さまざまなメモリ読み込みパターンを提供する API コントローラーが含まれています。
  • はサポートされているツールではありませんが、ASP.NET Core アプリのメモリ使用量パターンを表示するために使用できます。

MemoryLeak を実行します。 割り当てられたメモリは、GC が発生するまで徐々に増加します。 データをキャプチャするためのカスタムオブジェクトがツールによって割り当てられるため、メモリが増加します。 次の図は、Gen 0 GC が発生したときの MemoryLeak インデックスページを示しています。 API コントローラーからの API エンドポイントが呼び出されていないため、グラフには0個の RPS (1 秒あたりの要求数) が表示されます。

Chart showing 0 Requests Per Second (RPS)

グラフには、メモリ使用量の2つの値が表示されます。

  • 割り当て済み: マネージオブジェクトによって占有されているメモリの量
  • Working set: 現在物理メモリに常駐しているプロセスの仮想アドレス空間にあるページのセット。 表示される作業セットは、タスクマネージャーに表示される値と同じです。

一時オブジェクト

次の API では、10 KB の String インスタンスが作成され、クライアントに返されます。 各要求では、新しいオブジェクトがメモリに割り当てられ、応答に書き込まれます。 文字列は .NET で UTF-16 文字として格納されるため、各文字はメモリ内で2バイトを取ります。

[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
    return new String('x', 10 * 1024);
}

次のグラフは、の負荷が比較的小さい場合に生成され、メモリ割り当てが GC によってどのように影響されているかを示します。

Graph showing memory allocations for a relatively small load

前のグラフは次を示しています。

  • 4K RPS (1 秒あたりの要求数)。
  • ジェネレーション0の GC コレクションは、約2秒ごとに発生します。
  • ワーキングセットは約 500 MB に固定されています。
  • CPU は12% です。
  • メモリ使用量と解放 (GC 経由) が安定しています。

次のグラフは、マシンで処理できる最大スループットで取得されます。

Chart showing max throughput

前のグラフは次を示しています。

  • 22K RPS
  • ジェネレーション0の GC コレクションは1秒間に数回発生します。
  • ジェネレーション1のコレクションがトリガーされるのは、アプリによって1秒あたりにかなり多くのメモリが割り当てられたためです。
  • ワーキングセットは約 500 MB に固定されています。
  • CPU は33% です。
  • メモリ使用量と解放 (GC 経由) が安定しています。
  • CPU (33%) は過剰に使用されていないため、ガベージコレクションは多くの割り当てを保持できます。

ワークステーション GC とサーバー GC

.NET ガベージコレクターには、次の2つの異なるモードがあります。

  • WORKSTATION GC: デスクトップ用に最適化されています。
  • サーバー GC。 ASP.NET Core アプリの既定の GC。 サーバーに合わせて最適化されます。

GC モードは、プロジェクト ファイルまたは発行されたアプリの runtimeconfig.json ファイルで明示的に設定できます。 次のマークアップは、プロジェクトファイルの設定を示してい ServerGarbageCollection ます。

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

プロジェクトファイルでを変更するに ServerGarbageCollection は、アプリを再構築する必要があります。

注: サーバーのガベージコレクションは、コアが1つのマシンでは使用でき ません 。 詳細については、IsServerGCを参照してください。

次の図は、ワークステーション GC を使用した 5K RPS のメモリプロファイルを示しています。

Chart showing memory profile for a Workstation GC

このグラフとサーバーのバージョンの違いは重要です。

  • ワーキング セットは 500 MB から 70 MB に低下します。
  • GC では、ジェネレーション 0 のコレクションが 2 秒ごとにではなく、1 秒あたりに複数回実行されます。
  • GC は 300 MB から 10 MB に低下します。

一般的な Web サーバー環境では、CPU 使用率はメモリよりも重要であるため、サーバー GC の方が優れたものになります。 メモリ使用率が高く、CPU 使用率が比較的低い場合、ワークステーション GC のパフォーマンスが向上する可能性があります。 たとえば、メモリが不足している複数の Web アプリをホストする高密度です。

ドッカーと小さなコンテナを使用した GC

1 台のコンピューターで複数のコンテナー化されたアプリが実行されている場合、ワークステーション GC はサーバー GC よりも形式が高い可能性があります。 詳しくは、「小さなコンテナでサーバーのGCを実行する」および「小さなコンテナでサーバーのGCを実行する シナリオ パート1 - GCヒープのハードリミット」を参照してください。

永続オブジェクト参照

GC は、参照されているオブジェクトを解放できません。 参照されているが不要になったオブジェクトは、メモリ リークの原因になります。 アプリが頻繁にオブジェクトを割り当て、不要になった後に解放できない場合、メモリ使用量は時間のと一度に増加します。

次の API では、10 KB の String インスタンスが作成され、クライアントに返されます。 前の例との違いは、このインスタンスが静的メンバーによって参照されている点です。つまり、コレクションに使用できません。

private static ConcurrentBag<string> _staticStrings = new ConcurrentBag<string>();

[HttpGet("staticstring")]
public ActionResult<string> GetStaticString()
{
    var bigString = new String('x', 10 * 1024);
    _staticStrings.Add(bigString);
    return bigString;
}

上記のコードでは次の操作が行われます。

  • 一般的なメモリ リークの例を示します。
  • 頻繁に呼び出す場合、OutOfMemory 例外でプロセスがクラッシュするまでアプリのメモリが増加します。

Chart showing a memory leak

上の図では、次の手順を実行します。

  • エンドポイントをロード テスト /api/staticstring すると、メモリが直線的に増加します。
  • GC は、ジェネレーション 2 コレクションを呼び出すことによって、メモリの圧力が大きくなるとメモリを解放します。
  • GC は、リークしたメモリを解放できません。 割り当て済みセットとワーキング セットは時間に応じて増加します。

キャッシュなどの一部のシナリオでは、メモリの圧力が強制的に解放されるまでオブジェクト参照を保持する必要があります。 クラス WeakReference は、この種類のキャッシュ コードに使用できます。 オブジェクト WeakReference はメモリの圧力の下で収集されます。 の既定の実装では、 が IMemoryCache 使用されます WeakReference

ネイティブ メモリ

一部の .NET Core オブジェクトは、ネイティブ メモリに依存します。 ネイティブ メモリ GC で収集できない。 ネイティブ メモリを使用する .NET オブジェクトは、ネイティブ コードを使用して解放する必要があります。

.NET には、IDisposable 開発者がネイティブ メモリを解放するインターフェイスが提供されています。 が呼 Dispose び出されていない場合でも、ファイナライザーの実行時にクラスが正 Dispose しく実装 されると が呼び出 されます。

次のコードがあるとします。

[HttpGet("fileprovider")]
public void GetFileProvider()
{
    var fp = new PhysicalFileProvider(TempPath);
    fp.Watch("*.*");
}

PhysicalFileProvider はマネージド クラスなので、要求の最後に任意のインスタンスが収集されます。

次の図は、API を継続的に呼び出している間のメモリ プロファイル fileprovider を示しています。

Chart showing a native memory leak

上のグラフは、メモリ使用量が増え続けているので、このクラスの実装に関する明らかな問題を示しています。 これは、この問題で追跡されている既知の 問題です

ユーザー コードでも、次のいずれかの方法で同じリークが発生する可能性があります:

  • クラスを正しく解放しません。
  • 破棄する必要がある依存オブジェクトの Dispose メソッドを呼び出すのを忘れています。

ラージ オブジェクト ヒープ

メモリ割り当て/空きサイクルが頻繁に行われると、特にメモリの大きなチャンクを割り当てるときに、メモリが断片化する可能性があります。 オブジェクトは、連続するメモリ ブロックに割り当てされます。 断片化を軽減するために、GC がメモリを解放すると、最適化が試みされます。 このプロセスは圧縮 と呼ばれる。 圧縮には、オブジェクトの移動が含まれます。 大きなオブジェクトを移動すると、パフォーマンスが低下します。 このため、GC はラージ オブジェクト ヒープ (LOH) と呼ばれる、ラージ オブジェクト用の特殊なメモリゾーンを作成します。 85,000 バイト (約 83 KB) を超えるオブジェクトは次のとおりです。

  • LOH に配置されます。
  • 圧縮されません。
  • 第 2 世代の GC の間に収集されます。

LOH が満たされると、GC によってジェネレーション 2 コレクションがトリガーされます。 第 2 世代コレクション:

  • 本質的に低速です。
  • さらに、他のすべての世代でコレクションをトリガーするコストが発生します。

次のコードは、LOH をすぐに圧縮します。

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

LargeObjectHeapCompactionModeLOH の圧縮については、「」を参照してください。

.NET Core 3.0 以降を使用するコンテナーでは、LOH は自動的に圧縮されます。

この動作を示す次の API:

[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
   return new byte[size].Length;
}

次のグラフは、最大負荷の下でエンドポイントを呼び出 /api/loh/84975 すメモリ プロファイルを示しています。

Chart showing memory profile of allocating bytes

次のグラフは、エンドポイントを呼び出し、さらに 1 バイトだけ割り当てる /api/loh/84976メモリ プロファイルを示しています

Chart showing memory profile of allocating one more byte

注: 構造体 byte[] にはオーバーヘッド バイトがあります。 84,976 バイトが 85,000 の制限をトリガーする理由です。

前の 2 つのグラフの比較:

  • ワーキング セットは、両方のシナリオ (約 450 MB) で似ています。
  • LOH 要求 (84,975 バイト) では、ほとんどの場合、ジェネレーション 0 のコレクションが表示されます。
  • OVER LOH 要求では、定数ジェネレーション 2 コレクションが生成されます。 第 2 世代コレクションは高価です。 より多くの CPU が必要であり、スループットはほぼ 50% 低下します。

一時的なラージ オブジェクトは、Gen2 の GC を引き起こすため、特に問題になります。

パフォーマンスを最大限に高くするには、大きなオブジェクトの使用を最小限に抑える必要があります。 可能であれば、大きなオブジェクトを分割します。 たとえば、 の応答キャッシュミドルウェアは ASP.NET Core 85,000 バイト未満のブロックに分割できます。

次のリンクは、オブジェクトを LOH ASP.NET Coreに保つ方法を示しています。

詳細については、以下を参照してください:

HttpClient

を誤って使用 HttpClient すると、リソース リークが発生する可能性があります。 データベース接続、ソケット、ファイル ハンドルなどのシステム リソース:

  • メモリよりも不足しています。
  • メモリよりもリークした場合に問題が発生します。

経験豊富な .NET 開発者は、 を実装するオブジェクトで Dispose を呼び出す方法を知っています IDisposable 。 を実装するオブジェクトを破棄しない場合、通常、メモリがリークしたり、システム IDisposable リソースがリークしたりします。

HttpClient は を IDisposable 実装しますが、 すべての 呼び出しで破棄すべきではありません。 ではなく、 HttpClient を再利用する必要があります。

次のエンドポイントは、要求ごとに新しい HttpClient インスタンスを作成して破棄します:

[HttpGet("httpclient1")]
public async Task<int> GetHttpClient1(string url)
{
    using (var httpClient = new HttpClient())
    {
        var result = await httpClient.GetAsync(url);
        return (int)result.StatusCode;
    }
}

読み込み時に、次のエラー メッセージがログに記録されます。

fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HLG70PBE1CR1", Request id "0HLG70PBE1CR1:00000031":
      An unhandled exception was thrown by the application.
System.Net.Http.HttpRequestException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted --->
    System.Net.Sockets.SocketException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted
   at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port,
    CancellationToken cancellationToken)

インスタンスが破棄された場合でも、実際のネットワーク接続は、オペレーティング システムによって解放されるのに HttpClient 時間がかかる場合があります。 新しい接続を継続的に作成することで 、ポートの枯渇が 発生します。 各クライアント接続には、独自のクライアント ポートが必要です。

ポートの枯渇を防ぐ 1 つの方法は、同じインスタンスを再利用 HttpClient する方法です。

private static readonly HttpClient _httpClient = new HttpClient();

[HttpGet("httpclient2")]
public async Task<int> GetHttpClient2(string url)
{
    var result = await _httpClient.GetAsync(url);
    return (int)result.StatusCode;
}

インスタンス HttpClient は、アプリが停止すると解放されます。 この例は、各使用後に破棄する必要がある破棄可能なリソースを示しています。

インスタンスの有効期間をより適切に処理する方法については、次を参照 HttpClient してください。

オブジェクト プーリング

前の例では、インスタンスを HttpClient 静的にし、すべての要求で再利用する方法を示しました。 再利用すると、リソースが使い切れなされます。

オブジェクト プール:

  • 再利用パターンを使用します。
  • 作成コストが高いオブジェクト用に設計されています。

プールは、スレッド間で予約および解放できる、事前に初期化されたオブジェクトのコレクションです。 プールでは、制限、定義済みのサイズ、増加率などの割り当て規則を定義できます。

このNuGet Microsoft.Extensions.ObjectPoolには、このようなプールの管理に役立つクラスが含まれています。

次の API エンドポイントは、各 byte 要求に乱数が入力されたバッファーをインスタンス化します。

        [HttpGet("array/{size}")]
        public byte[] GetArray(int size)
        {
            var random = new Random();
            var array = new byte[size];
            random.NextBytes(array);

            return array;
        }

次のグラフは、負荷が中程度の前の API の呼び出しを示しています。

Chart showing calls to API with moderate load

前のグラフでは、ジェネレーション 0 のコレクションは約 1 秒に 1 回発生します。

上のコードは、ArrayPool<T> を使用して byte バッファーをプールすることで最適化できます。 静的インスタンスは、要求間で再利用されます。

この方法の違いは、プールされたオブジェクトが API から返されるという意味です。 これは次のことを意味します。

  • オブジェクトは、 メソッドから戻ったとすぐにコントロールから出ます。
  • オブジェクトを解放できない。

オブジェクトの廃棄を設定するには:

RegisterForDispose は、HTTP 要求が完了した場合にのみ解放されるターゲット オブジェクト上の Dispose の呼び出しを処理します。

private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();

private class PooledArray : IDisposable
{
    public byte[] Array { get; private set; }

    public PooledArray(int size)
    {
        Array = _arrayPool.Rent(size);
    }

    public void Dispose()
    {
        _arrayPool.Return(Array);
    }
}

[HttpGet("pooledarray/{size}")]
public byte[] GetPooledArray(int size)
{
    var pooledArray = new PooledArray(size);

    var random = new Random();
    random.NextBytes(pooledArray.Array);

    HttpContext.Response.RegisterForDispose(pooledArray);

    return pooledArray.Array;
}

プールされていないバージョンと同じ負荷を適用すると、次のグラフが表示されます。

Chart showing fewer allocations

主な違いは、割り当てられたバイト数であり、その結果、ジェネレーション 0 のコレクションがはるかに少なになります。

その他のリソース