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

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

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

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

スポンサーリンク

背景

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

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

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

Go値がどこに保存されるか

GCについて深く掘り下げる前に、まず、GCによって管理される必要のないメモリについて説明しましょう。

例えば、ローカル変数に格納された非ポインタのGo値は、おそらくGo GCによって管理されることはなく、代わりにGoは、作成されたレキシカルスコープに紐付けられたメモリを割り当てるように手配します。一般的に、これはGCに依存するよりも効率的です。なぜなら、Goコンパイラは、そのメモリがいつ解放できるかを事前に決定し、クリーンアップを行う機械語命令を発行できるからです。通常、この方法でGo値のためにメモリを割り当てることを「スタック割り当て」と呼びます。なぜなら、その領域はgoroutineスタックに格納されるからです。

Goコンパイラがその生存期間を決定できないために、この方法でメモリを割り当てることができないGo値は、ヒープにエスケープすると言われます。「ヒープ」は、Go値をどこかに配置する必要がある場合の、メモリ割り当ての受け皿のようなものと考えることができます。ヒープ上でメモリを割り当てる行為は、通常「動的メモリ割り当て」と呼ばれます。なぜなら、コンパイラとランタイムの両方が、このメモリがどのように使用され、いつクリーンアップできるかについて、ほとんど仮定を立てることができないからです。ここでGCが登場します。GCは、動的メモリ割り当てを特定してクリーンアップするシステムです。

Go値がヒープにエスケープする必要がある理由はたくさんあります。一つの理由は、そのサイズが動的に決定される場合です。例えば、初期サイズが定数ではなく変数によって決定されるスライスの裏側の配列を考えてみてください。ヒープへのエスケープは推移的でもなければなりません。つまり、すでにエスケープすることが決定されている別のGo値に、あるGo値への参照が書き込まれる場合、その値もエスケープしなければなりません。

Go値がエスケープするかどうかは、それが使用されるコンテキストと、Goコンパイラのエスケープ解析アルゴリズムの関数です。値がいつエスケープするかを正確に列挙しようとすることは脆弱で困難でしょう。アルゴリズム自体がかなり洗練されており、Goリリースごとに変更されます。どの値がエスケープし、どの値がエスケープしないかを識別する方法の詳細については、ヒープ割り当ての排除に関するセクションを参照してください。

メモリの保存場所は2つある

Goでは、変数やデータを保存する場所が主に2つあります:

  1. スタック(Stack) – 速くて効率的
  2. ヒープ(Heap) – 柔軟だけど、GCが必要

1. スタック割り当て – GC不要!

スタックは、関数の実行に使われる「作業台」のようなものです。

func calculateSum() int {
    a := 10      // スタックに割り当て
    b := 20      // スタックに割り当て
    sum := a + b // スタックに割り当て
    return sum
}
// 関数が終わると、a, b, sumは自動的に消える
// GCの出番なし!

スタック割り当ての特徴:

  • 高速 – メモリの確保と解放が一瞬
  • GC不要 – 関数が終わると自動的にクリーンアップ
  • 予測可能 – コンパイラがすべて管理

例え話: レストランのテーブル席のようなものです。

  • お客さん(関数)が来たら席(メモリ)を用意
  • 食事が終わったら席を片付け
  • 次のお客さんのためにすぐ使える

2. ヒープ割り当て – GCが必要

ヒープは、より長期間、または予測できない期間データを保存する場所です。

func createUser() *User {
    user := &User{Name: "太郎"}  // ヒープに割り当て
    return user  // 関数が終わってもuserは残る
}

func main() {
    u := createUser()  // uはヒープ上のデータを参照
    // ... uを使った処理 ...
}
// uが不要になったら、GCがメモリを回収

ヒープ割り当ての特徴:

  • ⚠️ スタックより遅い – メモリ管理のコストがかかる
  • ⚠️ GCが必要 – 不要になったメモリをGCが回収
  • 柔軟 – 関数を越えてデータを保持できる

例え話: 倉庫のようなものです。

  • いつまで必要かわからない荷物を保管
  • 定期的に「これはもういらない」とチェック(GC)
  • 不要な荷物を片付けてスペースを空ける

「エスケープする」とは?

「エスケープする」= スタックからヒープに移動すること

エスケープする例

例1: ポインタを返す

func newInt() *int {
    x := 42        // 本来はスタックに置きたい
    return &x      // でも関数の外で使われる → ヒープにエスケープ
}

例2: サイズが実行時に決まる

func createSlice(n int) []int {
    // nは実行時まで不明 → コンパイラが事前に判断できない
    return make([]int, n)  // ヒープにエスケープ
}

例3: インターフェースに格納

func storeValue() interface{} {
    x := 100       // 本来はスタックに置きたい
    return x       // interface{}に変換 → ヒープにエスケープ
}

エスケープしない例

func processData() {
    data := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}  // スタック
    sum := 0  // スタック
    
    for _, v := range data {
        sum += v
    }
    
    fmt.Println(sum)
    // 関数が終わると全部消える - GC不要!
}

エスケープ解析 – コンパイラの仕事

エスケープ解析は、コンパイラが「この変数はスタックで大丈夫?それともヒープが必要?」を判断するプロセスです。

// コンパイラの思考プロセス:
func example() *User {
    user := User{Name: "花子"}
    
    // 🤔 このuserへのポインタが関数の外に出る
    // 🤔 関数が終わってもuserが必要
    // ✅ 判断: ヒープに割り当てよう
    
    return &user
}

エスケープをチェックする方法

go build -gcflags="-m" yourfile.go

出力例:

./main.go:5:6: moved to heap: user
./main.go:10:2: x escapes to heap

推移的エスケープ

ある値がヒープにエスケープすると、その値が参照している他の値も一緒にエスケープします。

type User struct {
    Name string
    Address *Address  // Userがエスケープすると...
}

type Address struct {
    City string  // これもエスケープする!
}

func createUser() *User {
    addr := &Address{City: "東京"}
    user := &User{
        Name: "次郎",
        Address: addr,  // addrもヒープに行く
    }
    return user
}

パフォーマンスへの影響

特性スタックヒープ
速度⚡ 超高速🐢 比較的遅い
GCの影響✅ なし⚠️ あり
サイズ制限⚠️ 小さい(通常1-2MB)✅ 大きい
生存期間📍 関数内のみ🌍 任意

実践的なアドバイス

1. 小さい値はそのまま使う

// 良い - スタックに置かれる可能性が高い
func process(user User) { ... }

// 避ける - 必要ない限りポインタを使わない
func process(user *User) { ... }

2. サイズが小さくて固定ならスタック優先

// スタックに置かれやすい
arr := [100]int{}

// ヒープに置かれる
slice := make([]int, n)  // nが変数の場合

3. 早期最適化は避ける

  • まずは読みやすいコードを書く
  • 問題があればプロファイリングで確認
  • 必要な箇所だけ最適化

まとめ

  • スタック: 高速・GC不要・関数内の一時的なデータ向け
  • ヒープ: 柔軟・GC必要・長期保存や動的なデータ向け
  • エスケープ: スタックに置けない値がヒープに移動すること
  • エスケープ解析: コンパイラが自動的に判断してくれる
  • 最適化: 必要になってから考える

次の記事では、ヒープに割り当てられたメモリを、GCがどのように管理・回収するかを学びます!

おわりに 

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

よっしー
よっしー

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

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

コメント

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