Go言語入門:ガベージコレクター -Vol.6-

スポンサーリンク
Go言語入門:ガベージコレクター -Vol.6- ノウハウ
Go言語入門:ガベージコレクター -Vol.6-
この記事は約17分で読めます。
よっしー
よっしー

こんにちは。よっしーです(^^)

本日は、Go言語のガベージコレクターついて解説しています。

スポンサーリンク

背景

Goのガベージコレクター(GC)は、多くの開発者にとって「ブラックボックス」のような存在です。メモリ管理を自動で行ってくれる便利な仕組みである一方、アプリケーションのパフォーマンスに大きな影響を与える要因でもあります。「なぜ突然レスポンスが遅くなるのか?」「メモリ使用量が想定より多いのはなぜか?」「GCの停止時間をもっと短くできないか?」—— こうした疑問は、Goで高性能なアプリケーションを開発する上で避けて通れない課題です。

本記事では、Go公式ドキュメントの「ガベージコレクションガイド」を日本語で紹介します。このガイドは、GCの動作原理を理解し、その知見を活用してアプリケーションのリソース使用効率を改善することを目的としています。特筆すべきは、このドキュメントがガベージコレクションの前提知識を一切要求しない点です。Go言語の基本的な知識さえあれば、誰でもGCの仕組みを深く理解できるよう設計されています。

なぜ今、GCの理解が重要なのでしょうか。クラウドネイティブ時代において、リソースの効率的な活用はコスト削減に直結します。また、マイクロサービスアーキテクチャでは、各サービスのレイテンシが全体のユーザー体験に影響するため、GCによる一時停止を最小限に抑えることが求められます。このガイドを通じて、「なんとなく動いている」から「理解して最適化できる」レベルへとステップアップし、より高品質なGoアプリケーションの開発を目指しましょう。

GOGC

高レベルで見ると、GOGCはGCのCPUとメモリの間のトレードオフを決定します。

これは、各GCサイクル後のターゲットヒープサイズ、つまり次のサイクルにおける総ヒープサイズの目標値を決定することで機能します。GCの目標は、総ヒープサイズがターゲットヒープサイズを超える前にコレクションサイクルを完了することです。総ヒープサイズは、前回のサイクル終了時のライブヒープサイズに、前回のサイクル以降にアプリケーションが割り当てた新しいヒープメモリを加えたものとして定義されます。一方、ターゲットヒープメモリは次のように定義されます。

ターゲットヒープメモリ = ライブヒープ + (ライブヒープ + GCルート) × GOGC / 100

例として、ライブヒープサイズが8 MiB、goroutineスタックが1 MiB、グローバル変数のポインタが1 MiBのGoプログラムを考えてみましょう。すると、GOGC値が100の場合、次のGCが実行されるまでに割り当てられる新しいメモリの量は10 MiBになります。これは10 MiBの作業の100%であり、総ヒープフットプリントは18 MiBになります。GOGC値が50の場合は、50%、つまり5 MiBになります。GOGC値が200の場合は、200%、つまり20 MiBになります。

注:GOGCがルートセットを含むのは、Go 1.18以降です。以前は、ライブヒープのみをカウントしていました。多くの場合、goroutineスタック内のメモリ量は非常に小さく、ライブヒープサイズが他のすべてのGC作業のソースを支配していますが、プログラムが数十万のgoroutineを持っている場合、GCは不適切な判断を下していました。

ヒープターゲットはGC頻度を制御します。ターゲットが大きいほど、GCは次のマークフェーズを開始するまで長く待つことができ、逆もまた真です。正確な式は推定を行うのに有用ですが、GOGCはその根本的な目的の観点から考えるのが最善です。つまり、GCのCPUとメモリのトレードオフにおいて点を選ぶパラメータです。重要なポイントは、GOGCを2倍にすると、ヒープメモリのオーバーヘッドが2倍になり、GC CPUコストがおおよそ半分になり、逆もまた真であるということです。(完全な説明については、付録を参照してください。)

注:ターゲットヒープサイズはあくまで目標であり、GCサイクルがそのターゲットちょうどで終了しない理由はいくつかあります。一つには、十分に大きなヒープ割り当てが単純にターゲットを超える可能性があります。しかし、他の理由は、このガイドがこれまで使用してきたGCモデルを超えるGC実装に現れます。詳細については、レイテンシのセクションを参照してください。ただし、完全な詳細は追加リソースで見つけることができます。

GOGCは、GOGC環境変数(すべてのGoプログラムが認識する)、またはruntime/debugパッケージのSetGCPercent APIを通じて設定できます。

GOGCは、GOGC=offを設定するか、SetGCPercent(-1)を呼び出すことで、GCを完全にオフにするためにも使用できます(メモリ制限が適用されない場合)。概念的には、この設定はGOGCを無限大の値に設定するのと同等です。なぜなら、GCがトリガーされる前の新しいメモリの量が無制限だからです。

これまで議論してきたすべてをよりよく理解するために、前述のGCコストモデルに基づいて構築されたインタラクティブな視覚化を試してみてください。この視覚化は、非GC作業が完了するのに10秒のCPU時間がかかるプログラムの実行を描いています。最初の1秒で初期化ステップ(ライブヒープの増加)を実行し、その後定常状態に落ち着きます。アプリケーションは合計200 MiBを割り当て、一度に20 MiBがライブです。完了する必要がある関連するGC作業はライブヒープからのみ来ると仮定し、(非現実的ですが)アプリケーションは追加のメモリを使用しないと仮定します。

スライダーを使用してGOGCの値を調整し、総期間とGCオーバーヘッドの観点からアプリケーションがどのように応答するかを確認してください。各GCサイクルは、新しいヒープがゼロになる間に終了します。新しいヒープがゼロになる間に費やされる時間は、サイクルNのマークフェーズとサイクルN+1のスイープフェーズの合計時間です。この視覚化(およびこのガイドのすべての視覚化)は、GCが実行されている間、アプリケーションが一時停止していると仮定しているため、GC CPUコストは、新しいヒープメモリがゼロになるまでにかかる時間によって完全に表されます。これは視覚化を簡単にするためだけのものです。同じ直感がまだ適用されます。X軸は常にプログラムの完全なCPU時間の長さを示すようにシフトします。GCによって使用される追加のCPU時間が全体の期間を増加させることに注目してください。

GCは常にいくらかのCPUとピークメモリのオーバーヘッドを発生させることに注意してください。GOGCが増加すると、CPUオーバーヘッドは減少しますが、ピークメモリはライブヒープサイズに比例して増加します。GOGCが減少すると、ピークメモリ要件は減少しますが、追加のCPUオーバーヘッドが発生します。

注:グラフは、プログラムを完了するためのウォールクロック時間ではなく、CPU時間を表示します。プログラムが1つのCPUで実行され、リソースを完全に利用する場合、これらは同等です。実際のプログラムは、マルチコアシステムで実行され、常にCPUを100%利用するわけではありません。これらの場合、GCのウォールタイムへの影響は低くなります。

注:Go GCには4 MiBの最小総ヒープサイズがあるため、GOGCで設定されたターゲットがそれを下回る場合は、切り上げられます。視覚化はこの詳細を反映しています。

これは、もう少し動的で現実的な別の例です。再び、アプリケーションはGCなしで完了するのに10 CPU秒かかりますが、定常状態の割り当て率は途中で劇的に増加し、ライブヒープサイズは最初のフェーズで少し変動します。この例は、ライブヒープサイズが実際に変化しているときに定常状態がどのように見えるか、および割り当て率が高いとGCサイクルがより頻繁になることを示しています。

GOGC – GCチューニングの最重要パラメータ

GOGCは、Go言語でGCの動作を制御する最も重要なパラメータです。これは「メモリを節約するか、CPUを節約するか」を決める魔法の数字です。

GOGCの基本概念

GOGCは、次のGCをいつ実行するかを決める数字です。

ターゲットヒープ = ライブヒープ + (ライブヒープ + GCルート) × GOGC / 100

この公式を簡単に言うと:

次にGCを実行するメモリ量 = 今使っているメモリ × (1 + GOGC/100)

前提条件

  • ライブヒープ: 8 MiB(生きているデータ)
  • goroutineスタック: 1 MiB
  • グローバル変数のポインタ: 1 MiB
  • 合計GCルート: 10 MiB

GOGC = 100 (デフォルト)

ターゲット = 10 MiB + (10 MiB × 100/100)
          = 10 MiB + 10 MiB
          = 20 MiB

つまり...
- 現在: 10 MiB使用中
- 新しく割り当て可能: 10 MiB (100%増加)
- 次のGCトリガー: 20 MiBに達したとき

例え話: 銀行口座に10万円あります。

  • GOGC=100 → 残高が20万円(2倍)になったら整理
  • 追加で10万円使えます

GOGC = 50 (メモリ節約モード)

ターゲット = 10 MiB + (10 MiB × 50/100)
          = 10 MiB + 5 MiB
          = 15 MiB

つまり...
- 現在: 10 MiB使用中
- 新しく割り当て可能: 5 MiB (50%増加)
- 次のGCトリガー: 15 MiBに達したとき

例え話:

  • GOGC=50 → 残高が15万円(1.5倍)になったら整理
  • 追加で5万円しか使えません
  • 頻繁に整理が必要(GC頻度が高い)

GOGC = 200 (CPU節約モード)

ターゲット = 10 MiB + (10 MiB × 200/100)
          = 10 MiB + 20 MiB
          = 30 MiB

つまり...
- 現在: 10 MiB使用中
- 新しく割り当て可能: 20 MiB (200%増加)
- 次のGCトリガー: 30 MiBに達したとき

例え話:

  • GOGC=200 → 残高が30万円(3倍)になったら整理
  • 追加で20万円使えます
  • 整理の頻度は低い(GC頻度が低い)

視覚的な比較

現在のメモリ使用: 10 MiB
━━━━━━━━━━ (ライブヒープ)

GOGC=50:
━━━━━━━━━━━━━━━ (15 MiB) ← すぐGC
     50%増加

GOGC=100:
━━━━━━━━━━━━━━━━━━━━ (20 MiB) ← 標準
     100%増加

GOGC=200:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (30 MiB) ← GC少ない
     200%増加

Go 1.18の重要な変更 – GCルートの追加

以前(Go 1.17以前)

// 古い計算式
ターゲット = ライブヒープ × (1 + GOGC/100)

現在(Go 1.18以降)

// 新しい計算式
ターゲット = (ライブヒープ + GCルート) × (1 + GOGC/100)

なぜ変更されたか?

問題のシナリオ:

func manyGoroutines() {
    // 数十万のgoroutineを起動
    for i := 0; i < 100000; i++ {
        go func() {
            // 各goroutineは小さなスタックを持つ
            // 合計すると数百MBになる
            var stack [1024]byte
            // ... 処理 ...
            time.Sleep(time.Hour)
        }()
    }
    
    // ライブヒープ: 10 MiB (小さい)
    // goroutineスタック: 100 MiB (大きい!)
    // 旧式: スタックを無視 → GCの判断が不正確
    // 新式: スタックを考慮 → GCの判断が正確
}

重要なルール: GOGCを2倍にすると…

GOGCを2倍にする効果:
✅ ヒープメモリオーバーヘッド: 2倍
✅ GC CPUコスト: 約半分

GOGCを半分にする効果:
✅ ヒープメモリオーバーヘッド: 半分
✅ GC CPUコスト: 約2倍

数学的な証明(簡略版)

元の設定: GOGC=100
- ライブヒープ: 10 MiB
- 新規割り当て: 10 MiB
- GC頻度: 10 MiBごと

2倍の設定: GOGC=200
- ライブヒープ: 10 MiB
- 新規割り当て: 20 MiB
- GC頻度: 20 MiBごと → 半分の頻度!

結果:
- メモリ: 2倍使用
- GC回数: 半分 → CPU負荷も約半分

GOGCの設定方法

方法1: 環境変数

# デフォルト(推奨)
export GOGC=100

# メモリが限られている環境
export GOGC=50

# CPUを節約したい、メモリが豊富
export GOGC=200

# GCを完全に無効化(非推奨!)
export GOGC=off

方法2: プログラム内で設定

package main

import (
    "fmt"
    "runtime/debug"
)

func main() {
    // 現在の設定を取得
    current := debug.SetGCPercent(-1)
    fmt.Printf("現在のGOGC: %d\n", current)
    
    // GOGCを200に設定
    debug.SetGCPercent(200)
    
    // GOGCを無効化
    // debug.SetGCPercent(-1)
    
    // 処理...
}

方法3: プログラム起動時

# コマンドライン引数として
GOGC=150 go run main.go

# または
go run main.go &  # GOGCは環境変数から取得

ターゲットはあくまで「目標」

GCサイクルが必ずしもターゲットちょうどで終わるわけではありません

理由1: 大きな割り当て

func largeAllocation() {
    // ターゲット: 20 MiB
    // 現在: 18 MiB
    
    // 突然10 MiBを割り当て!
    huge := make([]byte, 10*1024*1024)
    
    // 結果: 28 MiB → ターゲットを超えた!
    // でもGCはまだ実行されていないかも
}

理由2: 並行GC

Goのガベージコレクタは並行実行されるため、GC実行中もアプリケーションが動作しており、さらにメモリを割り当てる可能性があります。

最小ヒープサイズ: 4 MiB

// ライブヒープが非常に小さい場合
liveHeap := 1 MiB
GOGC := 100

// 計算上のターゲット
target := 1 MiB × (1 + 100/100) = 2 MiB

// しかし実際のターゲット
actualTarget := max(2 MiB, 4 MiB) = 4 MiB
// ← 最小値が適用される

理由: あまりに頻繁なGCを防ぐため

実際のシナリオ例

シナリオ1: Webサーバー

// 定常状態のWebサーバー
// - リクエスト率: 一定
// - メモリ使用: 安定

// 推奨設定
GOGC=100  // デフォルトで十分

シナリオ2: バッチ処理

// 大量のデータを一度に処理
// - メモリ使用: 急増と急減を繰り返す
// - CPU: できるだけ節約したい

// 推奨設定
GOGC=200  // GC頻度を下げる

シナリオ3: メモリ制約の厳しい環境

// IoTデバイスや組み込みシステム
// - メモリ: 非常に限られている
// - CPU: 比較的余裕がある

// 推奨設定
GOGC=50   // GC頻度を上げてメモリ節約

動的な例: 割り当て率の変化

package main

import (
    "runtime"
    "time"
)

func dynamicWorkload() {
    // フェーズ1: 軽い負荷
    for i := 0; i < 1000; i++ {
        data := make([]byte, 1024)  // 1 KiB/回
        process(data)
        time.Sleep(time.Millisecond)
    }
    // → GCの頻度: 低い
    
    // フェーズ2: 重い負荷
    for i := 0; i < 1000; i++ {
        data := make([]byte, 1024*1024)  // 1 MiB/回
        process(data)
        time.Sleep(time.Millisecond)
    }
    // → GCの頻度: 高い
    
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    // 負荷に応じてGC回数が変化する
}

CPU時間 vs ウォールクロック時間

CPU時間

プログラムが実際にCPUを使っている時間
= GCの時間 + アプリケーションの時間

ウォールクロック時間(実時間)

時計で測った実際の経過時間
= CPU時間 ÷ コア数 (理想的な場合)

例:

CPU時間: 10秒
コア数: 4

理想的なウォールクロック時間: 10秒 ÷ 4 = 2.5秒

実践的な監視とチューニング

package main

import (
    "fmt"
    "runtime"
    "runtime/debug"
    "time"
)

func monitorAndTune() {
    // 初期設定
    debug.SetGCPercent(100)
    
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()
    
    var lastNumGC uint32
    var lastPauseNs uint64
    
    for range ticker.C {
        var m runtime.MemStats
        runtime.ReadMemStats(&m)
        
        // GC統計
        gcCount := m.NumGC - lastNumGC
        pauseTime := m.PauseTotalNs - lastPauseNs
        
        fmt.Printf("過去5秒間:\n")
        fmt.Printf("  GC回数: %d\n", gcCount)
        fmt.Printf("  GC一時停止時間: %v\n", time.Duration(pauseTime))
        fmt.Printf("  ヒープサイズ: %d MB\n", m.HeapAlloc/1024/1024)
        
        // 動的チューニングの例
        if gcCount > 10 {
            // GCが頻繁すぎる → GOGCを増やす
            fmt.Println("→ GOGCを増やします")
            debug.SetGCPercent(150)
        } else if m.HeapAlloc > 500*1024*1024 {
            // メモリ使用量が多すぎる → GOGCを減らす
            fmt.Println("→ GOGCを減らします")
            debug.SetGCPercent(75)
        }
        
        lastNumGC = m.NumGC
        lastPauseNs = m.PauseTotalNs
    }
}

まとめ

GOGC値メモリ使用CPU使用用途
50少ない高いメモリ制約環境
100標準標準デフォルト・バランス重視
200多い低いCPU節約・メモリ豊富
off制限なしなしデバッグ・特殊用途

チューニングの黄金律:

  1. まずはデフォルト(100)から始める
  2. 実際の負荷で測定する
  3. 必要に応じて調整する
  4. 極端な値は避ける

次のセクションでは、メモリ制限やレイテンシの最適化について学びます!

おわりに 

本日は、Go言語のガベージコレクターについて解説しました。

よっしー
よっしー

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。

それでは、また明日お会いしましょう(^^)

コメント

タイトルとURLをコピーしました