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

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

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

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

スポンサーリンク

背景

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

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

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

Finalizers, cleanups, and weak pointers: 一般的なアドバイス

ユニットテストを書きましょう。

クリーンアップ、ウィークポインタ、ファイナライザの正確なタイミングは予測が難しく、何度も連続して実行した後でも、すべてが機能していると自分自身を納得させるのは簡単です。しかし、微妙なミスを犯すことも簡単です。これらのテストを書くのは難しいかもしれませんが、それらが使用するのに非常に微妙であることを考えると、テストは通常よりもさらに重要です。

典型的なGoコードでこれらの機能を直接使用することを避けましょう。

これらは微妙な制限と動作を持つ低レベルの機能です。例えば、クリーンアップやファイナライザがプログラム終了時に実行される保証はありませんし、そもそも実行される保証もありません。これらのAPIドキュメントの長いコメントは、警告として見るべきです。大多数のGoコードは、これらの機能を直接使用することからではなく、間接的に恩恵を受けるだけです。

これらのメカニズムの使用をパッケージ内にカプセル化しましょう。

可能な限り、これらのメカニズムの使用をパッケージのパブリックAPIに漏らさないでください。ユーザーが誤用することが困難または不可能になるようなインターフェースを提供してください。例えば、ユーザーにC割り当てメモリに対してそれを解放するクリーンアップを設定するよう求めるのではなく、ラッパーパッケージを作成してその詳細を内部に隠してください。

ファイナライザ、クリーンアップ、ウィークポインタを持つオブジェクトへのアクセスを、それらを作成して適用したパッケージに制限しましょう。

これは前のポイントに関連していますが、これらの機能をエラーの起こりにくい方法で使用するための非常に強力なパターンであるため、明示的に呼び出す価値があります。例えば、uniqueパッケージは内部でウィークポインタを使用していますが、弱く指されているオブジェクトを完全にカプセル化しています。これらの値はアプリケーションの残りの部分によって決して変更されることはなく、Valueメソッドを通じてのみコピーでき、パッケージユーザーに対して無限のメモリの錯覚を保持します。

可能な場合は、非メモリリソースを決定論的にクリーンアップすることを優先し、ファイナライザとクリーンアップをフォールバックとして使用しましょう。

クリーンアップとファイナライザは、Cから割り当てられたメモリやmmapマッピングへの参照など、外部で割り当てられたメモリのようなメモリリソースに適しています。Cのmallocによって割り当てられたメモリは、最終的にCのfreeによって解放されなければなりません。Cメモリのラッパーオブジェクトにアタッチされたfreeを呼び出すファイナライザは、ガベージコレクションの結果としてCメモリが最終的に再利用されることを保証する合理的な方法です。

しかし、ファイル記述子のような非メモリリソースは、Goランタイムが一般的に認識していないシステム制限の対象となる傾向があります。さらに、特定のGoプログラムにおけるガベージコレクタのタイミングは、通常、パッケージ作成者がほとんど制御できないものです(例えば、GCがどのくらいの頻度で実行されるかはGOGCによって制御され、これは実際にはオペレータによってさまざまな異なる値に設定できます)。これら2つの事実が相まって、クリーンアップとファイナライザは、非メモリリソースを解放する唯一のメカニズムとして使用するには不適切になります。

非メモリリソースをラップするAPIを公開するパッケージ作成者の場合、クリーンアップやファイナライザを通じてガベージコレクタに依存するのではなく、リソースを決定論的に解放するための明示的なAPI(Closeメソッドまたは同様のもの)を提供することを検討してください。代わりに、os.Fileが行うようにリソースをとにかくクリーンアップするか、決定論的にクリーンアップできなかったことをユーザーに報告することによって、プログラマのミスに対するベストエフォートのハンドラとしてクリーンアップとファイナライザを使用することを優先してください。

ファイナライザよりもクリーンアップを優先しましょう。

歴史的に、ファイナライザはGoコードとCコード間のインターフェースを簡素化し、非メモリリソースをクリーンアップするために追加されました。意図された使用は、Cメモリまたはその他の非メモリリソースを所有するラッパーオブジェクトに適用することで、Goコードが使用を終えたらリソースを解放できるようにすることでした。これらの理由は、ファイナライザが狭くスコープされている理由、任意のオブジェクトが1つのファイナライザしか持てない理由、そしてそのファイナライザがオブジェクトの最初のバイトにのみアタッチされなければならない理由を少なくとも部分的に説明しています。この制限はすでに一部のユースケースを抑制しています。例えば、渡されたオブジェクトに関する情報を内部的にキャッシュしたいパッケージは、オブジェクトがなくなったらその情報をクリーンアップすることができません。

しかし、それよりも悪いことに、ファイナライザは、アタッチされているオブジェクトを復活させるという事実により、非効率でエラーが起こりやすくなります。これは、ファイナライザ関数に渡せるようにするためです(そしてその後も生き続けることさえできます)。この単純な事実は、オブジェクトが参照サイクルの一部である場合、決して解放できないことを意味し、オブジェクトを裏付けるメモリは、少なくとも次のガベージコレクションサイクルまで再利用できません。

しかし、ファイナライザはオブジェクトを復活させるため、クリーンアップよりも実行順序がよく定義されています。このため、ファイナライザは、複雑な破壊順序要件を持つ構造をクリーンアップするために、まだ潜在的に(しかしまれに)有用です。

しかし、Go 1.24以降のその他すべての用途については、ファイナライザよりも柔軟で、エラーが起こりにくく、効率的であるため、クリーンアップを使用することをお勧めします。

7つの重要なアドバイス

1. 必ずユニットテストを書く

クリーンアップやウィークポインタは、動作が微妙で予測が難しいです。

問題点:

// 何度実行してもたまたま動く...
func TestCleanup(t *testing.T) {
    obj := &MyObject{}
    runtime.AddCleanup(obj, func(o *MyObject) {
        // クリーンアップされる...はず?
    })
    
    // でも、GCがいつ実行されるかわからない!
}

正しいテスト:

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

例え話: 自動ドアのテスト

  • 「通れた」だけでは不十分
  • 「センサーが反応したか」「ドアが開いたか」を確認
  • 何度も繰り返してテスト

2. 直接使用を避ける

これらは低レベルの危険な機能です。普通のコードでは使わないでください。

// ❌ 悪い例: 直接使用
func badExample() {
    file, _ := os.Open("data.txt")
    
    // ファイナライザに頼る
    runtime.SetFinalizer(file, func(f *os.File) {
        f.Close()
    })
    
    // いつ閉じられる? わからない!
    // プログラム終了前に実行される? 保証なし!
}

// ✅ 良い例: 標準的な方法
func goodExample() {
    file, _ := os.Open("data.txt")
    defer file.Close()  // 確実に閉じる
    
    // シンプルで予測可能
}

警告サイン: APIドキュメントに長いコメントがある → 複雑で危険な機能という証拠!

3. パッケージ内にカプセル化する

これらの機能をパッケージの外に漏らさないでください。

❌ 悪い例: APIに露出

package badpkg

// 悪い: ユーザーにクリーンアップを要求
type Resource struct {
    data *C.char
}

func NewResource() *Resource {
    r := &Resource{data: C.malloc(1024)}
    // ユーザーが自分でクリーンアップを設定しなければならない!
    return r
}

// ユーザーのコード
func userCode() {
    r := badpkg.NewResource()
    runtime.AddCleanup(r, func(r *badpkg.Resource) {
        // これをユーザーに強制するのは悪い設計!
        C.free(unsafe.Pointer(r.data))
    })
}

✅ 良い例: 内部でカプセル化

package goodpkg

// 良い: パッケージ内でクリーンアップを隠蔽
type Resource struct {
    data *C.char
}

func NewResource() *Resource {
    r := &Resource{data: C.malloc(1024)}
    
    // 内部でクリーンアップを設定
    runtime.AddCleanup(r, func(r *Resource) {
        C.free(unsafe.Pointer(r.data))
    })
    
    return r
}

// ユーザーのコード
func userCode() {
    r := goodpkg.NewResource()
    // 何もする必要なし!シンプル!
}

例え話:

  • 悪い: レストランで「自分で皿を洗ってください」
  • 良い: レストランのスタッフが裏で処理

4. アクセスを制限する

ファイナライザやウィークポインタを持つオブジェクトは、そのパッケージ内だけでアクセスさせる。

良い例: uniqueパッケージのパターン

package unique

// ハンドルは外部に公開
type Handle[T comparable] struct {
    pointer weak.Pointer[T]  // 内部実装は隠蔽
}

// 値は読み取り専用で提供
func (h Handle[T]) Value() T {
    if ptr := h.pointer.Value(); ptr != nil {
        return *ptr  // コピーを返す
    }
    return zero[T]()
}

// ユーザーは内部のウィークポインタを直接触れない!

悪い例:

// ❌ ウィークポインタを直接公開
type BadCache struct {
    Data weak.Pointer[CachedData]  // 公開されている!
}

// ユーザーが誤用する可能性
func userMisuse(cache *BadCache) {
    // 内部のウィークポインタを直接操作
    cache.Data = weak.Make(&CachedData{})  // 危険!
}

例え話:

  • 銀行の金庫室への鍵を渡さない
  • 窓口だけで対応する

5. 決定論的クリーンアップを優先

非メモリリソース(ファイル、ソケットなど)は、明示的に閉じるべきです。

メモリリソース – ファイナライザOK
// ✅ メモリリソースの例
type CMemory struct {
    ptr unsafe.Pointer
}

func NewCMemory(size int) *CMemory {
    cm := &CMemory{ptr: C.malloc(C.size_t(size))}
    
    // Cのメモリ → ファイナライザが適切
    runtime.SetFinalizer(cm, func(cm *CMemory) {
        C.free(cm.ptr)
    })
    
    return cm
}
// GCがメモリを管理 → 良い
非メモリリソース – 明示的なCloseが必要
// ✅ 非メモリリソースの例
type File struct {
    fd int
}

func Open(name string) (*File, error) {
    fd, err := syscall.Open(name, ...)
    if err != nil {
        return nil, err
    }
    
    f := &File{fd: fd}
    
    // 保険としてクリーンアップ
    runtime.AddCleanup(f, func(f *File) {
        if f.fd != -1 {
            // 警告を出す
            log.Println("警告: ファイルが明示的に閉じられていません")
            syscall.Close(f.fd)
        }
    })
    
    return f, nil
}

// 明示的なClose - これがメイン
func (f *File) Close() error {
    if f.fd != -1 {
        err := syscall.Close(f.fd)
        f.fd = -1  // クリーンアップの重複を防ぐ
        return err
    }
    return nil
}

// 使用例
func usage() {
    file, _ := Open("data.txt")
    defer file.Close()  // 確実に閉じる!
    
    // クリーンアップは「もしものため」の保険
}

理由1: システム制限

// ファイル記述子は限られている
// 例: Linuxでは通常1024個まで

for i := 0; i < 2000; i++ {
    file, err := os.Open("data.txt")
    // Closeを忘れると...
    // → 1024個目でエラー!
    // → GCを待つ時間はない
}

理由2: GCのタイミングを制御できない

// GOGCはオペレータが設定
export GOGC=1000  // GCがまれ

// ファイナライザに頼ると...
// → ファイル記述子が枯渇するまでGCが実行されない
// → プログラムがクラッシュ

例え話:

  • メモリ: 広い倉庫(GCで管理OK)
  • ファイル記述子: 限られた駐車場(すぐに片付けが必要)

6. クリーンアップ > ファイナライザ

常にクリーンアップを選択してください。ファイナライザは避けましょう。

クリーンアップの利点
type MyObject struct {
    data []byte
}

func NewMyObject() *MyObject {
    obj := &MyObject{data: make([]byte, 1024)}
    
    // ✅ クリーンアップ使用
    runtime.AddCleanup(obj, func(o *MyObject) {
        // オブジェクトは復活しない
        // 効率的!
    })
    
    return obj
}
ファイナライザの問題
func NewMyObjectBad() *MyObject {
    obj := &MyObject{data: make([]byte, 1024)}
    
    // ❌ ファイナライザ使用
    runtime.SetFinalizer(obj, func(o *MyObject) {
        // 問題1: オブジェクトが復活する
        // 問題2: 参照サイクルがあると永遠に解放されない
        // 問題3: 次のGCまでメモリが再利用できない
    })
    
    return obj
}

問題1: 復活による非効率

クリーンアップ:
[オブジェクト生存] → [GC] → [即座に解放] ✅

ファイナライザ:
[オブジェクト生存] → [GC] → [復活] → [ファイナライザ実行] → [次のGC] → [解放] ❌
                              ↑無駄なステップ

問題2: 参照サイクル

type Node struct {
    next *Node
}

func createCycle() {
    a := &Node{}
    b := &Node{}
    a.next = b
    b.next = a  // サイクル!
    
    runtime.SetFinalizer(a, func(n *Node) {
        // 永遠に呼ばれない!
        // サイクルがある限り解放できない
    })
}

唯一の例外: 複雑な破壊順序

// まれなケース: 順序が重要
type Database struct {
    connections []*Connection
}

type Connection struct {
    db *Database
}

// ファイナライザは実行順序が定義されている
// → まれに有用(でもほとんど使わない)

7. os.Fileのパターンを参考にする

ベストプラクティスの実例です。

// os.Fileの実装(簡略版)
type File struct {
    fd      int
    name    string
    closed  bool
}

func Open(name string) (*File, error) {
    fd, err := syscall.Open(name, ...)
    if err != nil {
        return nil, err
    }
    
    f := &File{fd: fd, name: name}
    
    // 保険としてクリーンアップ
    runtime.AddCleanup(f, func(f *File) {
        if !f.closed {
            // 警告を出してクリーンアップ
            log.Printf("file %s was not closed", f.name)
            syscall.Close(f.fd)
        }
    })
    
    return f, nil
}

func (f *File) Close() error {
    if f.closed {
        return syscall.EINVAL
    }
    err := syscall.Close(f.fd)
    f.closed = true
    return err
}

// 使い方
func example() {
    f, _ := os.Open("data.txt")
    defer f.Close()  // メイン: 明示的なClose
    
    // もしdeferを忘れても...
    // → クリーンアップが実行される
    // → 警告が出る
    // → プログラマのミスを検出できる
}

このパターンの利点:

  1. 明確な責任: Closeが主、クリーンアップは保険
  2. エラー検出: Closeを忘れると警告
  3. 安全: 最悪でもリソースは解放される
  4. テスト可能: 動作が予測可能

実践的なチェックリスト

プロジェクトでこれらの機能を使う前に:

□ 本当に必要か? 普通の方法で解決できないか?
□ パッケージ内にカプセル化されているか?
□ 公開APIに露出していないか?
□ 明示的なCloseメソッドを提供しているか?
□ クリーンアップは保険として使っているか?
□ ファイナライザではなくクリーンアップを使っているか?
□ ユニットテストを書いたか?
□ ドキュメントに警告を書いたか?

まとめ表

アドバイス理由
テストを書く動作が微妙GCを2回呼ぶ
直接使用を避ける低レベルで危険defer Close を使う
カプセル化ユーザーの誤用を防ぐ内部で処理
アクセス制限エラーを減らすuniqueパターン
明示的Close非メモリリソースdefer file.Close()
クリーンアップ優先効率的ファイナライザ避ける
os.File参考ベストプラクティス保険パターン

最重要原則:

「普通のGoコードでは使わない。どうしても必要なら、パッケージ内に隠す。」

これらの機能は強力ですが、99%のコードでは不要です。必要だと思ったら、まず別の方法を探しましょう!

おわりに 

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

よっしー
よっしー

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

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

コメント

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