
こんにちは。よっしーです(^^)
本日は、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言語のガベージコレクターについて解説しました。

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

コメント