mcpField
D
dotnet-gc-memory

dotnet-gc-memory

github.com/wshaddix開発8684 回インストール35 週間前に更新
リポジトリ

dotnet-gc-memory について

.NET のガベージコレクションとメモリ管理を扱う開発者向けスキルです。GC モード、世代別ヒープ、バッファープール、Span/Memory の所有権、弱参照、メモリ分析ツールを解説します。

主な機能

  1. 01GC モードと設定
  2. 02Gen0・Gen1・Gen2 の管理
  3. 03LOH と POH の利用
  4. 04ArrayPool と MemoryPool
  5. 05Span/Memory の所有権パターン
  6. 06弱参照とリソース解放

活用シーン

  1. 01サービスの GC モードを調整
  2. 02大きなバッファーの割り当てを削減
  3. 03非同期 I/O バッファーの所有権を管理
  4. 04キャッシュやアンマネージリソースの寿命を検討

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-gc-memory

インストール後、Claude Code で/dotnet-gc-memoryを入力して読み込まれたか確認します。

詳細

dotnet-gc-memory

.NET アプリケーションのガベージコレクションとメモリ管理について解説します。GC モード(ワークステーションとサーバー、並行と非並行)、ラージオブジェクトヒープ(LOH)とピン留めオブジェクトヒープ(POH)、世代別の調整(Gen0/1/2)、メモリプレッシャー通知、基本を超えた Span/Memory の所有権パターン、ArrayPool と MemoryPool を使ったバッファープーリング、弱参照、ファイナライザーと IDisposable の比較、dotMemory と PerfView を使ったメモリプロファイリングを扱います。

対象外: Span/Memory の構文の紹介と基本的な使い方については、[skill:dotnet-performance-patterns] を参照してください。マイクロベンチマークのセットアップについては、[skill:dotnet-benchmarkdotnet] を参照してください。CLI 診断ツール(dotnet-counters、dotnet-trace、dotnet-dump)については、[skill:dotnet-profiling] を参照してください。Channel のプロデューサー/コンシューマーパターンについては、[skill:dotnet-channels] を参照してください。

相互参照:[skill:dotnet-performance-patterns] では Span/Memory の基本と sealed によるデバーチャライズを、[skill:dotnet-profiling] ではランタイム診断ツール(dotnet-counters、dotnet-trace、dotnet-dump)を、[skill:dotnet-channels] ではメモリ管理に関係するバックプレッシャーパターンを、[skill:dotnet-file-io] では MemoryMappedFile の使用方法とファイル I/O における POH バッファーパターンを解説しています。


GC モードと構成

ワークステーション GC とサーバー GC
項目ワークステーションサーバー
GC スレッド単一スレッド論理コアごとに 1 スレッド
ヒープセグメント単一ヒープコアごとに 1 ヒープ
一時停止のレイテンシ低い高い(スキャンするメモリが多い)
スループット低い高い
デフォルトの対象コンソールアプリ、デスクトップアプリASP.NET Core Web アプリ

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
// Or in runtimeconfig.json
{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Server": true
    }
  }
}
並行 GC と非並行 GC
モード動作使用する場面
並行(デフォルト)Gen2 コレクションがアプリケーションスレッドと並行して実行されますレイテンシが重視される場合(Web API、UI)
非並行Gen2 コレクション中、アプリケーションスレッドが一時停止します最大スループットが必要な場合、バッチ処理
{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Concurrent": true
    }
  }
}
DATAS(アプリケーションサイズへの動的適応) -- .NET 8+

DATAS は、アプリケーションのメモリ使用パターンに基づいて GC ヒープサイズを動的に調整します。.NET 8+ のサーバー GC モードでは、デフォルトで有効です。DATAS は、低アクティビティ期間中にヒープサイズを縮小することで、負荷が変動するアプリケーションのメモリ使用量を削減します。

{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.DynamicAdaptationMode": 1
    }
  }
}

定常状態のワークロードで GC の頻度が過剰になっている場合は、0 に設定して DATAS を無効にしてください。

GC リージョン -- .NET 7+

リージョンは、従来のセグメントベースのヒープ管理に代わるものです。各リージョンは小さな固定サイズのメモリブロックで、GC は個別に割り当てたり解放したりできます。リージョンは .NET 7+ でデフォルトで有効になっており、次の点が改善されます。

  • 使用量の急増後にメモリを OS に返却する
  • ヒープのコンパクション効率
  • 多数のコアを搭載したマシンでのサーバー GC のスケーラビリティ

構成は不要です。リージョンはデフォルトで有効です。セグメントに戻すには(必要になることはまれです)。

{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Regions": false
    }
  }
}

世代別 GC(Gen0/1/2)

世代の仕組み
世代含まれるものコレクションの頻度コレクションのコスト
Gen0新しく割り当てられたオブジェクト非常に頻繁(ミリ秒単位)非常に低い(小さなヒープ)
Gen1Gen0 を生き残ったオブジェクト頻繁低い
Gen2長期間存続するオブジェクトまれ高い(ヒープ全体をスキャン)

オブジェクトはコレクションを生き残るにつれて、Gen0 から Gen1、Gen2 へ昇格します。Gen0 の GC 予算は動的に調整されます。Gen0 がいっぱいになると、Gen0 コレクションが実行されます。

チューニングの原則
  1. Gen0 の割り当て率を最小限に抑える -- ホットパスで一時オブジェクトの生成を減らします。割り当てはすべて Gen0 の負荷につながります。
  2. オブジェクトの「中年の危機」を避ける -- Gen1/Gen2 に昇格する程度の期間だけ生存し、その後ガベージになるオブジェクトは、最もコストが高くつきます。安価な Gen0 コレクションを生き延び、回収には高コストの Gen2 コレクションが必要になります。
  3. Gen2 コレクションの頻度を減らす -- Gen2 コレクションは最も長い一時停止を引き起こします。オブジェクトプーリング、Span、値型を使用して、長期間生存するヒープ割り当てを低く抑えます。
世代の監視
# Real-time GC metrics
dotnet-counters monitor --process-id <PID> \
  --counters System.Runtime[gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,gc-heap-size]
// Programmatic GC observation
var gen0 = GC.CollectionCount(0);
var gen1 = GC.CollectionCount(1);
var gen2 = GC.CollectionCount(2);
var totalMemory = GC.GetTotalMemory(forceFullCollection: false);
var memoryInfo = GC.GetGCMemoryInfo();

logger.LogInformation(
    "GC: Gen0={Gen0} Gen1={Gen1} Gen2={Gen2} Heap={HeapMB:F1}MB",
    gen0, gen1, gen2, totalMemory / (1024.0 * 1024));

ラージオブジェクトヒープ(LOH)とピン留めオブジェクトヒープ(POH)

LOH

85,000 バイト以上のオブジェクトは LOH に割り当てられます。LOH のコレクションは Gen2 コレクション時にのみ行われ、既定では LOH はコンパクションされないため、断片化が発生します。

// Force LOH compaction (use sparingly -- expensive)
GCSettings.LargeObjectHeapCompactionMode =
    GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
LOH の断片化を防ぐ
戦略実装
大きな配列には ArrayPool を使用ArrayPool<byte>.Shared.Rent(100_000)
IMemoryOwner パターンには MemoryPool を使用MemoryPool<byte>.Shared.Rent(100_000)
事前に割り当てて再利用する起動時に大きなバッファーを一度だけ作成します
大きな文字列の頻繁な連結を避けるStringBuilder または string.Create を使用します
POH(ピン留めオブジェクトヒープ) -- .NET 5+

POH は、固定メモリーアドレスに留まる必要がある(ピン留めされた)オブジェクト専用のヒープです。.NET 5 より前は、通常のヒープ上のオブジェクトをピン留めすると、コンパクションが妨げられていました。POH はピン留めされたオブジェクトを分離するため、Gen0/1/2 ヒープのコンパクションを妨げません。

// Allocate on POH -- useful for I/O buffers passed to native code
byte[] buffer = GC.AllocateArray<byte>(4096, pinned: true);

// The buffer's address will not change, safe for native interop
// and overlapped I/O without explicit GCHandle pinning

POH は次の用途に使用します。

  • ネイティブ/アンマネージコードに渡す I/O バッファー
  • メモリーマップドファイルのバッキング配列
  • Socket.ReceiveAsync(オーバーラップ I/O)で使用するバッファー

Span/Memory の詳細な所有権パターン

Span/Memory の概要と基本的なスライスについては、[skill:dotnet-performance-patterns] を参照してください。このセクションでは、共有バッファーの所有権セマンティクスとライフタイム管理について説明します。

プールされたバッファーのための IMemoryOwner
// Rent from MemoryPool and manage lifetime with IDisposable
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
Memory<byte> buffer = owner.Memory[..4096]; // Slice to exact size needed

// Pass the Memory<T> to async I/O
int bytesRead = await stream.ReadAsync(buffer, cancellationToken);
Memory<byte> data = buffer[..bytesRead];

// Process the data
await ProcessDataAsync(data, cancellationToken);
// owner.Dispose() returns the buffer to the pool
所有権の移譲パターン

コンポーネント間でバッファーの所有権を移譲する際は、IMemoryOwner<T> を使用してライフタイムに関する責任を明示します。

public sealed class MessageParser
{
    // Caller transfers ownership -- this method is responsible for disposal
    public async Task ProcessAsync(
        IMemoryOwner<byte> messageOwner,
        CancellationToken ct)
    {
        using (messageOwner)
        {
            Memory<byte> data = messageOwner.Memory;
            // Parse and process...
            await HandleMessageAsync(data, ct);
        }
        // Buffer returned to pool on dispose
    }
}
Span のスタック規律
// Span<T> enforces stack-only usage (ref struct)
// These are compile-time errors:
// Span<byte> field;              // Cannot store in class/struct field
// async Task Foo(Span<byte> s);  // Cannot use in async method
// var list = new List<Span<byte>>(); // Cannot use as generic type argument

// When you need heap storage or async, use Memory<T> instead
public async Task ProcessAsync(Memory<byte> buffer, CancellationToken ct)
{
    // Can use Memory<T> in async methods
    int bytesRead = await stream.ReadAsync(buffer, ct);

    // Convert to Span<T> for synchronous processing within a method
    Span<byte> span = buffer.Span;
    ParseHeader(span[..bytesRead]);
}

ArrayPool と MemoryPool

ArrayPool

ArrayPool<T> は配列割り当てを再利用して GC の負荷を軽減します。借り受けた配列は必ず返却し、返却される配列が要求したサイズと正確に同じだと想定しないでください。

// Rent and return pattern
byte[] buffer = ArrayPool<byte>.Shared.Rent(minimumLength: 4096);
try
{
    // IMPORTANT: Rented array may be larger than requested
    int bytesRead = await stream.ReadAsync(
        buffer.AsMemory(0, 4096), cancellationToken);
    ProcessData(buffer.AsSpan(0, bytesRead));
}
finally
{
    // clearArray: true when buffer contained sensitive data
    ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
}
カスタムプールのサイズ設定
// Create a custom pool for specific allocation patterns
var pool = ArrayPool<byte>.Create(
    maxArrayLength: 1_048_576,  // 1 MB max array
    maxArraysPerBucket: 50);    // Keep up to 50 arrays per size bucket

// Use for workloads with predictable buffer sizes
byte[] buffer = pool.Rent(65_536);
try
{
    // Process...
}
finally
{
    pool.Return(buffer);
}
MemoryPool

MemoryPool<T> は ArrayPool<T> をラップし、RAII スタイルのライフタイム管理を実現する IMemoryOwner<T> を返します。

// MemoryPool returns IMemoryOwner<T> -- dispose to return
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(8192);
Memory<byte> buffer = owner.Memory;

// Slice to exact size (owner.Memory may be larger)
int bytesRead = await stream.ReadAsync(buffer[..8192], ct);
await ProcessAsync(buffer[..bytesRead], ct);
// Dispose returns the underlying array to the pool
プールの使用ガイドライン
ガイドライン理由
借り受けたバッファーは必ず finally または using で返却するバッファーの返却漏れはプーリングの目的を損ないます
処理前に正確なサイズにスライスする借り受けた配列は要求したサイズより大きい場合があります
機密データには clearArray: true を使用するプールの再利用によって、他の利用者に秘密情報が漏れる可能性があります
借り受けた配列を長期間生存するフィールドにキャッシュしないプールのバッファーを無期限に保持し、利用可能性を低下させます
生の ArrayPool<T> より MemoryPool<T> を優先する破棄ベースのライフタイム管理のほうが誤用しにくくなります

弱参照とキャッシュ

WeakReference

弱参照を使用すると、強参照が残っていない場合に GC が対象オブジェクトを回収できます。メモリー圧迫時の回収が許容されるキャッシュに使用します。

public sealed class ImageCache
{
    private readonly ConcurrentDictionary<string, WeakReference<byte[]>> _cache = new();

    public byte[]? TryGet(string key)
    {
        if (_cache.TryGetValue(key, out var weakRef)
            && weakRef.TryGetTarget(out var data))
        {
            return data;
        }
        return null;
    }

    public void Set(string key, byte[] data)
    {
        _cache[key] = new WeakReference<byte[]>(data);
    }

    // Periodically clean up dead references
    public void Purge()
    {
        foreach (var key in _cache.Keys)
        {
            if (_cache.TryGetValue(key, out var weakRef)
                && !weakRef.TryGetTarget(out _))
            {
                _cache.TryRemove(key, out _);
            }
        }
    }
}
弱参照を使用する場面
  • メモリー圧迫時に追い出しが行われるべき大きなオブジェクトのキャッシュ
  • 計算コストは高いものの再作成可能なデータ(画像サムネイル、レンダリング済みテンプレート)のキャッシュ
  • 小さなオブジェクトには使用しないでください -- WeakReference<T> のオーバーヘッドが利点を上回ります

ほとんどのキャッシュ用途では、サイズ制限と有効期限ポリシーを設定した MemoryCache を優先してください。弱参照は、GC による追い出しが必要な場合の最後の手段です。


ファイナライザーと IDisposable

IDisposable(推奨)

アンマネージドリソースを確実に解放するため、IDisposable を実装します。

public sealed class NativeBufferWrapper : IDisposable
{
    private IntPtr _handle;
    private bool _disposed;

    public NativeBufferWrapper(int size)
    {
        _handle = Marshal.AllocHGlobal(size);
    }

    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;

        Marshal.FreeHGlobal(_handle);
        _handle = IntPtr.Zero;
        // No GC.SuppressFinalize needed -- no finalizer
    }
}
ファイナライザー(セーフティネットとしてのみ使用)

オブジェクトが回収されると、GC のファイナライザースレッドでファイナライザーが実行されます。明示的に破棄されなかったアンマネージドリソースに対するセーフティネットです。

public class UnmanagedResourceHolder : IDisposable
{
    private IntPtr _handle;
    private bool _disposed;

    public UnmanagedResourceHolder(int size)
    {
        _handle = Marshal.AllocHGlobal(size);
    }

    ~UnmanagedResourceHolder()
    {
        Dispose(disposing: false);
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;
        _disposed = true;

        if (disposing)
        {
            // Free managed resources
        }

        // Free unmanaged resources
        if (_handle != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(_handle);
            _handle = IntPtr.Zero;
        }
    }
}
ファイナライザーのコスト
コスト影響
ファイナライザーを持つオブジェクトは、少なくとも 1 回余分に GC を生き延びますGen1/Gen2 に昇格し、メモリ負荷が増加します
ファイナライザースレッドはシングルスレッドです処理の遅いファイナライザーが、ほかのすべてのファイナライズをブロックします
実行順序は非決定的ですほかのファイナライズ対象オブジェクトに依存できません
プロセス終了時に実行される保証はありません重要なクリーンアップが実行されない可能性があります

ルール: アンマネージドハンドルを所有していない限り、ファイナライザーを持たない IDisposable 実装の sealed クラスを使用してください。アンマネージドリソースのセーフティネットとしてのみ、ファイナライザーを追加します。


メモリ負荷の通知

GC.AddMemoryPressure/RemoveMemoryPressure

アンマネージドメモリの割り当てを GC に通知し、コレクションの判断に反映させます。

public sealed class NativeImageBuffer : IDisposable
{
    private readonly IntPtr _buffer;
    private readonly long _size;
    private bool _disposed;

    public NativeImageBuffer(long sizeBytes)
    {
        _size = sizeBytes;
        _buffer = Marshal.AllocHGlobal((IntPtr)sizeBytes);
        GC.AddMemoryPressure(sizeBytes);
    }

    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;

        Marshal.FreeHGlobal(_buffer);
        GC.RemoveMemoryPressure(_size);
    }
}
適応的な動作のための GC.GetGCMemoryInfo
// React to memory pressure in application logic
var memoryInfo = GC.GetGCMemoryInfo();
double loadPercent = (double)memoryInfo.MemoryLoadBytes
    / memoryInfo.TotalAvailableMemoryBytes * 100;

if (loadPercent > 85)
{
    logger.LogWarning("High memory pressure: {Load:F1}%", loadPercent);
    // Shed load: reduce cache sizes, reject non-critical requests
}

メモリのプロファイリング

dotMemory(JetBrains)

dotMemory は、ヒープスナップショットと割り当て追跡を、視覚的な UI で提供します。メモリリークや割り当ての多いホットパスの調査に使用します。

ワークフロー:

  1. 実行中のプロセスに dotMemory をアタッチします(またはプロファイリングを有効にして起動します)
  2. アプリケーションのウォームアップ後にベースラインのスナップショットを取得します
  3. 調査対象のシナリオを実行します
  4. 2 つ目のスナップショットを取得します
  5. スナップショットを比較し、保持されているオブジェクトと増加量を特定します

主なビュー:

  • Sunburst -- 型の階層ごとの割り当てツリーを表示します
  • Dominator tree -- 保持メモリが GC されるのを妨げているオブジェクトを表示します
  • Survived objects -- スナップショット間に割り当てられ、GC を生き延びたオブジェクトを表示します
PerfView

PerfView は、GC と割り当てを詳細に分析するための Microsoft 製無料ツールです。オーバーヘッドの小さいプロファイリングに、ETW(Event Tracing for Windows)イベントを使用します。

# Collect GC and allocation events for 30 seconds
PerfView.exe /GCCollectOnly /MaxCollectSec:30 collect

# Collect allocation stacks (higher overhead)
PerfView.exe /ClrEvents:GC+Stack /MaxCollectSec:30 collect

PerfView の主なビュー:

  • GCStats -- GC の一時停止時間、世代ごとの回数、昇格率、断片化を表示します
  • GC Heap Alloc Stacks -- 割り当ての原因となったコールスタックを表示します
  • Any Stacks -- ホットメソッドを特定するための CPU サンプリングを表示します
プロファイリングのワークフロー
  1. 症状を特定する -- メモリ使用量の増加、Gen2 の増加、Gen2 コレクションの頻発、LOH の断片化
  2. dotnet-counters で監視する([skill:dotnet-profiling] を参照)し、GC メトリクスが症状と一致することを確認します
  3. dotMemory または PerfView でプロファイリングするし、オブジェクトと割り当て箇所を特定します
  4. 修正を適用する -- バッファーをプールし、Span を使用し、割り当てを減らし、リークを修正します
  5. BenchmarkDotNet の [MemoryDiagnoser] で検証する([skill:dotnet-benchmarkdotnet] を参照)し、改善を確認します
  6. 本番環境で監視する -- OpenTelemetry のランタイムメトリクスを使用します([skill:dotnet-observability] を参照)

エージェントが陥りやすい落とし穴

  1. ASP.NET Core アプリケーションでワークステーション GC を既定にしないでください -- サーバー GC が既定であり、Web ワークロードに適した選択です。ワークステーション GC は、マルチコアサーバーでスループットが低下します。特定のレイテンシ重視のシナリオでのみ、設定を上書きしてください。
  2. ArrayPool のバッファーを返し忘れないでください -- プールのバッファーを返さずに放置すると、プールの容量が無期限に占有されるため、通常の割り当てよりも問題になります。必ず try/finally を使うか、IMemoryOwner<T> を using と組み合わせてください。
  3. レンタルした配列が要求したサイズだと思い込まないでください -- ArrayPool<T>.Rent() は、要求したサイズより大きな配列を返すことがあります。処理する前に、必ず必要なサイズに正確にスライスしてください。
  4. マネージドリソースのみを使用するクラスにファイナライザーを追加しないでください -- ファイナライザーがあると、オブジェクトは Gen1/Gen2 に昇格し、GC のオーバーヘッドが増加します。マネージドリソースのみのクリーンアップには、ファイナライザーのない IDisposable を実装した sealed class を使用してください。
  5. 本番コードで GC.Collect() を呼び出さないでください -- フルコレクションを強制すると、長い一時停止が発生し、GC の動的なチューニングが妨げられます。アンマネージドメモリについては、GC.AddMemoryPressure() を使って通知してください。
  6. LOH の断片化を無視しないでください -- 85,000 バイト以上の大きな配列を繰り返し割り当てて解放すると、LOH が断片化します。新しい配列を割り当てるのではなく、ArrayPool<T> を使って大きなバッファーをレンタルし、返却してください。
  7. 破棄を追跡せずに、長期間存続するフィールドに IMemoryOwner をキャッシュしないでください -- 基盤となるプールバッファーが無期限に保持され、プールで再利用できなくなります。所有権を明示的に移すか、キャッシュの有効期間を制限してください。

参考資料

コメント