Read OSS

Goのメモリ管理:アロケータ階層と並行ガベージコレクタ

上級

前提知識

  • 第1〜4回:リポジトリ概要からスケジューラまで
  • ガベージコレクションの基礎理論(三色マーキング、ライトバリア、並行コレクション)
  • 仮想メモリとページレベルのアロケーションに関する基本的な理解

Goのメモリ管理:アロケータ階層と並行ガベージコレクタ

Go のメモリ管理は、2つの要求の間でバランスをとり続けています。一方はアロケーション速度——makenew、複合リテラルが呼ばれるたびに発生するオーバーヘッドをいかに削るか。もう一方はコレクション効率——長いポーズなしにメモリを回収できるかです。これを実現するのが、一般的なケースでロックを回避する階層型アロケータと、プログラムと並行して動作するガベージコレクタの組み合わせです。本記事では、この2つの仕組みを詳しく見ていきます。

アロケータ設計の全体像

アロケータの設計思想は、malloc.go 冒頭の丁寧なコメントに集約されています。

src/runtime/malloc.go#L5-L76

この設計は Google の tcmalloc を参考にしていますが、独自の進化を遂げています。小さなオブジェクト(32KB以下)はサイズクラスごとのキャッシュから割り当てられます。大きなオブジェクトはキャッシュを完全に飛ばして、ページアロケータに直接渡されます。

graph TD
    subgraph "Per-P (lock-free)"
        MC["mcache<br/>~70 size classes<br/>+ tiny allocator"]
    end
    subgraph "Per-size-class (locked)"
        MCE["mcentral<br/>spans with free slots"]
    end
    subgraph "Global (locked)"
        MH["mheap<br/>page-level allocation"]
    end
    subgraph "Operating System"
        OS["mmap / VirtualAlloc"]
    end

    MC -->|"empty span"| MCE
    MCE -->|"no spans"| MH
    MH -->|"no pages"| OS
    OS -->|"≥1MB chunks"| MH
    MH -->|"span of pages"| MCE
    MCE -->|"span with free slots"| MC

小さなオブジェクトのアロケーションは次の手順で進みます。

  1. 要求サイズを約70種類のサイズクラスのいずれかに丸める
  2. 現在の P の mcache から対応する mspan を探す
  3. mspan のフリービットマップをスキャンして空きスロットを見つける
  4. 空きスロットがなければ、mcentral から新しい mspan を取得する
  5. mcentral にも mspan がなければ、mheap からページを取得する
  6. mheap にもページがなければ、OS に要求する(最低 1MB)

ステップ 1〜3 はロックを一切取得せずに実行されます。これが Go のアロケーションがスタックアロケーションに匹敵するほど高速な理由であり、最も重要なファストパスです。

mcache:Per-P ロックフリーアロケーション

スケジューラの記事で触れたように、各 P は自身の mcache を持っています。

src/runtime/mcache.go#L14-L59

type mcache struct {
    nextSample  int64
    scanAlloc   uintptr
    tiny        uintptr
    tinyoffset  uintptr
    tinyAllocs  uintptr
    alloc       [numSpanClasses]*mspan
    // ...
}

alloc 配列はスパンクラス(サイズクラスと noscan/scan の組み合わせ)でインデックスされており、各エントリは空きスロットを持つ mspan を指しています。アロケーション時には、対応するスパンを探してフリービットマップをスキャンします。これは単純なビット演算であり、ロックは不要です。

tiny アロケータは、ポインタを含まない 16 バイト未満のオブジェクトに特化した最適化です。複数の tiny アロケーションを単一の 16 バイトブロックにまとめて配置することで、小さな文字列や 1 バイト値のようなオブジェクトのオーバーヘッドを大幅に削減しています。

ヒント: go test -bench=. -benchmem を実行すると、アロケーション回数を確認できます。"alloc/op" の数値が、このアロケータ階層を通る回数を表しています。ゼロアロケーションのホットパスは、パフォーマンスクリティカルな Go コードの理想です。

mcentral と mheap:セントラル・グローバルアロケーション

あるサイズクラスのスパンが mcache で枯渇すると、mcentral に新しいスパンを要求します。

src/runtime/mcentral.go#L22-L40

各 mcentral は特定のサイズクラスのスパンを管理しており、GC サイクルごとに役割を入れ替える二つのセット(スイープ済みスパンと未スイープスパン)を維持しています。どちらのセットがどちらかを決めるのは、2 ずつインクリメントされる sweepgen カウンタです。この設計により、アロケーション時にスイープが引き起こされることがあります。スパンが必要なとき、空きオブジェクトを探すために先にスイープを実行する場合があるためです。

mcentral にもスパンがない場合は、mheap がページを割り当てます。

src/runtime/mheap.go#L1-L20

ヒープはページ単位(8KB ページ)でメモリを管理しており、64 ビットシステムでは 64MB チャンクのアリーナを使います。各アリーナにはメタデータが紐付いています。ポインタスキャンのためのヒープビットマップと、各ページがどのスパンに属するかを記録するスパンマップです。

graph LR
    subgraph "Arena (64MB)"
        P1["Page 0<br/>mspan A"]
        P2["Page 1<br/>mspan A"]
        P3["Page 2<br/>mspan B"]
        P4["Page 3<br/>free"]
        P5["..."]
    end
    subgraph "Metadata"
        HB["Heap Bitmap<br/>(pointer/scalar per word)"]
        SM["Span Map<br/>(page → mspan)"]
    end
    P1 --- HB
    P1 --- SM

malloc.go に記されている仮想メモリレイアウトは、このアリーナ方式を詳しく説明しています。アドレス空間は一連のアリーナフレームとして捉えられており、二段階のアリーナマップ(mheap_.arenas)によってインデックスされています。これにより、Go ヒープはアドレス空間の任意の場所を使いながらも、メタデータのルックアップを O(1) に保てます。

GCアルゴリズムと四つのフェーズ

ガベージコレクタのアルゴリズムは mgc.go に記述されています。

src/runtime/mgc.go#L1-L83

Go の GC は、Dijkstra の挿入バリアと Yuasa の削除バリアを組み合わせたハイブリッドライトバリアを使用する、並行マーク&スイープコレクタです。

src/runtime/mbarrier.go#L24-L35

writePointer(slot, ptr):
    shade(*slot)          // Yuasa: shade old referent
    if current stack is grey:
        shade(ptr)        // Dijkstra: shade new referent
    *slot = ptr
stateDiagram-v2
    [*] --> SweepTerm: GC triggered
    SweepTerm --> Mark: STW - enable write barrier
    note right of SweepTerm
        Phase 1 - Stop the world,
        sweep remaining spans
    end note
    Mark --> MarkTerm: all grey objects drained
    note right of Mark
        Phase 2 - Concurrent marking
        with write barriers active
    end note
    MarkTerm --> Sweep: STW - disable write barrier
    note right of MarkTerm
        Phase 3 - Stop the world,
        flush mcaches
    end note
    Sweep --> [*]: all spans swept
    note right of Sweep
        Phase 4 - Concurrent sweeping
        in background and on allocation
    end note

フェーズ 1(Sweep Termination): Stop-the-world。前のサイクルで残った未スイープスパンをすべてスイープします。

フェーズ 2(Concurrent Mark): ライトバリアを有効にしてプログラムを再開します。マークワーカーがルート(スタック、グローバル変数、ランタイム構造体)をスキャンし、グレーオブジェクトのキューを処理します。この処理はプログラムと並行して実行されます。

フェーズ 3(Mark Termination): 再び Stop-the-world。マーキングの完了を検証し、P ごとのキャッシュをフラッシュします。

フェーズ 4(Concurrent Sweep): ライトバリアを無効にしてプログラムを再開します。バックグラウンドでスパンをスイープしつつ、アロケーション時にも遅延スイープを実行します。新たに割り当てられたオブジェクトはホワイトです。

二回の STW ポーズ(フェーズ 1 と 3)は、ほとんどのプログラムでサブミリ秒に収まります。ヒープのスキャンという重い処理は、フェーズ 2 で並行に行われます。

GCペーサーとチューニング

GC ペーサーは次のコレクションサイクルをいつ開始するかを決定します。実装は mgcpacer.go にあります。

src/runtime/mgcpacer.go#L16-L39

GC マーキングのターゲット CPU 使用率は 25%(gcBackgroundUtilization = 0.25)です。ペーサーはヒープがターゲットサイズに達する前に GC を開始し、ヒープがそのサイズに到達するのとほぼ同時にマーキングが完了するよう調整します。

GC の動作を制御する設定は二つあります。

  • GOGC(デフォルト 100):ヒープ成長率の比率。GOGC=100 は前回のコレクション以降にヒープが 2 倍になったタイミングで GC をトリガーします。GOGC=50 なら 50% 成長でトリガー。GOGC=off で GC を無効化できます。
  • GOMEMLIMIT:ソフトなメモリ上限。設定すると、GOGC の設定より頻繁に GC を実行してでも、この上限を下回るよう動作します。

GC Assist は、アロケーションが速い goroutine が GC を追い越してしまわないようにする仕組みです。各 goroutine は gcAssistBytes というクレジットを持っています。この値がマイナスになった(自分の割り当て分を超えてアロケーションした)場合、次のアロケーションを行う前にマーキング作業を手伝わなければなりません。アロケーションが速いほど GC を多く手伝うという、自然なバックプレッシャーが生まれます。

flowchart TD
    A["Goroutine calls malloc"] --> B{"gcAssistBytes > 0?"}
    B -->|Yes| C["Allocate, decrement credit"]
    B -->|No| D["Perform mark work<br/>(scan grey objects)"]
    D --> E["Earn assist credit"]
    E --> C
    C --> F["Return allocated memory"]

ヒント: GODEBUG=gctrace=1 を設定すると、各サイクルの GC タイミングを確認できます。ヒープサイズ、ポーズ時間、CPU 使用率が出力されます。本番環境のチューニングには、GOGC よりも GOMEMLIMIT の方が実用的なケースが多いでしょう。コンテナのメモリ予算に直接対応させられるためです。

アロケーションとスケジューラのつながり

メモリ管理とスケジューリングは密接に結びついています。スケジューラの P 構造体に紐付く per-P mcache が、ロックフリーのアロケーションを実現します。GC は2回のポーズフェーズにスケジューラの STW 機構を使います。GC マークワーカーは、前回の記事で紹介した G-M-P の仕組みによってスケジュールされる goroutine です。そして GC Assist(goroutine によるマーキングの補助)は、アロケーションのファストパスに組み込まれています。

次回の最終回では、Go の並行プリミティブと I/O インフラを取り上げます。チャネルがロック保護された循環バッファとしてどう実装されているか、ネットワークポーラーがスケジューラとどう連携しているか、そしてランタイム専用のコンパイラディレクティブがこれらのシステムをどうつなぎとめているかを見ていきましょう。