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

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

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

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

スポンサーリンク

背景

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

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

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

ファイナライザ、クリーンアップ、およびウィークポインタ

ガベージコレクションは、有限のメモリのみを使用して無限のメモリという錯覚を提供します。メモリは割り当てられますが、明示的に解放されることはなく、これにより、素朴な手動メモリ管理と比較して、よりシンプルなAPIと並行アルゴリズムが可能になります。(手動管理メモリを持つ一部の言語は、オブジェクトが解放されることを保証するために「スマートポインタ」やコンパイル時の所有権追跡などの代替アプローチを使用しますが、これらの機能はこれらの言語のAPI設計規約に深く組み込まれています。)

ライブなオブジェクト、つまりグローバル変数またはいくつかのgoroutineでの計算から到達可能なオブジェクトのみが、プログラムの動作に影響を与えることができます。オブジェクトが到達不可能(「死んでいる」)になった後はいつでも、GCによって安全にリサイクルされる可能性があります。これにより、今日Goが使用しているトレーシング設計など、さまざまなGC設計が可能になります。オブジェクトの死は、言語レベルでは観測可能なイベントではありません。

しかし、Goのランタイムライブラリは、この錯覚を破る3つの機能を提供しています。クリーンアップ、ウィークポインタ、およびファイナライザです。これらの機能はそれぞれ、オブジェクトの死を観測して反応する何らかの方法を提供し、ファイナライザの場合は、それを逆転させることさえできます。これはもちろん、Goプログラムを複雑にし、GC実装に追加の負担を追加します。それにもかかわらず、これらの機能は、さまざまな状況で有用であり、Goプログラムはそれらを使用し、常にそれらから恩恵を受けているため、存在しています。

各機能の詳細については、そのパッケージドキュメント(runtime.AddCleanup、weak.Pointer、runtime.SetFinalizer)を参照してください。以下は、これらの機能を使用するための一般的なアドバイス、各機能で遭遇する可能性のある一般的な問題の概要、およびこれらの機能の使用をテストするためのアドバイスです。

GCの基本原則 – 「オブジェクトの死」は見えない

通常、Goのガベージコレクションでは、オブジェクトがいつ削除されるかは観測できません

func example() {
    user := &User{Name: "太郎"}
    // ... userを使用 ...
    
    // 関数が終わると、userは到達不可能になる
    // でも「いつGCが実行されるか」はわからない
    // 「userがいつ削除されるか」も観測できない
}
// → これがGCの基本的な動作

例え話:

  • ゴミ箱にゴミを捨てる(オブジェクトが到達不可能になる)
  • いつゴミ収集車が来るかはわからない(GCのタイミング)
  • それでいい!(プログラマは気にしなくていい)

でも、時々「オブジェクトの死」を知りたい

しかし、実際のプログラミングでは、オブジェクトが削除される時に何かしたい場合があります。

典型的なシナリオ

1. ファイルを閉じる

func processFile() {
    file, _ := os.Open("data.txt")
    // ... ファイルを使用 ...
    
    // 忘れた!
    // file.Close()  ← これを呼ぶのを忘れた!
}
// → ファイルハンドルがリークする

2. データベース接続を閉じる

func query() {
    db, _ := sql.Open("mysql", "...")
    // ... クエリ実行 ...
    
    // 忘れた!
    // db.Close()  ← これを呼ぶのを忘れた!
}
// → 接続がリークする

3. Cのメモリを解放する

// #include <stdlib.h>
import "C"

func allocateCMemory() {
    ptr := C.malloc(1024)
    // ... Cのメモリを使用 ...
    
    // 忘れた!
    // C.free(ptr)  ← これを呼ぶのを忘れた!
}
// → Cのメモリがリークする

Goの3つの特殊機能

これらの問題を解決するために、Goは3つの特殊な機能を提供しています。

1. クリーンアップ (runtime.AddCleanup)

Go 1.24で追加された新機能

オブジェクトがGCされる時に、自動的に関数を呼び出します。

package main

import (
    "fmt"
    "os"
    "runtime"
)

type FileWrapper struct {
    file *os.File
}

func NewFileWrapper(filename string) *FileWrapper {
    file, err := os.Open(filename)
    if err != nil {
        panic(err)
    }
    
    wrapper := &FileWrapper{file: file}
    
    // クリーンアップを登録
    runtime.AddCleanup(wrapper, func(fw *FileWrapper) {
        fmt.Println("ファイルを自動的に閉じます")
        fw.file.Close()
    })
    
    return wrapper
}

func main() {
    wrapper := NewFileWrapper("data.txt")
    // ... ファイルを使用 ...
    
    // file.Close()を明示的に呼ばなくても...
    wrapper = nil  // 到達不可能にする
    
    runtime.GC()  // GCを強制実行
    // → クリーンアップ関数が自動的に呼ばれる!
}

例え話: 自動消灯システムのようなもの

  • 部屋を出る(オブジェクトが不要になる)
  • センサーが感知して自動的に電気を消す(クリーンアップ実行)

2. ウィークポインタ (weak.Pointer)

Go 1.24で追加された新機能

オブジェクトを「弱く」参照します。GCの判断に影響を与えません。

package main

import (
    "fmt"
    "runtime"
    "weak"
)

type CachedData struct {
    Value string
}

// キャッシュの実装
type Cache struct {
    data weak.Pointer[CachedData]
}

func main() {
    cache := &Cache{}
    
    // データを作成
    data := &CachedData{Value: "重要なデータ"}
    
    // ウィークポインタでキャッシュに保存
    cache.data = weak.Make(data)
    
    // データを使用
    if cached := cache.data.Value(); cached != nil {
        fmt.Println("キャッシュヒット:", cached.Value)
    }
    
    // 強い参照を削除
    data = nil
    
    // GCを実行
    runtime.GC()
    
    // ウィークポインタをチェック
    if cached := cache.data.Value(); cached != nil {
        fmt.Println("まだある:", cached.Value)
    } else {
        fmt.Println("GCされた")  // ← こちらが出力される
    }
}

例え話: 付箋メモのようなもの

  • 強い参照 = 契約書(これがある限り有効)
  • ウィーク参照 = 付箋(参照だけど、本体がなくなったら無効)

3. ファイナライザ (runtime.SetFinalizer)

古くからある機能(推奨されない)

オブジェクトがGCされる直前に関数を呼び出します。

package main

import (
    "fmt"
    "runtime"
)

type Resource struct {
    ID int
}

func NewResource(id int) *Resource {
    r := &Resource{ID: id}
    
    // ファイナライザを設定
    runtime.SetFinalizer(r, func(r *Resource) {
        fmt.Printf("Resource %d が削除されます\n", r.ID)
        // クリーンアップ処理...
    })
    
    return r
}

func main() {
    r := NewResource(1)
    r = nil  // 到達不可能にする
    
    runtime.GC()  // GCを強制実行
    // → ファイナライザが呼ばれる
}

警告: ファイナライザは複雑で問題が多いため、新しいコードではクリーンアップを使うべきです

3つの機能の比較

機能追加バージョン用途推奨度
クリーンアップGo 1.24リソース解放✅ 推奨
ウィークポインタGo 1.24キャッシュ実装✅ 推奨
ファイナライザGo 1.0レガシーコード⚠️ 非推奨

クリーンアップの実践例

ファイル管理

package main

import (
    "fmt"
    "os"
    "runtime"
)

type ManagedFile struct {
    file *os.File
    name string
}

func OpenManagedFile(filename string) (*ManagedFile, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    
    mf := &ManagedFile{
        file: file,
        name: filename,
    }
    
    // 自動クリーンアップを設定
    runtime.AddCleanup(mf, func(m *ManagedFile) {
        if m.file != nil {
            fmt.Printf("自動的にファイル %s を閉じます\n", m.name)
            m.file.Close()
        }
    })
    
    return mf, nil
}

func (mf *ManagedFile) Read(p []byte) (int, error) {
    return mf.file.Read(p)
}

func (mf *ManagedFile) Close() error {
    // 明示的にCloseを呼ぶこともできる
    if mf.file != nil {
        err := mf.file.Close()
        mf.file = nil  // クリーンアップが重複しないようにnil化
        return err
    }
    return nil
}

func main() {
    // 使用例
    file, _ := OpenManagedFile("data.txt")
    
    // ファイルを使用
    buf := make([]byte, 100)
    file.Read(buf)
    
    // Closeを忘れても...
    file = nil
    runtime.GC()
    // → 自動的に閉じられる!
}

データベース接続プール

package main

import (
    "database/sql"
    "fmt"
    "runtime"
)

type ManagedConnection struct {
    conn *sql.Conn
    pool *sql.DB
}

func (p *sql.DB) GetManagedConnection() (*ManagedConnection, error) {
    conn, err := p.Conn(context.Background())
    if err != nil {
        return nil, err
    }
    
    mc := &ManagedConnection{
        conn: conn,
        pool: p,
    }
    
    // 接続を自動的に返却
    runtime.AddCleanup(mc, func(m *ManagedConnection) {
        if m.conn != nil {
            fmt.Println("接続を自動的にプールに返却します")
            m.conn.Close()
        }
    })
    
    return mc, nil
}

func (mc *ManagedConnection) Query(query string) (*sql.Rows, error) {
    return mc.conn.QueryContext(context.Background(), query)
}

ウィークポインタの実践例

シンプルなキャッシュ

package main

import (
    "fmt"
    "runtime"
    "sync"
    "weak"
)

type User struct {
    ID   int
    Name string
}

// ウィークポインタを使ったキャッシュ
type UserCache struct {
    mu    sync.RWMutex
    cache map[int]weak.Pointer[User]
}

func NewUserCache() *UserCache {
    return &UserCache{
        cache: make(map[int]weak.Pointer[User]),
    }
}

func (c *UserCache) Set(user *User) {
    c.mu.Lock()
    defer c.mu.Unlock()
    
    // ウィークポインタとして保存
    c.cache[user.ID] = weak.Make(user)
}

func (c *UserCache) Get(id int) *User {
    c.mu.RLock()
    defer c.mu.RUnlock()
    
    if wp, ok := c.cache[id]; ok {
        // ウィークポインタから値を取得
        if user := wp.Value(); user != nil {
            return user
        }
        // GCされていた場合はキャッシュから削除
        delete(c.cache, id)
    }
    return nil
}

func main() {
    cache := NewUserCache()
    
    // ユーザーを作成してキャッシュに追加
    user := &User{ID: 1, Name: "太郎"}
    cache.Set(user)
    
    // キャッシュヒット
    if cached := cache.Get(1); cached != nil {
        fmt.Println("キャッシュから取得:", cached.Name)
    }
    
    // 強い参照を削除
    user = nil
    
    // GC実行
    runtime.GC()
    
    // キャッシュミス(GCされた)
    if cached := cache.Get(1); cached == nil {
        fmt.Println("キャッシュミス:GCされました")
    }
}

オブザーバーパターン

package main

import (
    "fmt"
    "runtime"
    "weak"
)

type Observer interface {
    Update(message string)
}

type Subject struct {
    observers []weak.Pointer[Observer]
}

func (s *Subject) Attach(obs Observer) {
    s.observers = append(s.observers, weak.Make(&obs))
}

func (s *Subject) Notify(message string) {
    // 生きているオブザーバーだけに通知
    alive := s.observers[:0]
    for _, wp := range s.observers {
        if obs := wp.Value(); obs != nil {
            (*obs).Update(message)
            alive = append(alive, wp)
        }
    }
    s.observers = alive  // GCされたものを削除
}

type ConcreteObserver struct {
    Name string
}

func (o *ConcreteObserver) Update(message string) {
    fmt.Printf("%s が受信: %s\n", o.Name, message)
}

ファイナライザの問題点 (なぜ避けるべきか)

問題1: 実行タイミングが不確実

func problematicFinalizer() {
    r := &Resource{}
    
    runtime.SetFinalizer(r, func(r *Resource) {
        // いつ実行されるかわからない!
        // すぐかもしれないし、数秒後かもしれない
        // プログラム終了前には実行されないかも!
        r.Cleanup()
    })
}

問題2: 実行順序が保証されない

type Parent struct {
    child *Child
}

type Child struct {
    data string
}

func problematicOrder() {
    p := &Parent{child: &Child{data: "重要"}}
    
    runtime.SetFinalizer(p, func(p *Parent) {
        // 問題: childが先にGCされているかも!
        fmt.Println(p.child.data)  // パニックの可能性!
    })
    
    runtime.SetFinalizer(p.child, func(c *Child) {
        c.data = ""  // 先に実行されるかも
    })
}

問題3: 復活の可能性

var resurrected *Resource

func problematicResurrection() {
    r := &Resource{}
    
    runtime.SetFinalizer(r, func(r *Resource) {
        // オブジェクトを「復活」させる
        resurrected = r  // グローバル変数に保存
        // → rは再び到達可能に!
        // → ファイナライザは二度と呼ばれない
    })
}

正しい使い方のベストプラクティス

1. 明示的なCloseを優先

// ✅ 良い: 明示的なClose
func goodPattern() {
    file, _ := os.Open("data.txt")
    defer file.Close()  // 確実に閉じる
    
    // ... ファイル操作 ...
}

// ⚠️ 避ける: ファイナライザに頼る
func badPattern() {
    file, _ := os.Open("data.txt")
    // Closeを忘れた!
    // ファイナライザが何とかしてくれる...かも?
}

2. クリーンアップは保険として使う

// ✅ 良い: 明示的Close + クリーンアップ(保険)
type SafeFile struct {
    file *os.File
}

func NewSafeFile(name string) *SafeFile {
    file, _ := os.Open(name)
    sf := &SafeFile{file: file}
    
    // 保険としてクリーンアップを設定
    runtime.AddCleanup(sf, func(s *SafeFile) {
        if s.file != nil {
            // Closeが呼ばれていない場合の最後の砦
            s.file.Close()
        }
    })
    
    return sf
}

func (sf *SafeFile) Close() error {
    if sf.file != nil {
        err := sf.file.Close()
        sf.file = nil  // クリーンアップが重複しないように
        return err
    }
    return nil
}

func usage() {
    sf := NewSafeFile("data.txt")
    defer sf.Close()  // 通常はこれで閉じる
    
    // でも、deferを忘れても最悪クリーンアップが動く
}

3. ウィークポインタはキャッシュ専用

// ✅ 良い: キャッシュとして使用
type ImageCache struct {
    cache map[string]weak.Pointer[Image]
}

// ❌ 悪い: 重要なデータに使用
type Database struct {
    records map[int]weak.Pointer[Record]  // データが消える!
}

テスト方法

クリーンアップのテスト

func TestCleanup(t *testing.T) {
    called := false
    
    func() {
        obj := &MyObject{}
        runtime.AddCleanup(obj, func(o *MyObject) {
            called = true
        })
        // objがスコープを抜ける
    }()
    
    // GCを強制実行
    runtime.GC()
    runtime.GC()  // 2回呼ぶと確実
    
    if !called {
        t.Error("クリーンアップが呼ばれていない")
    }
}

ウィークポインタのテスト

func TestWeakPointer(t *testing.T) {
    obj := &MyObject{Value: 42}
    wp := weak.Make(obj)
    
    // 最初は取得できる
    if got := wp.Value(); got == nil || got.Value != 42 {
        t.Error("値が取得できない")
    }
    
    // 強い参照を削除
    obj = nil
    
    // GCを強制実行
    runtime.GC()
    runtime.GC()
    
    // GC後は取得できない
    if got := wp.Value(); got != nil {
        t.Error("GC後も値が残っている")
    }
}

まとめ

機能用途利点注意点
クリーンアップリソース解放シンプル、確実Go 1.24以降
ウィークポインタキャッシュメモリ効率的Go 1.24以降
ファイナライザレガシー古いコードで使用非推奨、複雑

使用のベストプラクティス:

  1. 明示的なリソース管理を優先 – defer で Close
  2. クリーンアップは保険 – 忘れた時の最後の砦
  3. ウィークポインタはキャッシュ専用 – 重要なデータには使わない
  4. ファイナライザは避ける – 新しいコードでは使わない
  5. 必ずテストする – GCの動作を確認

これらの機能は強力ですが、通常のGoプログラミングではあまり使う必要がありません。基本は明示的なリソース管理です!

おわりに 

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

よっしー
よっしー

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

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

コメント

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