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

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

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

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

スポンサーリンク

背景

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

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

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

メモリ制限

Go 1.19まで、GOGCはGCの動作を変更するために使用できる唯一のパラメータでした。トレードオフを設定する方法としては素晴らしいのですが、利用可能なメモリが有限であることを考慮していません。ライブヒープサイズに一時的なスパイクがある場合に何が起こるか考えてみてください。GCはそのライブヒープサイズに比例した総ヒープサイズを選択するため、通常はより高いGOGC値がより良いトレードオフを提供する場合でも、GOGCはピークのライブヒープサイズに合わせて設定しなければなりません。

以下の視覚化は、この一時的なヒープスパイクの状況を示しています。

例のワークロードが60 MiBをわずかに超えるメモリが利用可能なコンテナで実行されている場合、他のGCサイクルにはその追加メモリを利用するための利用可能なメモリがあるにもかかわらず、GOGCを100を超えて増やすことはできません。さらに、一部のアプリケーションでは、これらの一時的なピークがまれで予測が難しく、時折発生する、避けられない、そして潜在的にコストのかかるメモリ不足状態につながる可能性があります。

そのため、1.19リリースで、Goはランタイムメモリ制限の設定のサポートを追加しました。メモリ制限は、すべてのGoプログラムが認識するGOMEMLIMIT環境変数、またはruntime/debugパッケージで利用可能なSetMemoryLimit関数を通じて設定できます。

このメモリ制限は、Goランタイムが使用できるメモリの総量の最大値を設定します。含まれる特定のメモリセットは、runtime.MemStatsの観点から次の式として定義されます。

Sys - HeapReleased

または、runtime/metricsパッケージの観点から同等に、

/memory/classes/total:bytes - /memory/classes/heap/released:bytes

Go GCはヒープメモリの使用量を明示的に制御できるため、このメモリ制限とGoランタイムが使用する他のメモリの量に基づいて総ヒープサイズを設定します。

以下の視覚化は、GOGCセクションと同じ単一フェーズの定常状態ワークロードを示していますが、今回はGoランタイムからの追加の10 MiBのオーバーヘッドと、調整可能なメモリ制限があります。GOGCとメモリ制限の両方を動かして何が起こるか見てみましょう。

メモリ制限がGOGCによって決定されるピークメモリ(GOGC 100の場合は42 MiB)を下回ると、GCはピークメモリを制限内に保つためにより頻繁に実行されることに注意してください。

一時的なヒープスパイクの以前の例に戻ると、メモリ制限を設定してGOGCを上げることで、両方の利点を得ることができます。メモリ制限違反なし、かつより良いリソース経済性です。以下のインタラクティブな視覚化を試してみてください。

GOGCとメモリ制限の一部の値では、ピークメモリ使用量はメモリ制限で止まりますが、プログラムの実行の残りの部分は依然としてGOGCによって設定された総ヒープサイズルールに従うことに注意してください。

この観察は別の興味深い詳細につながります。GOGCがoffに設定されている場合でも、メモリ制限は依然として尊重されます!実際、この特定の構成は、ある程度のメモリ制限を維持するために必要な最小のGC頻度を設定するため、リソース経済性の最大化を表しています。この場合、プログラムの実行のすべてで、ヒープサイズがメモリ制限に達するまで上昇します。

さて、メモリ制限が明らかに強力なツールである一方で、メモリ制限の使用にはコストが伴い、確実にGOGCの有用性を無効にするものではありません。

ライブヒープが、総メモリ使用量をメモリ制限に近づけるほど大きくなった場合に何が起こるか考えてみてください。上記の定常状態の視覚化で、GOGCをオフにしてから、メモリ制限をゆっくりとさらに下げていくと何が起こるか見てみましょう。GCが不可能なメモリ制限を維持するために常に実行されるため、アプリケーションが費やす総時間が無制限に増加し始めることに注意してください。

この状況、つまり定数的なGCサイクルのためにプログラムが合理的な進捗を遂げられない状況は、スラッシングと呼ばれます。これはプログラムを事実上停止させるため、特に危険です。さらに悪いことに、GOGCで避けようとしていたのとまったく同じ状況で発生する可能性があります。十分に大きな一時的なヒープスパイクは、プログラムを無期限に停止させる可能性があります!一時的なヒープスパイクの視覚化でメモリ制限を減らしてみて(30 MiB以下程度)、最悪の動作が具体的にヒープスパイクから始まることに注目してください。

多くの場合、無期限の停止は、メモリ不足状態よりも悪く、メモリ不足状態はより速い障害につながる傾向があります。

このため、メモリ制限はソフトとして定義されています。Goランタイムは、すべての状況下でこのメモリ制限を維持することを保証しません。合理的な量の努力を約束するだけです。このメモリ制限の緩和は、スラッシング動作を回避するために重要です。なぜなら、GCに逃げ道を与えるからです。GCに多くの時間を費やすことを避けるために、メモリ使用量が制限を超えることを許可します。

これが内部的にどのように機能するかというと、GCは一定の時間枠内で使用できるCPU時間の量に上限を設定します(CPU使用の非常に短い一時的なスパイクにはヒステリシスがあります)。この制限は現在、約50%に設定されており、2 * GOMAXPROCS CPU秒のウィンドウがあります。GC CPU時間を制限した結果、GCの作業が遅延し、その間、Goプログラムはメモリ制限を超えても新しいヒープメモリを割り当て続ける可能性があります。

50%のGC CPU制限の背後にある直感は、十分な利用可能なメモリを持つプログラムへの最悪の影響に基づいています。メモリ制限の設定ミスの場合、つまり誤って低く設定された場合、GCはCPU時間の50%以上を奪うことができないため、プログラムは最大で2倍遅くなります。

注:このページの視覚化は、GC CPU制限をシミュレートしていません。

推奨される使い方

メモリ制限は強力なツールであり、Goランタイムは誤用による最悪の動作を軽減するための措置を講じていますが、慎重に使用することが依然として重要です。以下は、メモリ制限が最も有用で適用可能な場所、そしてそれが害をもたらす可能性がある場所についてのアドバイスの集まりです。

✅ Goプログラムの実行環境が完全にあなたの制御下にあり、Goプログラムが一連のリソース(つまり、コンテナのメモリ制限のような何らかのメモリ予約)への唯一のアクセス権を持つプログラムである場合、メモリ制限を活用してください。

良い例は、固定量の利用可能なメモリを持つコンテナへのWebサービスのデプロイです。

この場合、良い経験則は、Goランタイムが認識していないメモリソースを考慮して、追加の5〜10%のヘッドルームを残すことです。

✅ 変化する条件に適応するために、メモリ制限をリアルタイムで調整することを躊躇しないでください。

良い例は、CライブラリがSUBSTANTIALLYに一時的により多くのメモリを使用する必要があるcgoプログラムです。

❌ Goプログラムがその限られたメモリの一部を他のプログラムと共有する可能性があり、それらのプログラムが一般にGoプログラムから切り離されている場合、GOGCをoffに設定してメモリ制限を設定しないでください。代わりに、望ましくない一時的な動作を抑制するのに役立つ可能性があるため、メモリ制限は保持しますが、GOGCを平均的な場合のより小さな合理的な値に設定してください。

共同テナントプログラムのためにメモリを「予約」しようとするのは魅力的かもしれませんが、プログラムが完全に同期されていない限り(例えば、Goプログラムがサブプロセスを呼び出し、そのサブプロセスが実行されている間ブロックする)、必然的に両方のプログラムがより多くのメモリを必要とするため、結果はあまり信頼できません。Goプログラムが必要としないときにメモリを少なく使用させることで、全体的により信頼できる結果が生成されます。このアドバイスは、1台のマシン上で実行されているコンテナのメモリ制限の合計が、マシンに利用可能な実際の物理メモリを超える可能性があるオーバーコミット状況にも適用されます。

❌ プログラムのメモリ使用量がその入力に比例する場合、特に制御できない実行環境にデプロイする場合は、メモリ制限を使用しないでください。

良い例は、CLIツールまたはデスクトップアプリケーションです。どのような種類の入力が供給されるか、またはシステムでどのくらいのメモリが利用可能かが不明な場合にプログラムにメモリ制限を組み込むと、混乱を招くクラッシュやパフォーマンスの低下につながる可能性があります。さらに、高度なエンドユーザーは望めばいつでもメモリ制限を設定できます。

❌ プログラムがすでに環境のメモリ制限に近い場合、メモリ不足状態を回避するためにメモリ制限を設定しないでください。

これは事実上、メモリ不足のリスクを、深刻なアプリケーション速度低下のリスクに置き換えることになり、Goがスラッシングを軽減するための努力をしても、これは多くの場合有利なトレードオフではありません。そのような場合、環境のメモリ制限を増やす(そしてその後潜在的にメモリ制限を設定する)か、GOGC を減らす(スラッシング軽減よりもはるかにクリーンなトレードオフを提供します)方がはるかに効果的です。

メモリ制限 – GOGCの弱点を補う新機能

Go 1.19で追加された**メモリ制限(GOMEMLIMIT)**は、GOGCだけでは解決できない問題を解決します。

GOGCの問題点 – 一時的なスパイク

問題のシナリオ

func problematicWorkload() {
    // 通常時: ライブヒープ 10 MiB
    normalHeap := 10 * 1024 * 1024
    
    // GOGC=100 → 次のGCトリガー: 20 MiB
    // → 通常は快適に動作
    
    // しかし突然...
    spike := processLargeFile()  // 一時的に50 MiBのライブヒープ!
    
    // GOGC=100 → 次のGCトリガー: 100 MiB
    // → コンテナのメモリ制限(60 MiB)を超える!
    // → クラッシュ!
}

問題の本質:

  • GOGCはライブヒープに比例してターゲットを決める
  • 一時的なスパイクでも、その間はずっと高いターゲットになる
  • ピークに合わせてGOGCを低く設定すると、通常時も無駄に頻繁にGCが走る

視覚化

メモリ制限: 60 MiB
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

通常時(ライブ10 MiB, GOGC=100):
ライブ: ██████████ (10 MiB)
新規:   ██████████ (10 MiB)
合計:   ████████████████████ (20 MiB) ← 余裕あり

スパイク時(ライブ50 MiB, GOGC=100):
ライブ: ██████████████████████████████████████████████████ (50 MiB)
新規:   ██████████████████████████████████████████████████ (50 MiB)
合計:   ████████████████████████████████████████████████████████████████████████████████████████████████ (100 MiB)
        ← 制限を超えてクラッシュ!

GOMEMLIMIT – 解決策

基本概念

GOMEMLIMITは、Goランタイムが使用できる最大メモリ量を設定します。

// メモリ制限の計算式
使用可能メモリ = Sys - HeapReleased

// Sys: Goランタイムが確保した総メモリ
// HeapReleased: OSに返却したメモリ

設定方法

方法1: 環境変数

# 100 MiBに制限
export GOMEMLIMIT=100MiB

# 1 GiBに制限
export GOMEMLIMIT=1GiB

# バイト単位でも可
export GOMEMLIMIT=104857600  # 100 MiB

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

package main

import (
    "fmt"
    "runtime/debug"
)

func main() {
    // 100 MiBに設定
    oldLimit := debug.SetMemoryLimit(100 * 1024 * 1024)
    fmt.Printf("前の制限: %d bytes\n", oldLimit)
    
    // 現在の制限を取得
    currentLimit := debug.SetMemoryLimit(-1)
    fmt.Printf("現在の制限: %d bytes\n", currentLimit)
    
    // 制限を元に戻す
    debug.SetMemoryLimit(oldLimit)
}

メモリ制限とGOGCの組み合わせ

パターン1: 通常時は高GOGC、スパイク時は制限で保護

// 設定
GOGC=200        // 通常は効率的
GOMEMLIMIT=60MiB  // スパイク時の保険

// 動作
通常時:
  ライブ: 10 MiB
  ターゲット: 30 MiB (10 × (1 + 200/100))
  実際: 30 MiB ← GOGCに従う
  
スパイク時:
  ライブ: 50 MiB
  ターゲット: 150 MiB (50 × (1 + 200/100))
  実際: 60 MiB ← メモリ制限で止まる!

両方の利点を享受:

  • ✅ 通常時は効率的(高GOGC)
  • ✅ スパイク時もクラッシュしない(メモリ制限)

パターン2: GOGC=off + メモリ制限

// 設定
GOGC=off           // GC頻度を最小化
GOMEMLIMIT=100MiB  // でも制限は守る

// 動作
// メモリが制限に達するまでGCを実行しない
// → 最大限のリソース効率

例え話: 水槽に例えると:

  • GOGC: 「水が○○cmになったら排水」というルール
  • GOMEMLIMIT: 水槽の高さ(物理的な制限)
  • GOGC=off: 排水ルールなし、溢れるまで入れ続ける
  • GOGC=off + GOMEMLIMIT: 溢れる直前で排水

実際の動作例

package main

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

func demonstrateMemoryLimit() {
    // メモリ制限を50 MiBに設定
    debug.SetMemoryLimit(50 * 1024 * 1024)
    debug.SetGCPercent(200)  // 高GOGC
    
    fmt.Println("メモリを徐々に割り当てます...")
    
    data := make([][]byte, 0)
    ticker := time.NewTicker(100 * time.Millisecond)
    defer ticker.Stop()
    
    for i := 0; i < 100; i++ {
        <-ticker.C
        
        // 1 MiBずつ割り当て
        data = append(data, make([]byte, 1024*1024))
        
        var m runtime.MemStats
        runtime.ReadMemStats(&m)
        
        fmt.Printf("割り当て: %d MiB, ", (i+1))
        fmt.Printf("使用中: %d MiB, ", m.Alloc/1024/1024)
        fmt.Printf("GC回数: %d\n", m.NumGC)
        
        // メモリ制限に近づくと、GCが頻繁に実行される
    }
}

スラッシング(Thrashing) – 最大の危険

スラッシングとは、GCが常に実行されてプログラムが進まない状態です。

スラッシングが起こる条件

ライブヒープ ≈ メモリ制限

// 危険な設定
ライブヒープ: 45 MiB
GOMEMLIMIT: 50 MiB  // ギリギリ!
GOGC: off

// 何が起こるか
// 1. 少し割り当て → 制限到達
// 2. GC実行 → わずかにメモリ解放
// 3. また少し割り当て → 制限到達
// 4. GC実行 → ...
// → 無限ループ! プログラムが進まない!

視覚化

時間 →

通常:
[アプリ動作][GC][アプリ動作][GC][アプリ動作]
         ↑短い         ↑短い

スラッシング:
[GC][GC][GC][アプリ][GC][GC][GC][アプリ][GC]...
    ↑ほとんどGC!

例え話: 交通渋滞のようなものです:

  • 通常: スムーズに流れる
  • スラッシング: 動いては止まり、動いては止まり…

ソフトリミット – 賢い緩和策

Goのメモリ制限は**ソフト(柔軟)**です。つまり、必ず守られるとは限りません。

なぜソフトなのか?

スラッシングを防ぐためです。

GC CPU制限

GCが使えるCPU時間 ≤ 50%
測定ウィンドウ: 2 × GOMAXPROCS CPU秒

意味:

  • GCがCPU時間の50%以上を使おうとする
  • → GCを一時停止
  • → メモリ制限を超えることを許容
  • → プログラムが進む

// 状況
ライブヒープ: 90 MiB
メモリ制限: 100 MiB
割り当て速度: 高速

// GCの判断
「メモリ制限を守るには、CPU時間の80%が必要」
「でも、CPU制限は50%」
「制限を超えることを許可します」
→ メモリ: 110 MiB (制限超過)
→ でもプログラムは進む

50%という数字の理由

最悪のケース:
- メモリ制限が誤って低く設定された
- GCがCPUの50%を使う
- プログラムの速度は最大で2倍遅くなる

これは許容範囲内と判断

推奨される使い方

✅ 使うべき場合

1. コンテナでの実行
# Docker Compose
services:
  app:
    image: myapp
    mem_limit: 512m  # コンテナ制限
    environment:
      - GOMEMLIMIT=460MiB  # 90%を使用(10%のバッファ)
      - GOGC=100

理由:

  • 環境が完全に制御下にある
  • Goプログラムが唯一のメモリ利用者
  • 5-10%のヘッドルームを残す(Goランタイムが知らないメモリ使用のため)
2. 動的な調整
func adaptiveMemoryManagement(useCLibrary bool) {
    if useCLibrary {
        // Cライブラリが一時的に大量メモリを使う
        debug.SetMemoryLimit(500 * 1024 * 1024)  // 制限を緩める
    } else {
        // 通常モード
        debug.SetMemoryLimit(300 * 1024 * 1024)  // 制限を厳しく
    }
}

❌ 使わない方が良い場合

1. 他のプログラムと共有する環境
// シナリオ: 1GBのメモリを3つのプログラムで共有
// NG設定
Goプログラム: GOMEMLIMIT=333MiB, GOGC=off
他のプログラムA: 使用量不明
他のプログラムB: 使用量不明

// 問題: 同時に全部がメモリを使うと破綻

// 良い設定
Goプログラム: GOMEMLIMIT=333MiB, GOGC=75
// → 通常は少なめに使い、必要時だけ多く使う
2. CLIツールやデスクトップアプリ
// NG: ハードコードされたメモリ制限
func init() {
    debug.SetMemoryLimit(100 * 1024 * 1024)
}

// 問題:
// - ユーザーの環境が不明
// - 入力サイズが予測不可能
// - 混乱を招くクラッシュ
3. すでにメモリギリギリの環境
// シナリオ
利用可能メモリ: 512 MiB
ライブヒープ: 500 MiB

// NG設定
GOMEMLIMIT=510MiB  // スラッシングのリスク!

// 良い設定
1. メモリを増やす(1 GiBに)、または
2. GOGCを下げる(GOGC=50)

実践的なチューニング例

ケース1: Webサービス in コンテナ

# Kubernetes Pod設定
resources:
  limits:
    memory: "1Gi"
  requests:
    memory: "1Gi"

env:
  - name: GOMEMLIMIT
    value: "950MiB"  # 95%を使用
  - name: GOGC
    value: "100"     # デフォルト

ケース2: バッチ処理

# 大量データ処理
export GOMEMLIMIT=4GiB
export GOGC=200  # CPU節約優先

./batch-processor --input=large-dataset.csv

ケース3: マイクロサービス

package main

import (
    "os"
    "runtime/debug"
    "strconv"
)

func init() {
    // 環境変数からメモリ制限を取得
    if limit := os.Getenv("MEMORY_LIMIT_MB"); limit != "" {
        if mb, err := strconv.Atoi(limit); err == nil {
            debug.SetMemoryLimit(int64(mb) * 1024 * 1024)
        }
    }
    
    // デプロイ環境に応じた設定
    env := os.Getenv("ENV")
    switch env {
    case "production":
        debug.SetGCPercent(100)  // バランス
    case "development":
        debug.SetGCPercent(200)  // 開発時はレスポンス優先
    }
}

監視とデバッグ

package main

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

func monitorMemory() {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()
    
    for range ticker.C {
        var m runtime.MemStats
        runtime.ReadMemStats(&m)
        
        limit := debug.SetMemoryLimit(-1)
        usage := float64(m.Sys-m.HeapReleased) / float64(limit) * 100
        
        fmt.Printf("メモリ使用率: %.1f%%\n", usage)
        fmt.Printf("  制限: %d MiB\n", limit/1024/1024)
        fmt.Printf("  使用中: %d MiB\n", (m.Sys-m.HeapReleased)/1024/1024)
        fmt.Printf("  GC回数: %d\n", m.NumGC)
        
        // 警告
        if usage > 90 {
            fmt.Println("⚠️ メモリ使用率が高い!")
        }
        if m.NumGC > 100 && time.Since(startTime) < time.Minute {
            fmt.Println("⚠️ GCが頻繁すぎる可能性(スラッシング?)")
        }
    }
}

var startTime = time.Now()

まとめ

概念説明目的
GOMEMLIMIT最大メモリ量スパイクから保護
ソフトリミット厳密ではない制限スラッシング防止
GC CPU制限最大50%極端な遅延を防ぐ
スラッシングGC地獄避けるべき状態

チューニングのベストプラクティス:

  1. コンテナでは必ず設定 – 90-95%を目安に
  2. 5-10%のバッファを残す – 未知のメモリ使用のため
  3. ギリギリの設定は避ける – スラッシングのリスク
  4. GOGC と併用 – 両方の利点を活用
  5. 監視する – 使用率とGC頻度をチェック

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

おわりに 

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

よっしー
よっしー

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

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

コメント

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