作成者:Sébastien Ros、Rick Anderson
メモリ管理は、.NET などのマネージフレームワークでも複雑です。 メモリの問題の分析と解決は困難な場合があります。 メモリ リークとガベージ コレクション (GC) の問題は、一般的に、.NETでのメモリ使用量の動作に関する理解が不足しているか、使用状況を測定するプロセスを理解していないためです。
この記事では、問題になる可能性がある一般的なメモリ使用パターンを示し、別の方法を提案します。
.NETでガベージ コレクション (GC) を探索する
GC プロセスは、各セグメントが連続したメモリ範囲であるヒープ セグメントを割り当てます。 ヒープに配置されたオブジェクトは、0、1、または 2 の 3 つの世代のいずれかに分類されます。 生成によって、アプリが参照しなくなったマネージド オブジェクトのメモリ解放を GC が試行する頻度が決まります。 GC は、番号の小さいジェネレーションに頻繁に対処します。
オブジェクトは、有効期間に基づいて、ある世代から別の世代に移動されます。 オブジェクトが長く生きていると、より高い世代に移動されます。 前述のように、GC は上位の世代では実行頻度が低くなります。 有効期間の短いオブジェクトは常に Gen 0 に残ります。 たとえば、web 要求の有効期間中に参照されるオブジェクトは、短時間で終了します。 通常、アプリケーション レベル のシングルトンは Gen 2 に移行します。
ASP.NET Core アプリが起動すると、GC プロセスは次のようになります。
- 初期ヒープセグメント用にメモリを予約します。
- ランタイムが読み込まれるとき、メモリのごく一部をコミットします。
前のメモリ割り当ては、パフォーマンス上の理由から実行されます。 パフォーマンス上の利点は、連続したメモリのヒープセグメントから取得されます。
GC.Collect を使用する場合の注意事項を確認します。
一般に、ASP.NET Coreアプリは運用環境で使用しないほうが良いです、GC.Collect メソッドを明示的に指定しないことをお勧めします。 最適でないタイミングでガベージ コレクションを誘導すると、パフォーマンスが大幅に低下する可能性があります。
GC.Collect は、メモリ リークを調査するときに便利です。
GC.Collect() を呼び出すと、マネージド コードからアクセスできないすべてのオブジェクトを回収しようとするブロッキング ガベージ コレクション サイクルがトリガーされます。 これは、ヒープ内の到達可能なライブ オブジェクトのサイズを把握し、時間の経過に伴うメモリ サイズの増加を追跡する便利な方法です。
アプリのメモリ使用量を分析する
専用ツールは、次のようなメモリ使用量の分析に役立ちます。
- オブジェクト参照のカウント。
- GC が CPU 使用率に与える影響を測定します。
- 各世代に使用されるメモリ領域の測定。
メモリ使用量を分析するには、次のツールを使用します。
- dotnet-trace ユーティリティ (実稼働マシンで使用できます)
- Visual Studio デバッガーを使用せずにメモリ使用量を分析する
- Visual Studio でメモリ使用量を測定する
メモリの問題を検出する
タスクマネージャーを使用して、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 秒あたりの要求数) が表示されます。
グラフには、メモリ使用量の2つの値が表示されます。
- 割り当て済み: マネージド オブジェクトによって占有されるメモリの量。
- ワーキング セット: 現在物理メモリに常駐しているプロセスの仮想アドレス空間内のページのセット。 表示される作業セットは、タスクマネージャーに表示される値と同じです。 詳細については、「 ワーキング セット」を参照してください。
一時的なオブジェクト
次の API は、20 KB の文字列インスタンスを作成し、クライアントに返します。 各要求では、新しいオブジェクトがメモリに割り当てられ、応答に書き込まれます。 文字列は .NET で UTF-16 文字として格納されるため、各文字はメモリ内で2バイトを取ります。
[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
return new String('x', 10 * 1024);
}
次のグラフは、GC がメモリ割り当てにどのように影響するかを示す比較的小さな負荷で生成されます。
このグラフは、次の詳細を示しています。
- 4K RPS (1 秒あたりの要求数)
- Gen 0 GC コレクションは約 2 秒ごとに発生します
- 一定の作業セット、約 500 MB
- CPU は 12%
- 安定したメモリ消費と解放 (GC を通じて)
次のグラフは、マシンが処理できる最大スループットで取得されます。
このグラフは、次の詳細を示しています。
- 22 K RPS
- Gen 0 GC コレクションは 1 秒あたり数回発生します
- Gen 1 コレクションは、アプリが 1 秒あたりに大幅に多くのメモリを割り当てるためにトリガーされます
- 定常ワーキングセット、約500MB
- CPU は 33%
- 安定したメモリ消費と解放 (GC を通じて)
- CPU (33%) が過剰に使用されていないため、GC は多数の割り当てに対応できます
ワークステーション GC とサーバー GC
.NET ガベージコレクターには、次の2つの異なるモードがあります。
- WORKSTATION GC: デスクトップ用に最適化されています。
- Server GC: ASP.NET Core アプリの既定の GC。 サーバーに合わせて最適化されます。
GC モードは、プロジェクト ファイルまたは発行済みアプリの runtimeconfig.json ファイルで明示的に設定できます。 次のマークアップは、プロジェクトファイルの設定ServerGarbageCollectionを示しています。
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
プロジェクトファイルのServerGarbageCollectionを変更すると、アプリを再構築する必要があります。
Note
サーバー ガベージ コレクションは、1 つのコアを持つマシンでは使用 できません 。 詳細については、 IsServerGC プロパティを参照してください。
次の図は、ワークステーション GC を使用した 5K RPS のメモリプロファイルを示しています。
このグラフとサーバーのバージョンの違いは重要です。
- ワーキング セットが 500 MB から 70 MB に低下する
- GC では、2 秒ごとではなく、Gen 0 コレクションが 1 秒あたり複数回実行されます
- GC が 300 MB から 10 MB に低下する
一般的な Web サーバー環境では、CPU 使用率はメモリよりも重要であるため、サーバー GC の方が優れたものになります。 メモリ使用率が高く、CPU 使用率が比較的低い場合、ワークステーション GC のパフォーマンスが向上する可能性があります。 たとえば、メモリが不足している複数の Web アプリをホストする高密度です。
ドッカーと小さなコンテナを使用した GC
コンテナー化された複数のアプリが同じコンピューター上で実行されている場合、ワークステーション GC はサーバー GC よりもパフォーマンスが高い可能性があります。 詳細については、「 小さなコンテナーでのサーバー GC の実行 (ブログ) 」および 「小さなコンテナー シナリオでのサーバー GC での実行」パート 1 – GC ヒープのハード制限 (ブログ) を参照してください。
永続オブジェクト参照
GC では、参照されているオブジェクトを解放できません。 参照されているが不要になったオブジェクトは、メモリ リークの原因になります。 アプリがオブジェクトを頻繁に割り当て、不要になった後に解放できない場合、メモリ使用量は時間の経過とともに増加します。
次の API は、20 KB の文字列インスタンスを作成し、クライアントに返します。 前の例との違いは、静的メンバーがこのインスタンスを参照していることです。つまり、インスタンスはコレクションに使用できません。
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例外でプロセスがクラッシュするまでアプリのメモリが増加します。
このグラフは、次の詳細を示しています。
-
/api/staticstringエンドポイントのロード テストにより、メモリが線形に増加する - GC は、Gen 2 コレクションを呼び出すことによってメモリ不足が大きくなるとメモリを解放しようとします
- GC ではリークしたメモリを解放できず、割り当て済みメモリとワーキング セットが時間とともに増加します。
キャッシュなどの一部のシナリオでは、メモリの圧力が強制的に解放されるまでオブジェクト参照を保持する必要があります。 クラス WeakReference は、この種類のキャッシュ コードに使用できます。 オブジェクト WeakReference はメモリの圧力の下で収集されます。
IMemoryCache インターフェイスの既定の実装では、WeakReferenceが使用されます。
ネイティブ メモリ
一部の.NET オブジェクトはネイティブ メモリに依存しますが、ネイティブ メモリは GC によって収集できません。 ネイティブ メモリを使用する.NET オブジェクトは、ネイティブ コードを使用して解放する必要があります。
.NET には、IDisposable 開発者がネイティブ メモリを解放するインターフェイスが提供されています。
Dispose メソッドが呼び出されていない場合でも、ファイDisposeの実行時に正しく実装されたクラスが呼び出されます。
次のコードがあるとします。
[HttpGet("fileprovider")]
public void GetFileProvider()
{
var fp = new PhysicalFileProvider(TempPath);
fp.Watch("*.*");
}
PhysicalFileProvider はマネージド クラスであるため、すべてのインスタンスは要求の最後に収集されます。
次の図は、API を継続的に呼び出している間のメモリ プロファイル fileprovider を示しています。
上のグラフは、メモリ使用量が増え続けているので、このクラスの実装に関する明らかな問題を示しています。 この結果は、GitHub dotnet/aspnetcore 問題 #844 で追跡される既知の問題です。
次のシナリオでは、ユーザー コードでも同じリークが発生します。
- クラスを正しく解放していない
- 破棄する必要がある依存オブジェクトの
Disposeメソッドを呼び出すのを忘れる
ラージ オブジェクト ヒープ
メモリの割り当て/解放サイクルが頻繁に発生すると、特に大きなメモリ チャンクを割り当てるときに、メモリが断片化します。 オブジェクトは、連続するメモリ ブロックに割り当てされます。 断片化を軽減するために、GC がメモリを解放する際に、そのメモリのデフラグメンテーションが試みられます。 このプロセスは圧縮 と呼ばれる。 圧縮には、オブジェクトの移動が含まれます。 大きなオブジェクトを移動すると、パフォーマンスペナルティが発生します。 このため、GC は 、ラージ オブジェクト ヒープ (LOH) と呼ばれる、 ラージ オブジェクト 用の特別なメモリ ゾーンを作成します。 85,000 バイト (約 83 KB) を超えるオブジェクトは次のとおりです。
- LOHに配置されました
- 圧縮されていない
- Gen 2 コレクション中に処理される
LOH がいっぱいになると、GC によって Gen 2 コレクションがトリガーされます。
- Gen 2 コレクションは生来の遅さがあります。
- 他のすべての世代でコレクションをトリガーするコストが発生する可能性があります。
次のコードは、LOH をすぐに圧縮します。
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
LOH の圧縮については、 LargeObjectHeapCompactionMode プロパティを参照してください。
.NET Core 3.0 以降を使用するコンテナーでは、LOH は自動的に圧縮されます。
この動作を示す次の API:
[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
return new byte[size].Length;
}
次のグラフは、最大負荷の下でエンドポイントを呼び出 /api/loh/84975 すメモリ プロファイルを示しています。
次のグラフは、エンドポイントを呼び出し、さらに 1 バイトだけ割り当てる /api/loh/84976メモリ プロファイルを示しています。
Note
byte[]構造体にはオーバーヘッド バイトがあり、84,976 バイトが 85,000 の制限をトリガーする理由です。
前の 2 つのグラフの比較:
- ワーキング セットは両方のシナリオで似ています。約 450 MB
- LOH 要求 (84,975 バイト) では、主に表示されるのは Gen 0 コレクションです。
- LOH 要求により、Gen 2 コレクションが絶えず生成され、これにより高いコストが発生します。 より多くのCPUが必要であり、スループットは約50%低下します。
一時的なラージ オブジェクトは Gen 2 コレクションを引き起こすため、問題があります。
パフォーマンスを最大限に高めるには、大きなオブジェクトの使用を最小限に抑えます。 可能であれば、大きなオブジェクトを分割します。 たとえば、ASP.NET Core の Response Caching Middleware は、キャッシュ エントリを 85,000 バイト未満のブロックに分割します。
次のリンクは、ASP.NET CoreでオブジェクトをLOH制限以下に維持する方法を紹介しています。
詳細については、以下を参照してください:
HttpClient
HttpClient クラスを誤って使用すると、リソース リークが発生する可能性があります。
システム リソース (データベース接続、ソケット、ファイル ハンドルなど) には、次の 2 つの問題があります。
- メモリよりも不足しています。
- それらはメモリよりも、リークした場合により深刻な問題を引き起こします。
経験豊富な.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 の呼び出しを示しています。
このグラフでは、Gen 0 コレクションが 1 秒に 1 回程度発生することを示しています。
このコードは、> クラスを使用して バッファーをプールすることによって最適化できます。 静的インスタンスは、要求間で再利用されます。
この方法の違いは、プールされたオブジェクトが API から返されるということです。
- オブジェクトは、 メソッドから戻ったとすぐにコントロールから出ます。
- オブジェクトを解放できない。
オブジェクトの廃棄を設定するには:
- プールされた配列を、使い捨て可能なオブジェクトにカプセル化します。
- HttpContext.Response.RegisterForDispose メソッドを使用して、プールされたオブジェクトを登録します。
RegisterForDispose はターゲット オブジェクトに対する Dispose の呼び出しを処理するため、オブジェクトは HTTP 要求が完了した後にのみ解放されます。
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;
}
プールされていないバージョンと同じ負荷を適用すると、次のグラフが表示されます。
主な違いは、割り当てられたバイト数であり、その結果、Gen 0 コレクションが少なくなります。
関連するコンテンツ
ASP.NET Core