Start Debugging

dotnet-gcdump と dotnet-dump でマネージドメモリリークを診断する方法

.NET 11 でマネージドメモリリークを見つけるための完全な手順です。dotnet-counters で増加を確認し、gcdump を 2 回取得して差分を見て、その後ダンプを収集して dotnet-dump analyze の dumpheap、gcroot、objsize で参照を保持しているものを突き止めます。

.NET でマネージドメモリリークを診断するには、まず dotnet-counters monitor で増加が本物であることを確認し、次に dotnet-gcdump collect のスナップショットを数分間隔で 2 回取得してどの型の数が増えているかを確認し、そのうえで dotnet-dump collect を取得して dotnet-dump analyze の中で dumpheap -statdumpheap -type <Name>gcroot <address> を実行し、それらのオブジェクトを生かし続けている参照のチェーンを見つけます。gcdump はほとんどオーバーヘッドなしで 何が 増えているかを教えてくれ、ダンプは 誰が保持しているか を教えてくれます。この順番で両方が必要です。この記事では .NET 11 (執筆時点では Preview 6、GA は 2026 年 11 月) に対して dotnet-gcdumpdotnet-dump の 10.0 を使いますが、ここに出てくるコマンドはすべて .NET Core 3.1 以降で安定しています。

GC がここで助けてくれない理由

マネージドメモリリークは C 言語の意味でのリークではありません。解放されていないものは何もありません。ガベージコレクションは設計どおりに動作しています。ルートから到達可能なオブジェクトは回収されませんし、あなたのコードが数十万個のオブジェクトを誤って到達可能にしてしまったのです。ルートとは、静的フィールド、どこかのスレッドのスタック上で生きているローカル変数や引数、強い GC ハンドル、あるいはファイナライザーキューです。それ以外はすべて、そこから推移的に到達可能になっています。

つまり診断の問いは「なぜ GC が動かないのか」ではありません。「どのルートのチェーンがまだこのオブジェクトを指しているのか」です。以下のツールはすべて、その 1 つの問いに答えるために存在します。ASP.NET Core アプリでの典型的な原因は次のとおりです。

ステップ 0: 本当にリークがあることを証明する

マネージドヒープが時間とともに増えていくのを確認するまでは、何も収集しないでください。ワーキングセットの増加だけではマネージドリークとは言えません。ネイティブの割り当てかもしれませんし、断片化かもしれませんし、単に何も圧力をかけていないので GC が OS にメモリを返していないだけかもしれません。

ツールを一度インストールして PID を調べます。

# Verified with the .NET 11 SDK, July 2026
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-dump

dotnet-counters ps
# 4807  MyApi  /srv/myapi/MyApi

そして、プロセスではなくヒープを観察します。

dotnet-counters monitor --refresh-interval 5 --process-id 4807 \
  --counters System.Runtime[dotnet.gc.last_collection.heap.size,dotnet.process.memory.working_set]

.NET 9 以降では System.RuntimeMeter であり、カウンター名は上記のような OpenTelemetry スタイルになります。.NET 8 以前では dotnet-counters は従来の EventCounters にフォールバックするので、代わりに GC Heap Size (MB) を見てください。

重要なのは世代別に分解された dotnet.gc.last_collection.heap.size です。2 回の測定で、何を相手にしているかが分かります。

リークする最小の再現コード

両方のツールで見つけられる形でリークする、最小の ASP.NET Core サービスです。あるシングルトンが別のシングルトンのイベントを購読し、決して購読を解除しません。

// .NET 11, C# 14
public sealed class TelemetryBus
{
    public event EventHandler<string>? MetricRecorded;
    public void Record(string metric) => MetricRecorded?.Invoke(this, metric);
}

public sealed class ReportSession
{
    private readonly byte[] _buffer = new byte[64 * 1024];
    private readonly List<string> _log = [];

    public ReportSession(TelemetryBus bus)
    {
        // Nothing ever removes this handler, so `bus` roots every ReportSession
        // ever created, and each one roots 64 KB plus a growing List<string>.
        bus.MetricRecorded += OnMetric;
    }

    private void OnMetric(object? sender, string metric) => _log.Add(metric);
}

app.MapPost("/reports", (TelemetryBus bus) =>
{
    _ = new ReportSession(bus);   // per-request, never released
    return Results.Accepted();
});

TelemetryBus はシングルトンなので、その呼び出しリストはプロセスが生きている間ずっとルートに保持されます。すべての ReportSession はそのデリゲートから到達可能であり、したがってすべての byte[64*1024] も到達可能です。/reports に負荷をかけると、gen2 のヒープは永遠に増え続けます。

手順の全体

  1. マネージドヒープが増えていることを確認しますdotnet-counters monitor --counters System.Runtime[dotnet.gc.last_collection.heap.size] を実行し、特に gen2 を見ます。
  2. 基準となる gcdump を取得しますdotnet-gcdump collect --process-id <PID> --output baseline.gcdump を実行します。
  3. アプリを負荷のもとで動かし続けます。リークが明白になるだけの時間、通常は 5 分から 15 分です。
  4. 2 回目の gcdump を取得しますdotnet-gcdump collect --process-id <PID> --output after.gcdump を実行し、2 つの型ごとの個数を比較して増えている型を見つけます。
  5. 完全なダンプを収集します。何を探すべきか分かったら dotnet-dump collect --process-id <PID> --type Heap --output leak.dmp を実行します。
  6. ダンプを開きますdotnet-dump analyze leak.dmp を実行し、dumpheap -stat または dumpheap -type <TypeName> -stat で型を確認します。
  7. インスタンスのアドレスを 1 つ取得しますdumpheap -type <TypeName> の出力から取り、gcroot <address> を実行してルートからそのオブジェクトまでの参照チェーンを出力します。
  8. オブジェクトではなくチェーンを修正しますgcroot の出力であなたの型の直前にあるホップが、参照を保持している当のものです。

ステップ 2 から 4: gcdump という安価な最初の一手

dotnet-gcdump はプロセスダンプを書き出しません。gen2 のコレクションを誘発し、GC のヒープ生存イベントを有効にして、EventPipe のストリームからオブジェクトグラフを再構築します。結果として得られる .gcdump ファイルには型、個数、サイズ、辺が含まれますが、フィールドの値もスレッドのスタックも含まれません。同じプロセスの完全なダンプが数百 MB になるところ、通常は 1 桁 MB で済みます。

dotnet-gcdump collect --process-id 4807 --output baseline.gcdump
# Writing gcdump to './baseline.gcdump'...
#     Finished writing 5763432 bytes.

# ... let it run under load ...

dotnet-gcdump collect --process-id 4807 --output after.gcdump

比較に GUI は必要ありません。report 動詞はヒープ統計のテーブルを標準出力に直接出力するので、.gcdump ファイルを開く手段が何もない Linux でも使えます。

dotnet-gcdump report ./after.gcdump
#           Size (Bytes) Count       Type
#         ============== =====       ====
#          1,603,588,000 22,000,000  System.String
#            201,096,000  2,010,000  System.Byte[]
#             25,000,000    250,000  MyApi.Reports.ReportSession

両方のファイルに対して report を実行し、個数を比較してください。Windows であれば 2 つの .gcdump ファイルを Visual Studio で同時に開き、差分列付きの本格的な並列比較ビューを使えます。Windows マシンが手元にあるなら、その手間をかける価値はあります。PerfView でも読めます。現時点で Linux や macOS で .gcdump を開く方法はないので、そこでは dotnet-gcdump report が唯一の選択肢です。

report--process-id を直接受け取ることもできます。ファイルが不要なら、収集と表示を一度に済ませられます。

dotnet-gcdump report --process-id 4807

このステップが終わった時点で、型名が 1 つ手に入っているはずです。gcdump が提供してくれるのはそこまでです。

ステップ 5 から 7: ルートを見つける dotnet-dump

gcdump は、どの オブジェクト のどの フィールド が参照を保持しているかを教えてくれませんし、スレッドのスタックも見せてくれません。それには本物のダンプと SOS が必要です。

dotnet-dump collect --process-id 4807 --type Heap --output leak.dmp

--type の既定値は Full で、マップされたモジュールイメージまで含むため、たいていは必要以上に大きくなります。Heap ならモジュール一覧、スレッド一覧、すべてのスタック、例外情報とハンドル情報、そしてマップされたイメージ以外のすべてのメモリが得られ、この作業に必要なものはすべて揃います。Mini はクラッシュのトリアージ専用です。GC ヒープは含まれません。

続いて対話型の SOS シェルを開きます。

dotnet-dump analyze leak.dmp

まずは統計ビューから始めます。-live を付けると GC のマークフェーズが使われ、すでにガベージだがまだ回収されていないオブジェクトが除外されるので、ノイズが大きく減ります。

> dumpheap -stat -live

Statistics:
              MT    Count    TotalSize Class Name
00007f6c1dc014c0      467       416464 System.Byte[]
00007f6c20a67498   250000     16000000 MyApi.Reports.ReportSession
00007f6c1dc00f90   206770     19494060 System.String

同じコマンドの便利なバリエーションです。

次に具体的なアドレスを 1 つ取得して、そのルートをたどります。

> dumpheap -type MyApi.Reports.ReportSession
         Address               MT     Size
00007f6ad09421f8 00007f6c20a67498       32
...

> gcroot 00007f6ad09421f8

HandleTable:
    00007F6C98BB15F8 (pinned handle)
    -> 00007F6BDFFFF038 System.Object[]
    -> 00007F69D0033570 MyApi.Telemetry.TelemetryBus
    -> 00007F69D0033588 System.EventHandler`1[[System.String, System.Private.CoreLib]]
    -> 00007F69D00335A0 System.Object[]
    -> 00007F6AD0942258 MyApi.Reports.ReportSession

Found 1 root.

このチェーンは下から上へ読みます。リークしているオブジェクトが一番下、ルートが一番上です。あなたの型のすぐ上のホップが犯人であり、ここでは疑いようがありません。呼び出しリスト (System.Object[]) がすべてのセッションを保持している EventHandler<string> のマルチキャストデリゲートです。これは -= の対になっていない bus.MetricRecorded += OnMetric の行に直接対応します。

gcroot は既定では一意なルートだけを出力します。すべての経路が欲しいときは -all を、古いレジスタ値によるスタックスキャンの誤検出が出ているときは検索をハンドルと到達可能なオブジェクトに限定する -nostacks を渡してください。

この段階で知っておく価値のあるコマンドがあと 2 つあります。objsize <address> は、あるオブジェクトが推移的に保持しているものすべてを含めた保持サイズを報告します。「これは 32 バイトだ」を「これは 68 KB を生かし続けている」に変えてくれるのがこれです。そして dumpobj <address> はフィールドごとのレイアウトを出力するので、保持側のどのフィールドがこちらを指しているのかを確認できます。

> dumpobj 00007F69D0033570
Name:        MyApi.Telemetry.TelemetryBus
MethodTable: 00007f6c20a67498
Size:        24(0x18) bytes
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007f6c1dc00f90  4000001        8 ...EventHandler`1  0 instance 00007F69D0033588 MetricRecorded

半日を無駄にしがちな落とし穴

gcdump は完全なブロッキング gen2 コレクションを引き起こします。 それがヒープを走査する仕組みだからです。ヒープが大きいプロセスでは、ランタイムが長時間停止することがあります。レイテンシに敏感な本番インスタンスに対して短い間隔で繰り返し実行しないでください。実行するときはメトリクスに目に見えるポーズのスパイクが出ることを覚悟してください。

巨大なヒープでは gcdump が黙って失敗することがあります。 イベントバッファは対象アプリケーション側が所有し、最大 256 MB まで拡張されます。ヒープが十分に大きくてイベントが落ちると、System.ApplicationException: ETL file shows the start of a heap dump but not its completion が出るか、ヒープの一部しか含まない .gcdump が黙って生成されます。そうなったら gcdump は諦めて、直接 dotnet-dump collect に進んでください。

どちらのツールも同じユーザーと同じ TMPDIR を必要とします。 Linux と macOS では、--process-id--name はランタイムが TMPDIR 配下に作成する Unix ドメインソケットに接続することで動作します。ツールが別のユーザーで動いていたり、別の TMPDIR の下で動いていたりすると、コマンドは有用なエラーを出さないまま 30 秒でタイムアウトするだけです。対象プロセスと同じユーザー、または root で実行してください。

コンテナーでは ptrace が必要です。 dotnet-dump collect には ptrace の権限が必要で、通常は --cap-add=SYS_PTRACE で付与します。それとは別に、ヒープダンプや完全なダンプの収集は対象プロセスの大量の仮想メモリを OS にページインさせるため、メモリ制限のあるコンテナーが cgroup の上限を超え、収集の途中で OOM により停止させられることがあります。プラットフォームが許すなら、制限を引き上げるか一時的に外してください。

Free の行はオブジェクトではありません。 dumpheap -statFree の数が多いのは断片化であって、リークではありません。GC が圧縮していない生存オブジェクト間の隙間で、たいていは LOH 上にあります。別の問題であり、対処も別です (プーリング、ArrayPool<T>GCSettings.LargeObjectHeapCompactionMode など)。

キャッシュ型のリークは、コードのバグではなく設定のバグかもしれません。 増えている型が IMemoryCache の中に入っている自作の DTO なら、その「リーク」はたいてい暴走した参照ではなく、サイズ上限や有効期限ポリシーの設定漏れです。その判断はデバッガーではなく HybridCache と IMemoryCache と IDistributedCache の比較 の領域です。

自分のコードを疑う前にファイナライザーキューを確認してください。 解析シェルの finalizequeue は、ファイナライズ用に登録されたオブジェクトを一覧表示します。キューが詰まっているということは、ファイナライズ可能なオブジェクトが gen2 に昇格し、余分に 1 回分のコレクションサイクルのあいだ保持されているということで、グラフ上ではまさに緩やかなリークのように見えます。この場合の対処はほぼ常に確定的な破棄であり、それこそが IAsyncDisposable の実装と await using の利用 の目的です。

非同期ステートマシンは自分のルートを隠します。 増えている型が <SomeMethod>d__12 のようなコンパイラー生成の構造体なら、gcroot ではなく dumpasync -roots を使ってください。継続のチェーンを理解しているので、どの待機中のタスクがステートマシンを保持しているかを示してくれます。素の gcroot の走査では、これは TaskAction のオブジェクトが積み重なった読めない出力になってしまいます。

答えが出たあとにすること

gcroot が保持側を名指ししたら、あとの修正はごく普通のコードです。Dispose で購読を解除する。キャッシュにサイズ上限と有効期限を設定する。スコープ付きサービスをシングルトンに捕捉するのをやめ、代わりに BackgroundService の中で作業単位ごとにスコープを作成する。そのあとでステップ 1 から 4 を繰り返します。負荷をかけて動かし、gcdump を 2 回取り、その型の個数が横ばいであることを確認します。リークが直ったと言えるのは、2 回目の gcdump がそれを証明したときだけです。

参考資料: dotnet-gcdump リファレンスdotnet-dump リファレンスメモリリークのデバッグチュートリアルSOS デバッグ拡張機能dotnet-counters リファレンス

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る