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

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

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

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

スポンサーリンク

背景

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

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

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

コストの理解

GCは本質的に、さらに複雑なシステム上に構築された複雑なソフトウェアです。GCを理解し、その動作を調整しようとすると、詳細に埋もれてしまいがちです。このセクションは、Go GCのコストとそのチューニングパラメータについて推論するためのフレームワークを提供することを目的としています。

まず、3つのシンプルな公理に基づくGCコストのモデルを考えてみましょう。

GCが関与するリソースは2つだけです:物理メモリとCPU時間です。

GCのメモリコストは、ライブヒープメモリ、マークフェーズ前に割り当てられた新しいヒープメモリ、およびメタデータ用のスペースで構成されますが、これらのコストに比例するとしても、比較的小さいものです。

サイクルNのGCメモリコスト = サイクルN-1からのライブヒープ + 新しいヒープ

ライブヒープメモリとは、前回のGCサイクルでライブであると判定されたメモリであり、新しいヒープメモリとは、現在のサイクルで割り当てられたメモリで、サイクル終了時点でライブである場合もそうでない場合もあります。任意の時点でどれだけのメモリがライブであるかは、プログラムの特性であり、GCが直接制御できるものではありません。

GCのCPUコストは、サイクルごとの固定コストと、ライブヒープのサイズに比例してスケールする限界コストとしてモデル化されます。

サイクルNのGC CPU時間 = サイクルごとの固定CPU時間コスト + 
                       バイトあたりの平均CPU時間コスト × サイクルNで見つかったライブヒープメモリ

サイクルごとの固定CPU時間コストには、次のGCサイクルのためのデータ構造の初期化など、各サイクルで一定回数発生する処理が含まれます。このコストは通常小さく、完全性のために含まれているだけです。

GCのCPUコストのほとんどは、マーキングとスキャンであり、これは限界コストによって捉えられます。マーキングとスキャンの平均コストは、GC実装に依存しますが、プログラムの動作にも依存します。例えば、ポインタが多いほどGCの作業が増えます。なぜなら、最低限、GCはプログラム内のすべてのポインタを訪問する必要があるからです。リンクリストやツリーのような構造も、GCが並列で辿るのがより困難で、バイトあたりの平均コストが増加します。

このモデルは、スイーピングコストを無視しています。スイーピングコストは、死んでいるメモリを含む総ヒープメモリに比例します(割り当て可能にする必要があります)。Goの現在のGC実装では、スイーピングはマーキングとスキャンよりもはるかに高速であるため、そのコストは比較的無視できます。

このモデルはシンプルですが効果的です。GCの支配的なコストを正確に分類します。また、ガベージコレクタの総CPUコストは、特定の時間枠内のGCサイクルの総数に依存することも教えてくれます。最後に、このモデルには、GCの基本的な時間/空間のトレードオフが組み込まれています。

その理由を理解するために、制約されているが有用なシナリオを探ってみましょう:定常状態です。GCの観点から見たアプリケーションの定常状態は、次の特性によって定義されます。

アプリケーションが新しいメモリを割り当てる速度(秒あたりのバイト数)が一定です。

これは、GCの観点から、アプリケーションのワークロードが時間とともにほぼ同じように見えることを意味します。例えば、Webサービスの場合、これは一定のリクエスト率であり、平均して同じ種類のリクエストが行われ、各リクエストの平均ライフタイムがほぼ一定であることを意味します。

GCの限界コストが一定です。

これは、オブジェクトサイズの分布、ポインタの数、データ構造の平均深度などのオブジェクトグラフの統計が、サイクルごとに同じままであることを意味します。

例を見ていきましょう。あるアプリケーションが定常状態で動作しており、10 MiB/sを割り当てているとします。一方、GCは100 MiB/cpu秒の速度でメモリをスキャンできます(これは仮定です)。定常状態はライブヒープのサイズについて何も仮定しませんが、簡単のため、このアプリケーションのライブヒープは常に10 MiBであるとしましょう。また、簡単のため、固定GCコストはゼロであると仮定しましょう。GCサイクル期間で遊んでみましょう。

各GCサイクルが正確に1 cpu秒後に発生するとします。すると、各GCサイクルの終わりまでに、この例のアプリケーションは10 MiBの追加メモリを割り当て、総ヒープサイズは20 MiBになります。そして、各GCサイクルごとに、GCは10 MiBのライブヒープをスキャンするのに0.1 cpu秒を費やし、CPUオーバーヘッドは10%になります。GCは、ヒープ全体ではなく、ライブヒープだけを辿る必要があることを思い出してください。(注:一定のライブヒープは、新しく割り当てられたメモリがすべて死んでいることを意味するわけではありません。GCが実行された後、古いヒープメモリと新しいヒープメモリの何らかの組み合わせが死に、最終結果として各サイクルで10 MiBがライブと判定されることだけを意味します。)

次に、各GCサイクルがあまり頻繁には起こらず、2 cpu秒ごとに1回起こるとします。すると、定常状態のこの例のアプリケーションは、各GCサイクルで総ヒープサイズが30 MiBになります。なぜなら、その時間に20 MiBを割り当てるからです。しかし、各GCサイクルでも、GCは10 MiBのライブメモリをスキャンするのに0.1 cpu秒しか必要としません。繰り返しますが、割り当てられるメモリの量に関係なく、ライブヒープサイズは同じままであると仮定しています。つまり、GCオーバーヘッドは、10%から5%に減少しましたが、その代償として50%多くのメモリが使用されています。

このオーバーヘッドの変化が、前述の基本的な時間/空間のトレードオフです。そして、GC頻度がこのトレードオフの中心にあります。GCをより頻繁に実行すると、使用するメモリは少なくなり、逆もまた真です。しかし、GCは実際にはどのくらいの頻度で実行されるのでしょうか?Goでは、GCをいつ開始すべきかを決定することが、ユーザーが制御できる主なパラメータです。

GCのコストを理解する – お金の話のように考えよう

GCには2種類のコストがあります。これは、お店を経営するときの「家賃」と「人件費」のようなものです。

GCのコストを理解する – お金の話のように考えよう

1. メモリコスト(家賃) – 場所代

2. CPUコスト(人件費) – 処理時間

メモリコスト – どれだけのメモリが必要か

今回のGCで必要なメモリ = 前回のGCで生きていたメモリ + 今回新しく作ったメモリ

例で理解する

// 前回のGC後
liveHeap := 10 // 10MBが生きている

// アプリケーションが動作中...
newAlloc := 5  // 5MB新しく割り当て

// 次のGCが実行される時点
totalHeap := liveHeap + newAlloc  // 15MB必要

重要なポイント:

  • ライブヒープ(前回生きていたメモリ)は、プログラムの特性で決まる
  • GCはこれを直接コントロールできない
  • 新しいメモリは、アプリケーションが割り当てる

例え話: 図書館で考えましょう。

  • ライブヒープ = 現在貸出中の本(絶対に必要)
  • 新しいメモリ = 新しく購入した本
  • 総メモリ = 貸出中の本 + 新しい本

CPUコスト – どれだけの処理時間が必要か

GCのCPU時間 = 固定コスト + (バイト単価 × ライブヒープサイズ)

2種類のコスト

1. 固定コスト – 毎回必ずかかる基本料金

// GCの準備作業
func gcCycle() {
    // データ構造の初期化
    initializeGCStructures()  // ← 固定コスト(小さい)
    
    // 実際のマーク&スキャン
    markAndScan()  // ← 変動コスト(大きい)
}

2. 変動コスト – ライブヒープのサイズに応じて変わる

// ライブヒープが大きいほど時間がかかる
liveHeap := 100MB
scanRate := 1000MB/秒
cpuTime := liveHeap / scanRate  // 0.1秒

何が変動コストを増やすか?

1. ポインタの数

// ポインタが多い構造 - GCの負荷が高い
type DataWithManyPointers struct {
    A *int
    B *string
    C *float64
    D *User
    E *Address
}

// ポインタが少ない構造 - GCの負荷が低い
type DataWithFewPointers struct {
    A int      // ポインタなし
    B string   // 内部にポインタあり(1つ)
    C float64  // ポインタなし
}

理由: GCはすべてのポインタを辿る必要があるから

2. データ構造の複雑さ

// 辿りやすい - 並列処理しやすい
type SimpleArray struct {
    Data [1000]int  // 連続したメモリ
}

// 辿りにくい - 並列処理しにくい
type LinkedList struct {
    Value int
    Next  *LinkedList  // あちこち飛び回る
}

定常状態(Steady State) – 安定した動作

定常状態とは、アプリケーションが一定のペースで動作している状態です。

条件1: 一定の割り当て速度

// 毎秒10MBずつメモリを割り当てる
const allocRate = 10 * 1024 * 1024  // 10MB/秒

func steadyStateApp() {
    ticker := time.NewTicker(1 * time.Second)
    for range ticker.C {
        // 毎秒同じ量のメモリを割り当て
        data := make([]byte, allocRate)
        process(data)
    }
}

条件2: 一定のGCコスト

オブジェクトの形や数が大きく変わらない状態です。

時間と空間のトレードオフ – 核心の概念

これがGCチューニングの最も重要な概念です!

具体例で理解しよう

前提条件:

  • アプリケーションは毎秒10MBを割り当てる
  • ライブヒープは常に10MB
  • GCは100MB/秒でスキャンできる

シナリオ1: GCを頻繁に実行(1秒ごと)

時間: 0秒 → 1秒
─────────────────────
割り当て: 10MB
ライブ: 10MB
総メモリ: 10MB + 10MB = 20MB

GC実行:
- スキャン対象: 10MB(ライブヒープのみ)
- 所要時間: 10MB ÷ 100MB/秒 = 0.1秒
- CPUオーバーヘッド: 0.1秒 ÷ 1秒 = 10%

結果: メモリ20MB、CPU負荷10%

シナリオ2: GCをあまり実行しない(2秒ごと)

時間: 0秒 → 2秒
─────────────────────
割り当て: 20MB (10MB × 2秒)
ライブ: 10MB (変わらず)
総メモリ: 10MB + 20MB = 30MB

GC実行:
- スキャン対象: 10MB(ライブヒープのみ!)
- 所要時間: 10MB ÷ 100MB/秒 = 0.1秒
- CPUオーバーヘッド: 0.1秒 ÷ 2秒 = 5%

結果: メモリ30MB、CPU負荷5%

トレードオフの可視化

GC頻度:  高い ←────────────→ 低い
         ↓                    ↓
メモリ:  少ない(20MB)      多い(30MB)
CPU:     高い(10%)        低い(5%)

これが「時間/空間のトレードオフ」です!

実践的な例

package main

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

func demonstrateTradeoff() {
    // シナリオ1: 頻繁なGC
    fmt.Println("=== 頻繁なGC ===")
    runtime.GC() // GC実行
    time.Sleep(1 * time.Second)
    
    var m1 runtime.MemStats
    runtime.ReadMemStats(&m1)
    fmt.Printf("総メモリ: %d MB\n", m1.Alloc/1024/1024)
    fmt.Printf("GC回数: %d\n", m1.NumGC)
    
    // シナリオ2: まれなGC
    // GOGC=200 に設定すると、GCの頻度が下がる
    // (デフォルトはGOGC=100)
    fmt.Println("\n=== まれなGC(GOGC=200) ===")
    // 環境変数 GOGC=200 で実行すると...
    // メモリ使用量は増えるが、CPU負荷は減る
}

なぜライブヒープだけスキャンするのか?

これが重要なポイントです!

// GCが実行される時点
totalHeap := 30MB  // 総メモリ
liveHeap := 10MB   // 生きているメモリ
deadHeap := 20MB   // 死んでいるメモリ

// GCがスキャンするのは...
scanTarget := liveHeap  // 10MBだけ!

// 理由: ポインタを辿るには、
// 生きているオブジェクトだけを見ればいい

例え話: 図書館で貸出中の本をチェックする場合:

  • 貸出中の本だけ確認すればいい(ライブヒープ)
  • 書庫にある本は確認不要(まだ使われていない)
  • 返却された本も確認不要(もう使われていない)

スイーピングコストが無視できる理由

マーク&スキャン: 複雑な追跡作業
    ↓
   遅い(支配的なコスト)

スイープ: シンプルな削除作業
    ↓
   速い(無視できるコスト)

比喩:

  • マーク&スキャン = 家中を探して必要なものを見つける(時間がかかる)
  • スイープ = 不要なものをゴミ箱に入れる(速い)

GC頻度を決めるもの

Goでは、いつGCを開始するかがユーザーが制御できる主なパラメータです。

GOGC環境変数

# デフォルト(GOGC=100)
# ライブヒープが2倍になったらGC実行
export GOGC=100

# メモリ節約モード(GOGC=50)
# ライブヒープが1.5倍になったらGC実行
export GOGC=50

# CPU節約モード(GOGC=200)
# ライブヒープが3倍になったらGC実行
export GOGC=200

# GC無効化(非推奨!)
export GOGC=off

計算式

次のGCトリガー = 前回のライブヒープ × (1 + GOGC/100)

例:
前回のライブヒープ = 10MB
GOGC = 100

次のGCトリガー = 10MB × (1 + 100/100)
               = 10MB × 2
               = 20MB

まとめ表

要素説明
メモリコスト必要なメモリ量ライブ10MB + 新規5MB = 15MB
CPUコスト処理にかかる時間10MB ÷ 100MB/秒 = 0.1秒
固定コスト毎回かかる基本料金初期化処理(小)
変動コストサイズに応じて変わるスキャン処理(大)
トレードオフ時間 vs 空間頻繁GC=少メモリ/高CPU

実践的なチューニング指針

ケース1: メモリが潤沢、CPUを節約したい

export GOGC=200  # GC頻度を下げる

ケース2: メモリが限られている

export GOGC=50   # GC頻度を上げる

ケース3: バランス重視(デフォルト)

export GOGC=100  # デフォルト設定

おわりに 

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

よっしー
よっしー

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

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

コメント

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