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

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

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

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

スポンサーリンク

背景

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

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

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

ヒーププロファイリング

GCが大きなコストの原因であることを特定した後、ヒープ割り当てを削減する次のステップは、それらのほとんどがどこから来ているかを見つけ出すことです。この目的のために、メモリプロファイル(実際には、ヒープメモリプロファイル)は非常に有用です。それらの使い始め方については、ドキュメントを確認してください。

メモリプロファイルは、プログラム内のどこからヒープ割り当てが来ているかを説明し、割り当てられた時点でのスタックトレースによってそれらを識別します。各メモリプロファイルは、メモリを4つの方法で分類できます。

  • inuse_objects—生きているオブジェクトの数で分類します。
  • inuse_space—生きているオブジェクトを、それらが使用するメモリ量(バイト単位)で分類します。
  • alloc_objects—Goプログラムの実行開始以降に割り当てられたオブジェクトの数で分類します。
  • alloc_space—Goプログラムの実行開始以降に割り当てられたメモリの総量で分類します。

これらの異なるヒープメモリのビューへの切り替えは、pprofツールへの-sample_indexフラグ、またはツールがインタラクティブに使用される場合はsample_indexオプションで行うことができます。

注:メモリプロファイルはデフォルトでヒープオブジェクトのサブセットのみをサンプリングするため、すべてのヒープ割り当てに関する情報が含まれているわけではありません。ただし、これはホットスポットを見つけるには十分です。サンプリング率を変更するには、runtime.MemProfileRateを参照してください。

GCコストを削減する目的では、alloc_spaceは通常最も有用なビューです。なぜなら、それは割り当て率に直接対応するからです。このビューは、最も恩恵をもたらす割り当てホットスポットを示します。

ヒーププロファイリング – 割り当ての犯人を見つける

ヒーププロファイルは、どこでメモリを割り当てているかを教えてくれる探偵ツールです。

ヒーププロファイルとは?

メモリ割り当ての「家計簿」

どの関数が
どれだけメモリを
何回割り当てたか

を記録したもの

例え話: クレジットカードの明細書

  • いつ: 日時
  • どこで: 店舗名
  • いくら: 金額
  • 何回: 取引回数

プロファイルの取得方法

方法1: pprofエンドポイント

package main

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

func main() {
    // pprofサーバーを起動
    go func() {
        http.ListenAndServe("localhost:6060", nil)
    }()
    
    // アプリケーション実行
    runApp()
}
# ヒーププロファイル取得
go tool pprof http://localhost:6060/debug/pprof/heap

# Webブラウザで見る
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap

方法2: ファイルに保存

package main

import (
    "os"
    "runtime/pprof"
)

func saveHeapProfile() {
    f, _ := os.Create("heap.prof")
    defer f.Close()
    
    pprof.WriteHeapProfile(f)
}

func main() {
    // アプリケーション実行
    runApp()
    
    // プロファイル保存
    saveHeapProfile()
}
# 保存したプロファイルを分析
go tool pprof heap.prof

4つのビュー – 異なる視点

1. inuse_objects (現在使用中のオブジェクト数)

今この瞬間、
何個のオブジェクトが生きているか

用途: メモリリークの発見

例え話: 今の時点で家にある物の個数

(pprof) top -sample_index=inuse_objects
      flat  flat%   sum%        cum   cum%
   500000 50.00% 50.00%    500000 50.00%  main.leak
                                ↑
                          50万個のオブジェクト!

2. inuse_space (現在使用中のメモリ量)

今この瞬間、
何バイトのメモリを使っているか

用途: 大きなオブジェクトの発見

例え話: 今の時点で家にある物の総重量

(pprof) top -sample_index=inuse_space
      flat  flat%   sum%        cum   cum%
    512MB 50.00% 50.00%     512MB 50.00%  main.bigAlloc
                                ↑
                           512MBも使用中!

3. alloc_objects (累積割り当てオブジェクト数)

プログラム起動から今まで、
何個のオブジェクトを割り当てたか(総数)

用途: 割り当て頻度の発見

例え話: これまでに買った物の総個数

(pprof) top -sample_index=alloc_objects
      flat  flat%   sum%        cum   cum%
  10000000 50.00% 50.00%   10000000 50.00%  main.frequent
                                ↑
                        1000万個も割り当て!

4. alloc_space (累積割り当てメモリ量) ⭐最重要

プログラム起動から今まで、
何バイトのメモリを割り当てたか(総量)

用途: GC最適化(最も重要!)

例え話: これまでに使ったお金の総額

(pprof) top -sample_index=alloc_space
      flat  flat%   sum%        cum   cum%
     5GB 50.00% 50.00%      5GB 50.00%  main.hotspot
                                ↑
                           5GBも割り当て!

4つのビューの関係

現在(スナップショット):        累積(歴史):
┌─────────────┐          ┌─────────────┐
│inuse_objects│          │alloc_objects│
│ (個数)      │          │ (総個数)    │
└─────────────┘          └─────────────┘

┌─────────────┐          ┌─────────────┐
│inuse_space  │          │alloc_space  │
│ (バイト数)  │          │ (総バイト数)│
└─────────────┘          └─────────────┘
     ↑                        ↑
  メモリリーク             GC最適化
  の発見                   に最適!

alloc_space が最重要な理由

GC頻度への直接的な影響

alloc_space が大きい
    ↓
割り当て率が高い
    ↓
GCが頻繁に実行される
    ↓
パフォーマンス低下

具体例:

関数A: alloc_space = 5 GB
関数B: alloc_space = 500 MB

関数Aを最適化すると:
- 割り当て削減: 5 GB
- GC頻度削減: 大幅
- 効果: 非常に大きい ✅

関数Bを最適化すると:
- 割り当て削減: 500 MB
- GC頻度削減: 小さい
- 効果: 小さい

ビューの切り替え方法

コマンドラインから

# alloc_spaceで起動(推奨)
go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/heap

# inuse_spaceで起動
go tool pprof -sample_index=inuse_space http://localhost:6060/debug/pprof/heap

インタラクティブモードで切り替え

(pprof) sample_index = alloc_space
(pprof) top

(pprof) sample_index = inuse_objects
(pprof) top

実践例: ホットスポットの発見

ステップ1: alloc_spaceでトップを確認

$ go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/heap

(pprof) top
      flat  flat%   sum%        cum   cum%
     2GB 40.00% 40.00%      2GB 40.00%  main.processData
     1GB 20.00% 60.00%      1GB 20.00%  main.parseJSON
   500MB 10.00% 70.00%    500MB 10.00%  main.formatOutput
   
解釈:
main.processData が最大のホットスポット!
→ ここを最適化すれば最大の効果

ステップ2: 詳細を確認

(pprof) list main.processData

Total: 5GB
ROUTINE ======================== main.processData
     2GB     2GB (flat, cum) 40.00% of Total
         .          .     10: func processData(items []Item) {
         .          .     11:     for _, item := range items {
     1GB      1GB     12:         buffer := make([]byte, 1024*1024)  ← ここ!
     500MB    500MB   13:         data := item.ToJSON()  ← ここも!
     500MB    500MB   14:         result := format(data)  ← ここも!
         .          .     15:     }
         .          .     16: }

問題点:
1. ループ内で毎回bufferを割り当て
2. JSON変換で割り当て
3. フォーマットで割り当て

ステップ3: 最適化

// Before
func processData(items []Item) {
    for _, item := range items {
        buffer := make([]byte, 1024*1024)  // 毎回1MB割り当て
        data := item.ToJSON()
        result := format(data)
        send(result)
    }
}

// After
var bufferPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 1024*1024)
    },
}

func processDataOptimized(items []Item) {
    buffer := bufferPool.Get().([]byte)
    defer bufferPool.Put(buffer)
    
    for _, item := range items {
        // バッファ再利用
        data := item.ToJSONWithBuffer(buffer)
        result := format(data)
        send(result)
    }
}

ステップ4: 効果を測定

# 最適化前
(pprof) top -sample_index=alloc_space
     2GB 40.00%  main.processData

# 最適化後
(pprof) top -sample_index=alloc_space
   200MB  4.00%  main.processDataOptimized

削減率: 90%!

サンプリングについて

デフォルトのサンプリング

runtime.MemProfileRate = 512 * 1024  // 512 KB

意味:
- 平均して512KBごとに1つのサンプルを取る
- すべての割り当てを記録するわけではない
- でも、ホットスポット発見には十分

例え話:

信号調査:

全数調査:
- すべての車をカウント
- 正確だが時間がかかる

サンプリング調査:
- 100台に1台カウント
- 十分な精度で速い

サンプリング率の変更

package main

import "runtime"

func init() {
    // より細かいサンプリング(重くなる)
    runtime.MemProfileRate = 1  // 1バイトごと(非推奨)
    
    // より粗いサンプリング(軽くなる)
    runtime.MemProfileRate = 1024 * 1024  // 1MBごと
    
    // サンプリング無効化(非推奨)
    runtime.MemProfileRate = 0
}

注意:

細かすぎるサンプリング:
✅ より正確
❌ プログラムが遅くなる
❌ プロファイルが巨大

推奨: デフォルト(512KB)を使う

よくあるパターン

パターン1: ループ内の割り当て

(pprof) list main.badLoop

     5GB     5GB  10: for i := 0; i < 1000000; i++ {
     5GB     5GB  11:     data := make([]byte, 1024)  ← 毎回割り当て!
         .      .  12:     process(data)
         .      .  13: }

対処: ループの外で1回だけ割り当て

パターン2: 文字列連結

(pprof) list main.badConcat

     2GB     2GB  10: var result string
     2GB     2GB  11: for i := 0; i < 10000; i++ {
     2GB     2GB  12:     result += "data"  ← 毎回新しい文字列!
         .      .  13: }

対処: strings.Builderを使う

パターン3: インターフェース変換

(pprof) list main.toInterface

     1GB     1GB  10: func toInterface(x int) interface{} {
     1GB     1GB  11:     return x  ← ヒープにエスケープ!
         .      .  12: }

対処: 具体的な型を使う

実用的なワークフロー

完全な分析手順

# 1. alloc_spaceでホットスポット発見
go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/heap
(pprof) top10
(pprof) list main.suspiciousFunction

# 2. 問題のコードを特定
# → ソースコードを確認

# 3. 最適化を実施
# → コードを修正

# 4. 効果を測定
go test -bench=. -benchmem
# Before: 5000 ns/op  2048 B/op  15 allocs/op
# After:  1000 ns/op   256 B/op   2 allocs/op

# 5. 再度プロファイル
go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/heap
(pprof) top10
# → 改善を確認

ベンチマークとの組み合わせ

func BenchmarkProcessData(b *testing.B) {
    items := makeTestItems(1000)
    
    b.ResetTimer()
    b.ReportAllocs()  // 割り当て情報を表示
    
    for i := 0; i < b.N; i++ {
        processData(items)
    }
}
# ベンチマーク実行
go test -bench=. -benchmem -memprofile=mem.prof

# 結果
BenchmarkProcessData-8  1000  1234567 ns/op  2048576 B/op  1000 allocs/op
                                              ↑           ↑
                                          2MB割り当て   1000回

# プロファイル分析
go tool pprof mem.prof
(pprof) top -sample_index=alloc_space

Webインターフェースの活用

# Webブラウザで開く
go tool pprof -http=:8080 -sample_index=alloc_space http://localhost:6060/debug/pprof/heap

便利な機能:

📊 Graph view:
- 関数の呼び出し関係を視覚化
- 太い矢印 = 多くの割り当て

🔥 Flame graph:
- 割り当ての階層を視覚化
- 広い部分 = ホットスポット

📋 Top view:
- 関数ランキング
- ソート可能

🔍 Source view:
- ソースコードと対応
- 行ごとの割り当て量

まとめ表

ビュー意味用途重要度
alloc_space累積割り当て量GC最適化⭐⭐⭐⭐⭐
alloc_objects累積割り当て数頻度確認⭐⭐⭐
inuse_space現在使用量リーク発見⭐⭐⭐
inuse_objects現在オブジェクト数リーク発見⭐⭐

チェックリスト

ヒーププロファイル分析:

□ alloc_spaceでホットスポット確認
□ top10で上位関数を特定
□ list で詳細を確認
□ ソースコードを読む
□ 最適化案を考える
□ ベンチマークで効果測定
□ 再度プロファイルで確認
□ 改善が十分か判断

最重要ポイント:

alloc_space を見る! これがGC最適化の鍵!

推奨される分析順序:

  1. alloc_spaceでホットスポット発見
  2. listで詳細確認
  3. 最適化実施
  4. benchmemで効果測定
  5. 繰り返し

この流れで進めることで、効率的に割り当てを削減できます! 🎯

おわりに 

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

よっしー
よっしー

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

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

コメント

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