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

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

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

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

スポンサーリンク

背景

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

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

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

仮想メモリに関する注意

このガイドは主にGCの物理メモリ使用に焦点を当ててきましたが、よく出てくる質問は、それが正確に何を意味するのか、そして仮想メモリ(topなどのプログラムで通常「VSS」として表示される)とどのように比較されるのかということです。

物理メモリは、ほとんどのコンピュータの実際の物理RAMチップに収容されているメモリです。仮想メモリは、プログラムを互いに分離するためにオペレーティングシステムが提供する物理メモリ上の抽象化です。また、プログラムが物理アドレスにまったくマッピングされない仮想アドレス空間を予約することも、通常は許容されます。

仮想メモリはオペレーティングシステムによって維持されるマッピングに過ぎないため、物理メモリにマッピングされない大規模な仮想メモリ予約を行うことは、通常非常に安価です。

Goランタイムは一般的に、いくつかの方法で仮想メモリのコストに関するこの見解に依存しています。

  • Goランタイムは、マッピングした仮想メモリを削除することはありません。代わりに、ほとんどのオペレーティングシステムが提供する特別な操作を使用して、ある仮想メモリ範囲に関連付けられた物理メモリリソースを明示的に解放します。

この技術は、メモリ制限を管理し、Goランタイムがもはや必要としないメモリをオペレーティングシステムに返すために明示的に使用されます。Goランタイムはまた、バックグラウンドで継続的にもはや必要としないメモリを解放します。詳細については、追加リソースを参照してください。

  • 32ビットプラットフォームでは、Goランタイムは断片化の問題を制限するために、ヒープのために128 MiBから512 MiBのアドレス空間を事前に予約します。
  • Goランタイムは、いくつかの内部データ構造の実装で大規模な仮想メモリアドレス空間予約を使用します。64ビットプラットフォームでは、これらは通常、約700 MiBの最小仮想メモリフットプリントを持ちます。32ビットプラットフォームでは、そのフットプリントは無視できます。

その結果、topの「VSS」などの仮想メモリメトリックは、通常、Goプログラムのメモリフットプリントを理解するのにあまり有用ではありません。代わりに、物理メモリ使用量をより直接的に反映する「RSS」および同様の測定値に焦点を当ててください。

仮想メモリとは? – 2種類のメモリ

Goアプリケーションのメモリを監視していると、2つの異なる数値が表示されることがあります。これらは何が違うのでしょうか?

物理メモリ (Physical Memory)

実際にRAMチップに保存されているメモリ

┌─────────────────────┐
│   物理RAMチップ      │
│ ┌─────────────────┐ │
│ │ 実際のデータ    │ │
│ │ が保存される    │ │
│ └─────────────────┘ │
└─────────────────────┘

特徴:
✅ 実際に使用中
✅ 有限(例: 16GB)
✅ 高速

仮想メモリ (Virtual Memory)

OSが提供する「アドレス空間の予約」

┌─────────────────────┐
│  仮想アドレス空間   │
│ ┌─────────────────┐ │
│ │ 予約されている  │ │ ← 物理メモリにマップされていない
│ │ だけで、        │ │    かもしれない
│ │ 実際は空かも    │ │
│ └─────────────────┘ │
└─────────────────────┘

特徴:
✅ 予約だけなら安い
✅ 非常に大きくてもOK(64bit環境)
✅ 実際の使用量とは別

例え話: ホテルの予約

物理メモリ = 実際に使っている部屋

  • ホテルに実際にチェックインして泊まっている
  • 部屋代を払っている
  • 実際にベッドで寝ている

仮想メモリ = 予約だけした部屋

  • 将来のために予約している
  • まだチェックインしていない
  • 部屋代はまだ払っていない(キャンセル料もない)

topコマンドで見る2つの値

$ top

PID   USER  VSS    RSS    %MEM
1234  user  2.5G   100M   0.6%
      ↑           ↑
      │           └─ 物理メモリ(実際に使用中)
      └─ 仮想メモリ(予約しているだけ)

VSS (Virtual Set Size)

予約している仮想メモリの合計

// Goプログラムの場合
VSS = 実際に使用中 + 予約だけしている + 内部データ構造

// 例:
// - 実際に使用: 100 MB
// - 予約だけ: 2.4 GB  ← 大きい!
// - VSS: 2.5 GB

RSS (Resident Set Size)

実際に物理RAMに保存されているメモリ

// これが本当のメモリ使用量
RSS = 実際に使用中のメモリ

// 例:
// - RSS: 100 MB  ← これが重要!

Goが仮想メモリを大量に予約する理由

理由1: 仮想メモリは安い

// 64ビット環境では...
virtualSpace := 700 * 1024 * 1024  // 700 MBを予約

// コスト:
// - 物理メモリ: 0 MB (まだマップしていない)
// - 仮想メモリ: 700 MB (予約だけ)
// - 実際のコスト: ほぼゼロ!

なぜ安いのか?

仮想メモリの予約 = OSのテーブルにエントリを追加するだけ

┌─────────────────────┐
│  OSのマッピング表   │
│ ┌─────────────────┐ │
│ │ 0x1000: 未使用 │ │ ← エントリだけ
│ │ 0x2000: 未使用 │ │    物理メモリは
│ │ 0x3000: 未使用 │ │    使っていない
│ └─────────────────┘ │
└─────────────────────┘

理由2: メモリを削除せず、解放する

Goは仮想メモリを削除しませんが、物理メモリは解放します。

// 通常のプログラム:
malloc(size)  // メモリ確保
free(ptr)     // メモリ削除 ← 仮想メモリも物理メモリも解放

// Goランタイム:
map(size)     // 仮想メモリを予約
// ...使用...
release(ptr)  // 物理メモリだけ解放 ← 仮想メモリは保持!

視覚化:

初期状態:
仮想: [予約済み空間................................]
物理: [                                          ]

使用開始:
仮想: [予約済み空間................................]
物理: [■■■■■                                    ]

解放後:
仮想: [予約済み空間................................] ← 維持
物理: [                                          ] ← 解放

理由3: 断片化の防止(32ビット環境)

// 32ビット環境の制約
totalAddressSpace := 4 * 1024 * 1024 * 1024  // 4 GB

// Goの戦略: ヒープ用に事前予約
heapReservation := 128 * 1024 * 1024  // 128 MB〜512 MB

// これにより:
// ✅ 連続したアドレス空間を確保
// ✅ 断片化を防ぐ
// ✅ 効率的なメモリ管理

断片化の問題:

悪い例(予約なし):
[Go][他][Go][他][Go][他]  ← メモリが断片化

良い例(予約あり):
[    Go専用エリア    ][他][他][他]  ← 連続して使える

理由4: 内部データ構造(64ビット環境)

// 64ビット環境では、Goは内部管理のために
// 約700 MBの仮想メモリを予約

// これは:
// - ヒープのメタデータ
// - GCの管理構造
// - メモリアロケータの情報
// などに使用される

// でも実際の物理メモリ使用量は小さい!

実際の例

例1: シンプルなGoプログラム

package main

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

func main() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    
    fmt.Printf("Allocated: %d MB\n", m.Alloc/1024/1024)
    fmt.Printf("System: %d MB\n", m.Sys/1024/1024)
    
    // topコマンドで確認:
    // VSS: 約1GB以上  ← 大きい!
    // RSS: 約5MB      ← 小さい!
    
    time.Sleep(time.Hour)
}

topでの表示:

$ top
PID   VSS    RSS
1234  1.2G   5M
      ↑      ↑
      │      └─ 実際に使用中
      └─ 予約しているだけ

実際の例

例2: メモリを大量に割り当てる

package main

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

func main() {
    // 100MBを割り当て
    data := make([]byte, 100*1024*1024)
    _ = data
    
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    
    fmt.Printf("Allocated: %d MB\n", m.Alloc/1024/1024)
    fmt.Printf("System: %d MB\n", m.Sys/1024/1024)
    
    // topコマンドで確認:
    // VSS: 約1.3GB   ← さっきとあまり変わらない
    // RSS: 約105MB   ← これが増えた!
    
    time.Sleep(time.Hour)
}

例3: メモリを解放する

package main

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

func main() {
    // 100MBを割り当て
    data := make([]byte, 100*1024*1024)
    _ = data
    
    fmt.Println("使用中...")
    time.Sleep(5 * time.Second)
    
    // 解放
    data = nil
    runtime.GC()
    
    fmt.Println("解放後...")
    
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    
    fmt.Printf("Allocated: %d MB\n", m.Alloc/1024/1024)
    
    // topコマンドで確認:
    // VSS: 約1.3GB   ← 変わらない(仮想メモリは保持)
    // RSS: 約5MB     ← 減った!(物理メモリは解放)
    
    time.Sleep(time.Hour)
}

監視すべきメトリック

✅ 注目すべき: RSS

# RSSを監視
$ top -p <pid>
# または
$ ps aux | grep myapp

# RSSが重要な理由:
# ✅ 実際の物理メモリ使用量
# ✅ システムへの実際の負荷
# ✅ OOMの判断基準

❌ 無視してよい: VSS

# VSSは通常無視してOK

# 理由:
# - Goは大量の仮想メモリを予約する
# - でも実際には使っていない
# - システムへの負荷はほぼゼロ

実践的な監視例

コンテナ環境での監視

# Docker Compose
services:
  app:
    image: myapp
    mem_limit: 512m  # 物理メモリの制限
    environment:
      - GOMEMLIMIT=460MiB  # Goのメモリ制限(RSS基準)

重要: mem_limitGOMEMLIMITも**物理メモリ(RSS)**を制限します。

Goプログラムでの監視

package main

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

func monitorMemory() {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()
    
    for range ticker.C {
        var m runtime.MemStats
        runtime.ReadMemStats(&m)
        
        // 重要なメトリック
        fmt.Printf("=== メモリ統計 ===\n")
        fmt.Printf("Alloc: %d MB (現在使用中)\n", 
            m.Alloc/1024/1024)
        fmt.Printf("Sys: %d MB (OSから取得済み)\n", 
            m.Sys/1024/1024)
        fmt.Printf("HeapReleased: %d MB (OSに返却済み)\n", 
            m.HeapReleased/1024/1024)
        
        // RSS相当の計算
        rss := m.Sys - m.HeapReleased
        fmt.Printf("推定RSS: %d MB\n", rss/1024/1024)
    }
}

Prometheusでの監視

import (
    "github.com/prometheus/client_golang/prometheus"
    "runtime"
)

var (
    memoryAlloc = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "go_memory_alloc_bytes",
        Help: "Currently allocated memory",
    })
    
    memoryRSS = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "go_memory_rss_bytes",
        Help: "Estimated RSS",
    })
)

func updateMetrics() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    
    memoryAlloc.Set(float64(m.Alloc))
    memoryRSS.Set(float64(m.Sys - m.HeapReleased))
}

よくある誤解と解決

誤解1: VSSが大きい = メモリリーク?

❌ 間違い:
「VSSが1GBもある!メモリリークだ!」

✅ 正しい理解:
「VSSは予約だけ。RSSを確認しよう」

確認方法:
$ top
RSS: 50MB ← これが小さければOK
VSS: 1GB  ← 無視してよい

誤解2: VSSを減らしたい

❌ 間違い:
「VSSを減らす設定はないか?」

✅ 正しい理解:
「VSSは気にしなくていい。
 RSSを最適化しよう」

最適化方法:
- GOGC を調整
- GOMEMLIMIT を設定
- メモリプールを使用

誤解3: 32ビットと64ビットで同じ

❌ 間違い:
「32ビットでも64ビットでも同じでしょ?」

✅ 正しい理解:

32ビット:
- アドレス空間: 4GB (制限あり)
- Goの予約: 128-512MB (小さめ)
- VSSへの影響: 小さい

64ビット:
- アドレス空間: 理論上無限
- Goの予約: 約700MB+ (大きめ)
- VSSへの影響: 大きい

まとめ表

メトリック意味重要度監視すべき?
RSS実際の物理メモリ⭐⭐⭐⭐⭐✅ Yes
VSS予約した仮想メモリ❌ No
Alloc現在使用中のヒープ⭐⭐⭐⭐✅ Yes
SysOSから取得したメモリ⭐⭐⭐✅ Yes
HeapReleasedOSに返却したメモリ⭐⭐⭐✅ Yes

実践チェックリスト

メモリ監視で確認すること:

□ RSSを監視している
□ VSSは無視している
□ runtime.MemStatsを定期的に取得
□ RSS = Sys - HeapReleased を計算
□ アラートはRSS基準で設定
□ コンテナの制限はRSS基準
□ GOMEMLIMITもRSS基準で設定

最重要ポイント

VSSは無視、RSSを見る!

Goアプリケーションのメモリ監視では:

  1. RSS (Resident Set Size) を監視する
  2. VSS (Virtual Set Size) は無視する
  3. ✅ Goは大量の仮想メモリを予約するが、それは正常
  4. ✅ 実際のメモリ使用量はRSSで判断する

これで仮想メモリの謎が解けました! 🎉

おわりに 

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

よっしー
よっしー

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

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

コメント

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