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

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

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

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

スポンサーリンク

背景

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

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

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

レイテンシ

このドキュメントの視覚化では、GCが実行されている間、アプリケーションが一時停止しているものとしてモデル化してきました。このように動作するGC実装は実際に存在し、「ストップ・ザ・ワールド」GCと呼ばれています。

しかし、GoのGCは完全なストップ・ザ・ワールドではなく、その作業のほとんどをアプリケーションと並行して行います。これは主にアプリケーションのレイテンシを削減するためです。具体的には、単一の計算単位(例えば、Webリクエスト)のエンドツーエンドの継続時間です。これまで、このドキュメントは主にアプリケーションのスループット(例えば、1秒あたりに処理されるWebリクエスト)を考慮してきました。GCサイクルセクションの各例が、実行中のプログラムの総CPU時間に焦点を当てていたことに注意してください。しかし、そのような継続時間は、例えばWebサービスにとってははるかに意味が薄れます。スループット(つまり、1秒あたりのクエリ数)はWebサービスにとって依然として重要ですが、多くの場合、各個別リクエストのレイテンシの方がさらに重要です。

レイテンシの観点から、ストップ・ザ・ワールドGCは、マークフェーズとスイープフェーズの両方を実行するのにかなりの時間を必要とする場合があり、その間、アプリケーション、そしてWebサービスのコンテキストでは、進行中のリクエストは、さらなる進捗を行うことができません。代わりに、Go GCは、グローバルなアプリケーション一時停止の長さがヒープのサイズに比例することを避け、コアトレーシングアルゴリズムはアプリケーションがアクティブに実行されている間に実行されます。(一時停止は、アルゴリズム的にはGOMAXPROCSにより強く比例しますが、最も一般的には実行中のgoroutineを停止するのにかかる時間によって支配されます。)並行的にコレクションを行うことにはコストがかかります。実際には、同等のストップ・ザ・ワールドガベージコレクタよりもスループットが低い設計になることがよくあります。しかし、低レイテンシが本質的に低スループットを意味するわけではないことに注意することが重要です。Go ガベージコレクタのパフォーマンスは、レイテンシとスループットの両方で、時間とともに着実に改善されてきました。

GoのGCの並行的な性質は、これまでこのドキュメントで議論してきたことを無効にするものではありません。どの記述もこの設計選択に依存していませんでした。GC頻度は依然として、GCがスループットのためにCPU時間とメモリの間でトレードオフを行う主な方法であり、実際、レイテンシに対してもこの役割を果たします。これは、GCのコストのほとんどが、マークフェーズがアクティブな間に発生するためです。

重要なポイントは、GC頻度を減らすことがレイテンシの改善にもつながる可能性があるということです。これは、GOGCやメモリ制限の増加などのチューニングパラメータの変更によるGC頻度の削減だけでなく、最適化ガイドで説明されている最適化にも適用されます。

しかし、レイテンシはスループットよりも理解が複雑であることがよくあります。なぜなら、レイテンシはプログラムの瞬間瞬間の実行の産物であり、単なるコストの集約ではないからです。その結果、レイテンシとGC頻度の間の関連はあまり直接的ではありません。以下は、より深く掘り下げることに関心のある方のための、レイテンシの考えられる原因のリストです。

  1. GCがマークフェーズとスイープフェーズの間を遷移するときの短いストップ・ザ・ワールド一時停止、
  2. マークフェーズ中にGCがCPUリソースの25%を使用することによるスケジューリング遅延、
  3. 高い割り当て率に応答してユーザーgoroutineがGCをアシストすること、
  4. GCがマークフェーズにある間、ポインタの書き込みに追加の作業が必要になること、および
  5. 実行中のgoroutineがそのルートをスキャンされるために一時停止する必要があること。

これらのレイテンシの原因は、ポインタの書き込みに追加の作業が必要になることを除いて、実行トレースで確認できます。

レイテンシ – もう一つの重要な指標

これまでは主にスループット(処理量)に焦点を当ててきましたが、実際のアプリケーションではレイテンシ(応答時間)も非常に重要です。

スループット vs レイテンシ

スループット(Throughput)

「1秒間にどれだけ処理できるか」

// Webサーバーの例
スループット = 1000リクエスト/秒
             ↑
          たくさん処理できる!

例え話: ファストフード店で1時間に何人のお客さんを捌けるか

レイテンシ(Latency)

「1つの処理にどれだけ時間がかかるか」

// Webサーバーの例
レイテンシ = 各リクエストが50ミリ秒で完了
            ↑
          速く応答できる!

例え話: 1人のお客さんが注文してから商品を受け取るまでの時間

両方が重要な理由

// ケース1: 高スループット、高レイテンシ
スループット: 1000リクエスト/秒 ← 良い
レイテンシ:   5秒/リクエスト     ← 悪い!
→ たくさん処理できるが、各リクエストは遅い

// ケース2: 低スループット、低レイテンシ
スループット: 100リクエスト/秒   ← 悪い
レイテンシ:   50ミリ秒/リクエスト ← 良い!
→ 各リクエストは速いが、全体の処理量は少ない

// 理想: 高スループット、低レイテンシ
スループット: 1000リクエスト/秒  ← 良い!
レイテンシ:   50ミリ秒/リクエスト ← 良い!

ストップ・ザ・ワールド(STW) vs 並行GC

ストップ・ザ・ワールドGC

すべてを止めてGCを実行

時間 →

[アプリ動作][★STW GC★][アプリ動作][★STW GC★][アプリ動作]
           ↑全停止!           ↑全停止!
           
レイテンシへの影響:
- リクエスト処理中にGCが始まると...
- そのリクエストは完全に停止
- GCが終わるまで待つ(例: 100ミリ秒)
- → レイテンシが大幅に増加!

例え話: レストランで全員が手を止めて大掃除をする

  • お客さんの料理も止まる
  • 掃除が終わるまで待たされる

並行GC (Goの方式)

アプリを動かしながらGCを実行

時間 →

[アプリ動作 + GC並行実行][アプリ動作 + GC並行実行]
     ↑同時に動く!              ↑同時に動く!
     
レイテンシへの影響:
- リクエスト処理は続行
- GCは裏で並行して動く
- 一時停止は非常に短い(例: 1ミリ秒未満)
- → レイテンシへの影響が小さい!

例え話: レストランで清掃スタッフが働きながら、シェフは料理を続ける

  • お客さんへのサービスは中断されない
  • 掃除は裏で進む

Goの並行GCの仕組み

マークフェーズ – 並行実行

// アプリケーションとGCが同時に動く

┌─────────────────┐  ┌─────────────────┐
│  アプリケーション  │  │      GC         │
│                 │  │                 │
│ リクエスト処理   │  │ マーク作業      │
│ データベース接続 │  │ スキャン作業    │
│ 計算処理        │  │ ...             │
└─────────────────┘  └─────────────────┘
       ↓                    ↓
   同時に実行中 (並行)

短い一時停止だけ

// フェーズ遷移時のみ短時間停止

[並行マーク中...][一時停止][並行マーク中...][一時停止][スイープ]
                ↑短い!                    ↑短い!
                (通常1ms未満)              

並行GCのコスト

トレードオフ: スループット vs レイテンシ

ストップ・ザ・ワールドGC:
✅ 高スループット (GCが効率的に実行できる)
❌ 高レイテンシ (完全停止する)

並行GC (Go):
❌ やや低スループット (並行実行のオーバーヘッド)
✅ 低レイテンシ (ほとんど停止しない)

Goの選択: レイテンシを優先(Webサービスなど対話的なアプリに適している)

GC頻度とレイテンシの関係

重要な洞察: GC頻度を減らすとレイテンシも改善する

理由

// シナリオ1: GCが頻繁
GOGC=50
→ GCが頻繁に実行される
→ マークフェーズが頻繁に発生
→ レイテンシのスパイクが頻繁

// シナリオ2: GCがまれ
GOGC=200
→ GCがまれに実行される
→ マークフェーズがまれに発生
→ レイテンシのスパイクがまれ

視覚化:

GOGC=50 (頻繁なGC):
レイテンシ:
100ms |     ▲     ▲     ▲     ▲     ▲
 50ms |  ▲  |  ▲  |  ▲  |  ▲  |  ▲  |
  0ms |__|__|__|__|__|__|__|__|__|__|__|
      時間 →
      ↑ スパイクが頻繁

GOGC=200 (まれなGC):
レイテンシ:
100ms |           ▲
 50ms |  ▲        |
  0ms |__|________|____________
      時間 →
      ↑ スパイクがまれ

レイテンシの5つの原因

1. STW一時停止 (フェーズ遷移時)

// マーク開始時とマーク終了時に短時間停止

[並行動作] → [★一時停止★] → [マークフェーズ] → [★一時停止★] → [スイープ]
             ↑ 通常1ms未満            ↑ 通常1ms未満

影響: 小さいが測定可能

例え話: 信号待ちのようなもの(短いが避けられない)

2. CPUリソースの消費 (25%)

// マークフェーズ中、GCがCPU時間の25%を使用

利用可能CPU: 100%
┌──────────────────────────────┐
│ アプリケーション: 75%         │
│ GC: 25%                      │
└──────────────────────────────┘

影響:
- アプリケーションの処理が遅くなる
- レイテンシが増加する可能性

例え話: 4人のシェフのうち1人が常に掃除をしている → 料理の速度が少し遅くなる

3. アシストマーキング (高割り当て率時)

// アプリケーションが速くメモリを割り当てすぎると...

func highAllocationRate() {
    for i := 0; i < 1000000; i++ {
        data := make([]byte, 1024*1024)  // 1MBを高速で割り当て
        
        // GC: 「待って!マーク作業が追いつかない!」
        // → アプリケーションgoroutineにマーク作業を手伝わせる
        // → このgoroutineが一時的に遅くなる
    }
}

影響: 割り当てが多いgoroutineのレイテンシ増加

例え話: お客さんが次々に来るので、ウェイターも片付けを手伝う → サービスが遅くなる

4. ライトバリア (ポインタ書き込み時)

// マークフェーズ中、ポインタを書き込むと追加の作業が発生

type Node struct {
    Value int
    Next  *Node
}

func updatePointer() {
    node1 := &Node{Value: 1}
    node2 := &Node{Value: 2}
    
    // マークフェーズ中にポインタを更新
    node1.Next = node2  // ← 通常より遅い(ライトバリア)
    
    // GCが見逃さないように追加の記録が必要
}

影響: ポインタの書き込みが少し遅くなる

例え話: 掃除中は物を移動するたびに記録をつける → 少し時間がかかる

5. Goroutineのスキャン

// Goroutineのスタックをスキャンするために一時停止

func worker() {
    // このgoroutineが実行中...
    
    // GC: 「このgoroutineのスタックをスキャンする必要がある」
    // → goroutineを短時間停止
    // → スタックをスキャン
    // → 再開
    
    // 処理続行...
}

影響: 各goroutineが短時間停止する

例え話: 健康診断で一人ずつ呼ばれて検査される → 検査中は仕事ができない

実際のレイテンシ測定

package main

import (
    "fmt"
    "runtime"
    "time"
)

func measureLatency() {
    // リクエスト処理のシミュレーション
    latencies := make([]time.Duration, 0)
    
    for i := 0; i < 1000; i++ {
        start := time.Now()
        
        // 実際の処理
        processRequest()
        
        latency := time.Since(start)
        latencies = append(latencies, latency)
        
        time.Sleep(10 * time.Millisecond)
    }
    
    // 統計を計算
    var total time.Duration
    var max time.Duration
    for _, lat := range latencies {
        total += lat
        if lat > max {
            max = lat
        }
    }
    
    avg := total / time.Duration(len(latencies))
    
    fmt.Printf("平均レイテンシ: %v\n", avg)
    fmt.Printf("最大レイテンシ: %v\n", max)
    
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("GC一時停止時間: %v\n", time.Duration(m.PauseNs[(m.NumGC+255)%256]))
}

func processRequest() {
    // リクエスト処理をシミュレート
    data := make([]byte, 1024*1024)  // 1MB割り当て
    _ = data
}

レイテンシ改善のヒント

1. GOGC を上げる

// Before
GOGC=100
平均レイテンシ: 50ms
P99レイテンシ: 200ms (GCによるスパイク)

// After
GOGC=200
平均レイテンシ: 45ms
P99レイテンシ: 100ms (GC頻度が半分)

2. メモリ制限を適切に設定

// コンテナメモリ: 1GB
GOMEMLIMIT=900MiB  // 余裕を持たせる
GOGC=100

// 効果: GCがスラッシングしない

3. 割り当てを減らす

// Before: 頻繁な割り当て
func processData(items []Item) {
    for _, item := range items {
        buffer := make([]byte, 1024)  // 毎回割り当て
        process(buffer, item)
    }
}

// After: バッファを再利用
func processDataOptimized(items []Item) {
    buffer := make([]byte, 1024)  // 1回だけ割り当て
    for _, item := range items {
        process(buffer, item)  // 再利用
    }
}

4. ポインタを減らす

// Before: ポインタが多い
type Response struct {
    UserID    *int
    ProductID *int
    Price     *float64
}

// After: 値型を使う
type Response struct {
    UserID    int
    ProductID int
    Price     float64
}
// → ライトバリアのコスト削減

実行トレースでレイテンシを分析

package main

import (
    "os"
    "runtime/trace"
)

func main() {
    // トレース開始
    f, _ := os.Create("trace.out")
    defer f.Close()
    
    trace.Start(f)
    defer trace.Stop()
    
    // アプリケーション実行
    runApplication()
}
# トレース解析
go tool trace trace.out

# ブラウザで開いて確認:
# - GCの一時停止
# - Goroutineのブロック
# - スケジューリング遅延
# - アシストマーキング

Webサービスでの実践例

package main

import (
    "fmt"
    "log"
    "net/http"
    "runtime"
    "runtime/debug"
    "time"
)

func init() {
    // レイテンシ重視の設定
    debug.SetGCPercent(200)           // GC頻度を下げる
    debug.SetMemoryLimit(1 << 30)     // 1GB制限
    runtime.GOMAXPROCS(runtime.NumCPU()) // 全CPUを使用
}

func handler(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    
    // リクエスト処理
    result := processRequest(r)
    
    // レイテンシを記録
    latency := time.Since(start)
    
    // レスポンス
    w.Header().Set("X-Response-Time", latency.String())
    fmt.Fprintf(w, "Result: %v", result)
    
    // 警告: レイテンシが高い
    if latency > 100*time.Millisecond {
        log.Printf("High latency: %v", latency)
    }
}

func processRequest(r *http.Request) string {
    // 実際の処理...
    return "success"
}

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

まとめ

概念説明レイテンシへの影響
並行GCアプリと同時に動作低レイテンシ ✅
STW一時停止フェーズ遷移時小(1ms未満)
CPU消費マーク中25%中程度
アシストマーキング高割り当て時高(避けるべき)
ライトバリアポインタ書き込み
Goroutineスキャンスタック確認

レイテンシ最適化の黄金律:

  1. GC頻度を減らす – GOGC を上げる
  2. 割り当てを減らす – オブジェクトプールを使う
  3. ポインタを減らす – 値型を活用
  4. 測定する – トレースやメトリクスで確認
  5. バランスを取る – スループットとのトレードオフを考慮

次のセクションでは、具体的な最適化テクニックを学びます!

おわりに 

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

よっしー
よっしー

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

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

コメント

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