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

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

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

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

スポンサーリンク

背景

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

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

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

よくあるクリーンアップの問題

  • クリーンアップが添付されたオブジェクトは、クリーンアップ関数から到達可能であってはなりません(例えば、キャプチャされたローカル変数を通じて)。これにより、オブジェクトが回収されなくなり、クリーンアップが実行されなくなります。
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(fd int) {
    syscall.Close(f.fd) // 間違い: fを参照しているので、このクリーンアップは実行されない!
}, f.fd)
  • クリーンアップが添付されたオブジェクトは、クリーンアップ関数の引数から到達可能であってはなりません。これにより、オブジェクトが回収されなくなり、クリーンアップが実行されなくなります。
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(f *myFile) {
    syscall.Close(f.fd)
}, f) // 間違い: fを参照しているので、このクリーンアップは実行されない。この特定のケースはパニックも起こします。
  • ファイナライザには明確に定義された実行順序がありますが、クリーンアップにはありません。クリーンアップは互いに並行して実行されることもあります。
  • 長時間実行されるクリーンアップは、他のクリーンアップの実行をブロックしないように、goroutineを作成する必要があります。
  • runtime.GCは、到達不可能なオブジェクトのクリーンアップが実行されるまで待ちません。すべてがキューに入るまでしか待ちません。

クリーンアップの落とし穴 – よくある間違い

クリーンアップは便利ですが、非常に間違いやすい機能です。ここでは、典型的な間違いとその解決方法を学びます。

問題1: クロージャでオブジェクトを参照する

❌ 間違った例

type myFile struct {
    fd   int
    name string
}

func openFileBad(name string) *myFile {
    f := &myFile{
        fd:   syscall.Open(name, ...),
        name: name,
    }
    
    // 間違い!
    runtime.AddCleanup(f, func(fd int) {
        fmt.Printf("Closing %s\n", f.name)  // ← f を参照!
        syscall.Close(f.fd)                  // ← f を参照!
    }, f.fd)
    
    return f
}

// 何が起こるか:
// 1. クリーンアップ関数がfを参照している
// 2. fは到達可能なまま
// 3. GCはfを回収できない
// 4. クリーンアップは永遠に実行されない!

問題の本質:

[myFile オブジェクト] ←─┐
         ↓               │
    [クリーンアップ]      │
         ↓               │
    [クロージャ] ─────────┘
     (fを参照)

→ 循環参照!
→ GCが回収できない!

✅ 正しい例

func openFileGood(name string) *myFile {
    f := &myFile{
        fd:   syscall.Open(name, ...),
        name: name,
    }
    
    // 正しい: 必要な値だけをコピー
    fdCopy := f.fd
    nameCopy := f.name
    
    runtime.AddCleanup(f, func(fd int, name string) {
        fmt.Printf("Closing %s\n", name)  // ← fを参照していない!
        syscall.Close(fd)                 // ← fを参照していない!
    }, fdCopy, nameCopy)
    
    return f
}

// 何が起こるか:
// 1. クリーンアップ関数はfを参照していない
// 2. fが到達不可能になったら
// 3. GCはfを回収できる
// 4. クリーンアップが実行される!

視覚化:

[myFile オブジェクト]
         ↓
    [クリーンアップ]
         ↓
    [クロージャ]
         ↓
    fd と name のコピー
    (オブジェクトへの参照なし!)

→ 循環なし!
→ GCが回収できる!

実例: さまざまなパターン

間違い: フィールドにアクセス

// ❌ 悪い
runtime.AddCleanup(obj, func() {
    obj.cleanup()  // objを参照!
})

間違い: メソッドを呼ぶ

// ❌ 悪い
runtime.AddCleanup(obj, func() {
    obj.Close()  // objを参照!
})

正しい: 値をコピー

// ✅ 良い
value := obj.someValue
runtime.AddCleanup(obj, func(v int) {
    cleanup(v)  // objを参照していない
}, value)

問題2: 引数でオブジェクトを渡す

❌ 間違った例(パニックする!)

func openFileVeryBad(name string) *myFile {
    f := &myFile{
        fd:   syscall.Open(name, ...),
        name: name,
    }
    
    // 間違い: fそのものを引数に渡す
    runtime.AddCleanup(f, func(file *myFile) {
        syscall.Close(file.fd)
    }, f)  // ← これがパニックを起こす!
    
    return f
}

// 実行時エラー:
// panic: runtime: AddCleanup object cannot be an argument

なぜパニックするのか?

AddCleanup(obj, func(arg), obj)
           ↑              ↑
           同じオブジェクト!

→ 明らかな循環参照
→ Goが検出してパニック

✅ 正しい例

func openFileCorrect(name string) *myFile {
    f := &myFile{
        fd:   syscall.Open(name, ...),
        name: name,
    }
    
    // 正しい: fの内容をコピーして渡す
    runtime.AddCleanup(f, func(fd int, name string) {
        fmt.Printf("Closing file: %s (fd=%d)\n", name, fd)
        syscall.Close(fd)
    }, f.fd, f.name)  // ← 値だけをコピー
    
    return f
}

実践的なパターン

パターン1: プリミティブ型を使う

type Connection struct {
    socket int
    addr   string
}

func NewConnection(addr string) *Connection {
    conn := &Connection{
        socket: createSocket(addr),
        addr:   addr,
    }
    
    // ✅ プリミティブ型(int, string)をコピー
    socket := conn.socket
    addrCopy := conn.addr
    
    runtime.AddCleanup(conn, func(s int, a string) {
        log.Printf("Closing connection to %s", a)
        closeSocket(s)
    }, socket, addrCopy)
    
    return conn
}

パターン2: 必要なデータだけを抽出

type Database struct {
    handle  unsafe.Pointer
    queries int
    name    string
}

func NewDatabase(name string) *Database {
    db := &Database{
        handle: openDB(name),
        name:   name,
    }
    
    // ✅ クリーンアップに必要なものだけ
    handle := db.handle
    
    runtime.AddCleanup(db, func(h unsafe.Pointer) {
        closeDB(h)
    }, handle)
    
    return db
}

パターン3: 匿名関数を避ける

// ❌ 危険: 匿名関数は変数をキャプチャしやすい
func dangerousPattern(obj *MyObject) {
    runtime.AddCleanup(obj, func() {
        // うっかりobjを使ってしまう可能性が高い
    })
}

// ✅ 安全: 名前付き関数を使う
func cleanupMyObject(fd int) {
    syscall.Close(fd)
}

func safePattern(obj *MyObject) {
    runtime.AddCleanup(obj, cleanupMyObject, obj.fd)
}

問題3: 実行順序がない

クリーンアップには順序がない

type Parent struct {
    child *Child
}

type Child struct {
    data string
}

func createObjects() {
    child := &Child{data: "重要なデータ"}
    parent := &Parent{child: child}
    
    // Parentのクリーンアップ
    runtime.AddCleanup(parent, func(c *Child) {
        // 問題: Childが先にクリーンアップされるかも!
        fmt.Println(c.data)  // 危険!
    }, parent.child)
    
    // Childのクリーンアップ
    runtime.AddCleanup(child, func() {
        fmt.Println("Childをクリーンアップ")
    })
    
    // どちらが先に実行される? わからない!
}

ファイナライザには順序がある

// ファイナライザは実行順序が定義されている
// でも、複雑なので避けるべき

解決策: 依存関係を持たせない

// ✅ 各クリーンアップを独立させる
func createObjectsSafely() {
    child := &Child{data: "重要なデータ"}
    parent := &Parent{child: child}
    
    // Childのクリーンアップ: 独立
    childData := child.data
    runtime.AddCleanup(child, func(data string) {
        fmt.Printf("Child data: %s\n", data)
    }, childData)
    
    // Parentのクリーンアップ: 独立
    runtime.AddCleanup(parent, func() {
        fmt.Println("Parentをクリーンアップ")
    })
    
    // どちらが先でも問題ない!
}

問題4: 並行実行される

クリーンアップは同時に複数実行される可能性があります。

var counter int  // 共有変数

func createManyObjects() {
    for i := 0; i < 100; i++ {
        obj := &MyObject{}
        runtime.AddCleanup(obj, func() {
            counter++  // 競合状態!
            // 複数のクリーンアップが同時に実行される
        })
    }
}

解決策: 競合を避ける

var (
    counter int
    mu      sync.Mutex
)

func createManyObjectsSafely() {
    for i := 0; i < 100; i++ {
        obj := &MyObject{}
        runtime.AddCleanup(obj, func() {
            mu.Lock()
            counter++
            mu.Unlock()
        })
    }
}

// または、競合が起きない設計にする
func betterDesign() {
    for i := 0; i < 100; i++ {
        obj := &MyObject{}
        id := i  // 各クリーンアップが独立したデータを持つ
        runtime.AddCleanup(obj, func(id int) {
            log.Printf("Object %d cleaned", id)
        }, id)
    }
}

問題5: 長時間実行されるクリーンアップ

❌ 悪い例: 他のクリーンアップをブロック

func slowCleanupBad(obj *MyObject) {
    runtime.AddCleanup(obj, func(data []byte) {
        // 時間がかかる処理
        uploadToServer(data)  // 5秒かかる!
        
        // 問題: 他のクリーンアップが待たされる
    }, obj.data)
}

✅ 良い例: Goroutineを使う

func slowCleanupGood(obj *MyObject) {
    runtime.AddCleanup(obj, func(data []byte) {
        // Goroutineで実行
        go func() {
            uploadToServer(data)  // 5秒かかる
        }()
        // すぐに戻る → 他のクリーンアップをブロックしない
    }, obj.data)
}

注意点:

func slowCleanupWithError(obj *MyObject) {
    runtime.AddCleanup(obj, func(data []byte) {
        go func() {
            if err := uploadToServer(data); err != nil {
                // エラーをどうする?
                // クリーンアップなので、エラーを返せない
                log.Printf("Upload failed: %v", err)
            }
        }()
    }, obj.data)
}

問題6: runtime.GCは待たない

誤解

// これは期待通りに動かない
func misunderstanding() {
    obj := &MyObject{}
    runtime.AddCleanup(obj, func() {
        fmt.Println("クリーンアップ実行")
    })
    
    obj = nil
    runtime.GC()  // GCを呼ぶ
    
    // 「クリーンアップ実行」が出力される...はず?
    // → 出力されないかもしれない!
}

真実

// runtime.GCの動作:
// 1. 到達不可能なオブジェクトを見つける
// 2. クリーンアップをキューに入れる
// 3. すぐに戻る (実行は待たない!)
// 4. クリーンアップは別のgoroutineで実行される

func reality() {
    obj := &MyObject{}
    runtime.AddCleanup(obj, func() {
        fmt.Println("クリーンアップ実行")
    })
    
    obj = nil
    runtime.GC()  // キューに入れるだけ
    
    // ここではまだ実行されていない可能性が高い
    
    time.Sleep(100 * time.Millisecond)  // 少し待つ
    // → これでやっと実行される
}

テストでの対処

func TestCleanup(t *testing.T) {
    cleaned := false
    
    func() {
        obj := &MyObject{}
        runtime.AddCleanup(obj, func() {
            cleaned = true
        })
    }()
    
    // 確実にクリーンアップを実行させる
    runtime.GC()
    runtime.GC()  // 2回呼ぶ
    time.Sleep(10 * time.Millisecond)  // 少し待つ
    
    if !cleaned {
        t.Error("クリーンアップが実行されていない")
    }
}

チェックリスト

クリーンアップを使う前に確認:

□ クロージャでオブジェクトを参照していないか?
□ 引数でオブジェクト自身を渡していないか?
□ 必要な値だけをコピーしているか?
□ 他のクリーンアップとの依存関係はないか?
□ 長時間実行される場合、goroutineを使っているか?
□ 競合状態は起きないか?
□ テストを書いたか?

まとめ表

問題間違い正しい方法
循環参照fを参照値をコピー
引数エラーfを引数に値を引数に
実行順序依存関係独立させる
並行実行共有変数ロックor独立
長時間処理ブロックgoroutine
GC待ちすぐ実行期待時間を置く

黄金律:

クリーンアップ関数からオブジェクトを参照しない! 必要な値だけをコピーして渡す!

この原則を守れば、ほとんどの問題を避けられます!

おわりに 

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

よっしー
よっしー

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

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

コメント

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