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

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

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

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

スポンサーリンク

背景

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

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

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

CPUプロファイル

良い出発点はCPUプロファイリングです。CPUプロファイリングは、CPU時間がどこで費やされているかの概観を提供しますが、訓練を受けていない目には、特定のアプリケーションにおいてGCが果たす役割の大きさを特定することは難しいかもしれません。幸いなことに、GCがどのように関わっているかを理解することは、主にruntimeパッケージのさまざまな関数が何を意味するかを知ることに帰着します。以下は、CPUプロファイルを解釈するためのこれらの関数の有用なサブセットです。

以下にリストされている関数は、リーフ関数ではないため、pprofツールがtopコマンドで提供するデフォルトには表示されない場合があることに注意してください。代わりに、top -cumコマンドを使用するか、これらの関数で直接listコマンドを使用し、累積パーセント列に焦点を当ててください。

  • runtime.gcBgMarkWorker: バックグラウンドマークワーカーgoroutineへのエントリポイント。ここで費やされる時間は、GC頻度とオブジェクトグラフの複雑さとサイズに応じてスケールします。これは、アプリケーションがマーキングとスキャンに費やす時間のベースラインを表します。

これらのgoroutine内では、runtime.gcDrainMarkWorkerDedicatedruntime.gcDrainMarkWorkerFractional、およびruntime.gcDrainMarkWorkerIdleへの呼び出しが見つかります。これらはワーカータイプを示します。主にアイドル状態のGoアプリケーションでは、Go GCは追加の(アイドル)CPUリソースを使用して、より速く作業を完了しようとします。これはruntime.gcDrainMarkWorkerIdleシンボルで示されます。その結果、ここでの時間は、Go GCが自由だと信じているCPUサンプルの大きな割合を表す可能性があります。アプリケーションがよりアクティブになると、アイドルワーカーのCPU時間は減少します。これが発生する一般的な理由の1つは、アプリケーションが完全に1つのgoroutineで実行されているが、GOMAXPROCSが>1である場合です。

  • runtime.mallocgc: ヒープメモリのメモリアロケータへのエントリポイント。ここで費やされる累積時間が多い場合(>15%)は、通常、多くのメモリが割り当てられていることを示します。
  • runtime.gcAssistAlloc: goroutineがGCのスキャンとマーキングを支援するために時間の一部を譲るために入る関数。ここで費やされる累積時間が多い場合(>5%)は、アプリケーションが割り当ての速さに関してGCを上回っている可能性が高いことを示します。これは、GCからの特に高い影響度を示し、アプリケーションがマーキングとスキャンに費やす時間も表します。これはruntime.mallocgcのコールツリーに含まれているため、それも膨らませることに注意してください。

CPUプロファイル – GCの影響を測定する

CPUプロファイルは、プログラムのどこでCPU時間が使われているかを教えてくれる強力なツールです。

CPUプロファイルとは?

プログラムがどこで時間を使っているかの地図

プログラムの実行時間 = 100%

内訳:
- データベースクエリ: 30%
- JSON解析: 20%
- GC: 15%  ← これを見つけたい!
- その他: 35%

例え話: 家計簿をつけるようなものです

  • 何にいくら使ったか記録
  • 無駄遣いを見つける
  • 改善策を考える

pprofの基本的な使い方

ステップ1: プロファイルを有効にする

package main

import (
    "net/http"
    _ "net/http/pprof"  // pprofハンドラを自動登録
)

func main() {
    // pprofサーバーを起動
    go func() {
        http.ListenAndServe("localhost:6060", nil)
    }()
    
    // アプリケーションのメイン処理
    runApp()
}

ステップ2: プロファイルを取得

# 30秒間のCPUプロファイルを取得
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# プロファイルファイルから読み込み
go tool pprof cpu.prof

ステップ3: プロファイルを分析

# pprofインタラクティブモード
(pprof) top
Showing nodes accounting for 850ms, 85.00% of 1000ms total
Dropped 50 nodes (cum <= 5ms)
      flat  flat%   sum%        cum   cum%
     200ms 20.00% 20.00%      500ms 50.00%  myapp.processData
     150ms 15.00% 35.00%      300ms 30.00%  runtime.mallocgc
     100ms 10.00% 45.00%      200ms 20.00%  runtime.gcBgMarkWorker

# 累積時間でソート(GC関連を見つけやすい)
(pprof) top -cum

重要な用語の理解

flat vs cum (累積)

関数A {
    自分の処理: 100ms  ← flat
    関数B呼び出し: 200ms
}
関数B {
    自分の処理: 200ms
}

関数Aの:
- flat: 100ms (自分の処理だけ)
- cum:  300ms (自分 + 呼び出した関数の合計)

例え話:

レストラン経営(関数A):
- flat: オーナー自身の作業時間
- cum:  オーナー + スタッフ全員の作業時間

リーフ関数 vs 非リーフ関数

非リーフ関数(他を呼ぶ):
mainFunction() {
    helper1()  ← 他の関数を呼ぶ
    helper2()
}

リーフ関数(他を呼ばない):
calculate() {
    return x + y  ← 計算だけ、他を呼ばない
}

GC関連の重要な関数

1. runtime.gcBgMarkWorker

バックグラウンドでGC作業をする裏方

役割:
- マークフェーズの実行
- オブジェクトグラフのスキャン
- 生きているオブジェクトの特定

この時間が多い = GCが頻繁 or オブジェクトが多い

プロファイルでの見え方:

(pprof) list runtime.gcBgMarkWorker
Total: 1000ms
ROUTINE ======================== runtime.gcBgMarkWorker
     50ms      200ms (flat, cum) 20.00% of Total
         .          .     1: func gcBgMarkWorker() {
      10ms       50ms     2:     gcDrainMarkWorkerDedicated()
      20ms       80ms     3:     gcDrainMarkWorkerFractional()
      20ms       70ms     4:     gcDrainMarkWorkerIdle()
         .          .     5: }
3種類のワーカー

1. Dedicated Worker (専任ワーカー)

runtime.gcDrainMarkWorkerDedicated

// 特徴:
// - GC専用のCPU時間
// - マークフェーズ中は常に動作
// - 最も重要なワーカー

2. Fractional Worker (分数ワーカー)

runtime.gcDrainMarkWorkerFractional

// 特徴:
// - CPU時間の一部をGCに使う
// - アプリとGCでCPUを分け合う
// - 通常25%程度

3. Idle Worker (アイドルワーカー)

runtime.gcDrainMarkWorkerIdle

// 特徴:
// - 暇なCPUを使う
// - アプリが動いていない時だけ
// - 「タダで働く」ワーカー

重要な注意:

# アイドルワーカーが多い場合
(pprof) top -cum
200ms 20.00%  runtime.gcDrainMarkWorkerIdle  ← 多い!

# でも心配不要な場合:
# - アプリケーションが1個のgoroutineだけ
# - GOMAXPROCS=8 (CPUコアが8個)
# - 7個のコアが暇
# → GCが暇なコアを活用している

# これは無駄ではない!
# 暇なCPUを有効活用しているだけ

例え話:

レストランのスタッフ:

Dedicated Worker:
- ホールスタッフ
- 常に接客している

Fractional Worker:
- マネージャー
- 接客と管理を両方やる

Idle Worker:
- 暇なシェフ
- 料理の注文がない時に掃除を手伝う
  (本来の仕事ではないが、暇だから手伝う)

2. runtime.mallocgc

メモリ割り当ての入り口

役割:
- ヒープメモリの割り当て
- 新しいオブジェクトの作成
- make(), new() の実体

この時間が多い = メモリを大量に割り当てている

判断基準:

(pprof) top -cum
ROUTINE ======================== runtime.mallocgc
     100ms      400ms (cum) 40.00% of Total  ← 40%は多すぎ!

判断:
< 15%: ✅ 正常
15-30%: ⚠️ 注意
> 30%: 🔴 最適化が必要

よくあるパターン:

// 問題: ループ内で頻繁に割り当て
func badPattern() {
    for i := 0; i < 1000000; i++ {
        data := make([]byte, 1024)  // 毎回割り当て!
        process(data)
    }
}
// → runtime.mallocgcが多くなる

// 改善: 再利用
func goodPattern() {
    data := make([]byte, 1024)  // 1回だけ
    for i := 0; i < 1000000; i++ {
        process(data)  // 再利用
    }
}
// → runtime.mallocgcが減る

3. runtime.gcAssistAlloc

アプリケーションが強制的にGCを手伝う

役割:
- メモリを速く割り当てすぎた時
- アプリのgoroutineがGCを手伝う
- 「ちょっと待って!」のブレーキ

この時間が多い = アプリがGCより速すぎる

判断基準:

(pprof) top -cum
ROUTINE ======================== runtime.gcAssistAlloc
     50ms      150ms (cum) 15.00% of Total  ← 15%は多すぎ!

判断:
< 5%:  ✅ 正常
5-10%: ⚠️ 注意
> 10%: 🔴 深刻な問題

何が起きているか:

通常の動作:
[アプリ] → メモリ割り当て → [成功]

GCが追いつかない時:
[アプリ] → メモリ割り当て → [待って!]
         ↓
    GCを手伝わされる (gcAssistAlloc)
         ↓
      [やっと成功]

例え話:

工場の生産ライン:

通常:
- 製造: 製品を作る
- 掃除: 清掃スタッフが片付ける

GCアシスト:
- 製造が速すぎてゴミが溜まる
- 製造スタッフが掃除も手伝わされる
- 製造が遅くなる

実践例: プロファイルの読み方

例1: 正常なアプリケーション

(pprof) top -cum
Total: 1000ms
      flat  flat%   sum%        cum   cum%
     300ms 30.00% 30.00%      300ms 30.00%  main.processRequest
     100ms 10.00% 40.00%      150ms 15.00%  database.Query
      50ms  5.00% 45.00%      100ms 10.00%  runtime.mallocgc
      20ms  2.00% 47.00%       50ms  5.00%  runtime.gcBgMarkWorker
      10ms  1.00% 48.00%       20ms  2.00%  runtime.gcAssistAlloc

分析:
✅ mallocgc: 10% (正常)
✅ gcBgMarkWorker: 5% (正常)
✅ gcAssistAlloc: 2% (正常)
→ GCは問題なし

例2: GCが問題のアプリケーション

(pprof) top -cum
Total: 1000ms
      flat  flat%   sum%        cum   cum%
     200ms 20.00% 20.00%      200ms 20.00%  main.processRequest
     150ms 15.00% 35.00%      300ms 30.00%  runtime.mallocgc
     100ms 10.00% 45.00%      200ms 20.00%  runtime.gcBgMarkWorker
      80ms  8.00% 53.00%      150ms 15.00%  runtime.gcAssistAlloc

分析:
🔴 mallocgc: 30% (高すぎ!)
🔴 gcBgMarkWorker: 20% (高すぎ!)
🔴 gcAssistAlloc: 15% (深刻!)
→ GCが大きな問題

pprofのコマンド一覧

基本コマンド

# トップ関数を表示
(pprof) top
(pprof) top10
(pprof) top -cum  # 累積時間でソート

# 特定の関数を詳しく見る
(pprof) list runtime.mallocgc
(pprof) list main.processData

# Webインターフェース(便利!)
(pprof) web

# グラフ生成
(pprof) png > profile.png
(pprof) pdf > profile.pdf

フィルタリング

# GC関連だけを表示
(pprof) top -cum -focus=runtime.gc

# 特定の関数を除外
(pprof) top -cum -ignore=runtime.gc

# 正規表現で検索
(pprof) top -cum -sample_index=gc.*

Webインターフェースの使い方

# Webブラウザで開く
go tool pprof -http=:8080 cpu.prof

# または、プログラム実行中に
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/profile

Webインターフェースの機能:

  • 📊 グラフビュー – 関数の呼び出し関係を視覚化
  • 🔥 フレームグラフ – 時間の使われ方を直感的に表示
  • 📋 トップビュー – 関数のランキング
  • 🔍 ソースビュー – ソースコードと対応

実践的なデバッグワークフロー

ステップ1: 全体を把握

# 累積時間でトップ10
(pprof) top10 -cum

# GC関連の時間を確認
# - mallocgc
# - gcBgMarkWorker
# - gcAssistAlloc

ステップ2: GC関連を詳しく調べる

# mallocgcを呼んでいる関数を特定
(pprof) list runtime.mallocgc

# gcAssistAllocが多い場合
(pprof) list runtime.gcAssistAlloc

# どの関数がメモリを割り当てているか
(pprof) top -cum -focus=mallocgc

ステップ3: 問題の関数を特定

# 自分のコードで時間がかかっている関数
(pprof) top -cum -focus=main

# その関数の詳細
(pprof) list main.problemFunction

ステップ4: ソースコードを見る

# ソースコードと対応付け
(pprof) weblist main.problemFunction

# または
(pprof) list main.problemFunction

よくあるパターンと対処法

パターン1: mallocgcが多い

原因: メモリを大量に割り当てている

対処法:
1. オブジェクトプールを使う
2. バッファを再利用する
3. 事前割り当てを行う

パターン2: gcAssistAllocが多い

原因: 割り当てが速すぎてGCが追いつかない

対処法:
1. 割り当てを減らす(最優先)
2. GOGCを上げる
3. GOMEMLIMITを設定する

パターン3: gcBgMarkWorkerが多い

原因: オブジェクトグラフが複雑/大きい

対処法:
1. ポインタを減らす
2. データ構造を単純化する
3. GOGCを上げる

パターン4: アイドルワーカーが多い

原因: CPUが余っている

対処:
通常は問題なし
並行性を上げられるなら上げる

まとめ表

関数意味正常範囲多い場合の対処
mallocgcメモリ割り当て< 15%割り当てを減らす
gcBgMarkWorkerGCのマーク作業< 10%GOGCを上げる
gcAssistAlloc強制GC支援< 5%割り当て速度を下げる
gcDrainMarkWorkerIdle暇なCPUでGC任意通常問題なし

チェックリスト

□ pprofでCPUプロファイルを取得
□ top -cum でGC関連関数を確認
□ mallocgcの割合をチェック
□ gcAssistAllocが5%以下か確認
□ 問題の関数を特定
□ ソースコードと照らし合わせ
□ 最適化案を考える
□ 効果を測定

最重要ポイント:

top ではなく top -cum を使う! GC関連関数は非リーフなので累積時間で見る!

次は、具体的な最適化テクニックを学びます! 🎯

おわりに 

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

よっしー
よっしー

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

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

コメント

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