🦜

GoのGCの挙動を色々試してみる

に公開

はじめに

最近GoのGCに興味を持ってコードを眺めている。デカいし難しい。おおざっぱにはわかってきたが、細かいところがよくわからない。
そもそもとして、GCに興味を持ったのはGoが

  1. メモリ内のレイアウトを直接指定する構造体があり、
  2. ポインタもある

という言語だからで、このようなタイプでGCが搭載された言語に触るのは初めてだったからである。
例えば.net系やJVM系の言語であればオブジェクトは参照で、クラスの中身はプリミティブ型の値とオブジェクトへのポインタだ。スクリプト系の言語もだいたい同様のはず。Mark&Sweepであればポインタを追いかけていけばいい。保守的なGCなら制御も楽だ。
ところがGoの場合は、構造体を値で持ったりポインタで持ったり、構造体の中に構造体が値で入っていたりするし、更には構造体の中の任意のデータのポインタを取り出すことができ、そのうえ正確なGCときたもんだ。おまけにコンパイル言語でネイティブで動く。さすがになんだコレ、GCどうなってんだコレ、ってなる。なるよね?なって?
ということで、GCのコードは難しいので、Goでちょっとしたコードを書いてGCの挙動を確認する方向から攻めてみたいと考えている。
なおGoのバージョンは1.25.1である。

> go version
go version go1.25.1 windows/amd64

まず初手

このようなコードを書いてみた。

package1.go
package package1

type T struct {
	D [10000000]int
}

var A *T

func F() {
	A = &T{}
}
main.go
package main

import (
	"fmt"
	"myproject/package1"
	"runtime"
)

func main() {
	disp("最初") // => 最初 = 211816
	package1.F()
	disp("GC前") // => GC前 = 80237912
	runtime.GC()
	disp("GC後") // => GC後 = 80241448
	package1.A = nil
	runtime.GC()
	disp("参照削除してGC後") // => 参照削除してGC後 = 238376
}

func disp(s string) {
	q := runtime.MemStats{}
	runtime.ReadMemStats(&q)
	fmt.Printf(s+" = %d\n", q.Alloc)
}

パッケージを分けているのはエスケープ解析対策である。細かいことはよくわからないが、関数内で使って関数から出るところで捨てられるオブジェクトはスタックに置いてしまうようなので、別パッケージ化してみた。
runtimeのReadMemStats()を使ってヒープに確保されているメモリを表示するようにして、構造体の作成と、参照を切ってGCするという一連のメモリ増減の遷移が見えるようにした。
F()を呼ぶとメモリが80MBほど確保されて、参照を切ってからGCすると回収される、という挙動が見て取れる。細かい数字は管理領域みたいなもんだろうから無視する。

正確なGC

GoのGCは正確なGCということなのでそれを試してみよう。

main.go
package main

import (
	"fmt"
	"myproject/package1"
	"runtime"
	"unsafe"
)

func main() {
	disp("最初") // => 最初 = 211816
	package1.F()
	disp("GC前") // => GC前 = 80237304
	runtime.GC()
	disp("GC後") // => GC後 = 80241448
	a := uintptr(unsafe.Pointer(package1.A))
	package1.A = nil
	runtime.GC()
	disp("参照削除してGC後") // => 参照削除してGC後 = 238376
	fmt.Printf("%08x\n", a) // => c000180000
}

func disp(s string) {
	q := runtime.MemStats{}
	runtime.ReadMemStats(&q)
	fmt.Printf(s+" = %d\n", q.Alloc)
}

2行ほど増やした。package1.Aはポインタなのでそれをunsafe.Pointer化してuintptrにキャストして保存。Printfしてるのは使わないとGoに怒られてしまうから。結果として、package1.Aを整数に変換して保持していた場合、これは構造体への参照とは認識されなかったようで、GCに回収された。
この変数aはスタックやCPUのレジスタに格納されているはずで、そういった場所にある値は値だけ見てもポインタなのか整数なのか見分けがつかず、ポインタらしきものであればポインタとして扱ってマークしに行くのを保守的なGCと呼ぶ。保守的なGCは使っていないオブジェクトを誤ってマークしてしまうことがあるが、使っているオブジェクトを誤ってマークしないよりかは遥かにマシである。
GoのGCはスタックやレジスタであっても型を認識していて、uintptrはポインタではなく整数であると理解しているので、それがポインタらしき数字であってもマークしない。マークすべきものだけを正確にマークするGCを正確なGCと呼ぶ。作り方は色々ありそうだがGoではコンパイラが型情報をメタデータとして埋め込んで、runtimeがそれを参照しているようだ。
なお、uintptrへのキャストを外すとGCに回収されなくなる。unsafe.Pointerはuintptrとは違ってポインタであり、マークすべき対象なのだとGCが判断するからだ。

正確なGCはどこまで正確か

unsafe.Pointerという型はCで言うvoid*みたいなもんなので、型の情報が消えている。これを経由すれば型が取れずに保守的GC的な動作をするのではないか。と考えてみた。保守的GCをするコードはGoのGC内に存在している。

package1.go
package package1

import "unsafe"

type T struct {
	D [10000000]int
	P unsafe.Pointer
	U uintptr
}

var A *T

func F() {
	A = &T{P: unsafe.Pointer(&T{})}
}
main.go
package main

import (
	"fmt"
	"myproject/package1"
	"runtime"
	"unsafe"
)

func main() {
	disp("最初") // => 最初 = 211816
	package1.F()
	disp("GC前") // => GC前 = 160249656
	runtime.GC()
	disp("GC後") // => GC後 = 160254800

	package1.A.U = uintptr(package1.A.P) // unsafe.Pointerからuintptrへ
	package1.A.P = nil                   // unsafe.Pointerは削除

	a := unsafe.Pointer(package1.A) // Aの参照はunsafe.Pointerだけしかない
	package1.A = nil
	runtime.GC()
	disp("参照削除してGC後") // => 参照削除してGC後 = 80256896
	fmt.Printf("%08x\n", a)
}

func disp(s string) {
	q := runtime.MemStats{}
	runtime.ReadMemStats(&q)
	fmt.Printf(s+" = %d\n", q.Alloc)
}

構造体Tにunsafe.Pointerとしてもう一つのTを参照させて、unsafe.Pointerからuintptrに付け替える。これをせずに直接uintptrにキャストして入れるとそもそも構造体値が作成されないようだった。
で、変数aとしてAのポインタをunsafe.Pointerに変換して保持する。この状態であれば、作成した構造体Tはunsafe.Pointer経由でしかアクセスできず、構造体Tの型が取れなければuintptrがマークされて構造体T2個分のメモリが保持されるのではないかと思ったわけだ。
結果は上のコメントに記載した通りで、構造体Tのuintptrで参照したつもりのもう一つの構造体Tはあえなく回収されてしまった。まあ、unsafe.Pointerでアクセスした構造体のサイズがわかっている時点でこの結末は予想できていたのだが、GoのGCが参照しているのはポインタの型ではなく、データ自体の型ということだ。

構造体の一部を参照する場合

では、デカい構造体の一部を参照するポインタだけを保持した場合はどうなるか。一部以外が回収されたりするだろうか。

package1.go
package package1

type T struct {
	D [10000000]int
	I int
}

var A *T

func F() {
	A = &T{}
}
main.go
package main

import (
	"fmt"
	"myproject/package1"
	"runtime"
)

func main() {
	disp("最初") // => 最初 = 211816
	package1.F()
	disp("GC前") // => GC前 = 80237304
	runtime.GC()
	disp("GC後") // => GC後 = 80241448
	a := &package1.A.I
	package1.A = nil
	runtime.GC()
	disp("参照削除してGC後") // => 参照削除してGC後 = 80241448
	fmt.Printf("%08x\n", a) // => c004dcb400
}

func disp(s string) {
	q := runtime.MemStats{}
	runtime.ReadMemStats(&q)
	fmt.Printf(s+" = %d\n", q.Alloc)
}

構造体Tにint型の項目Iを用意して、Iのポインタだけを保持してみた。結果は構造体Tの一部分のポインタを持っているだけで、構造体Tの全体が保持された。参照できないところだけ器用に回収とかはしないようだ。つっても通常のmallocみたいにポインタの更に前に管理情報を持っていたりすることもあるだろうし、Goのような言語で外側が回収されたりしたら困りそうだ。
ところでこの挙動を見ただけでは、構造体T全体を保持する処理をマーク側でやっている(ポインタを含む構造体の先頭をマークしている)のか、スイープ側でやっている(マークされたデータを含む構造体全体を保持する)のかは判断がつかない。

参照できない部分の探索は

構造体Tにもう一つのTへのポインタT2を持たせて、Iだけ参照していた場合、このT2もマークされて保持されるはずである。構造体T全体が残っている状態であれば、ポインタも有効でなければならないからだ。
そして、これが想定通りに動くなら、この処理はマークの時に行われているのだと言える。

package1.go
package package1

type T struct {
	D [10000000]int
	I int
	T2 *T
}

var A *T

func F() {
	A = &T{T2:&T{}}
}
main.go
package main

import (
	"fmt"
	"myproject/package1"
	"runtime"
)

func main() {
	disp("最初") // => 最初 = 211816
	package1.F()
	disp("GC前") // => GC前 = 160249656
	runtime.GC()
	disp("GC後") // => GC後 = 160259856
	a := &package1.A.I
	package1.A = nil
	runtime.GC()
	disp("参照削除してGC後")       // => 参照削除してGC後 = 160264928
	fmt.Printf("%08x\n", a) // => c004dcb400
}

func disp(s string) {
	q := runtime.MemStats{}
	runtime.ReadMemStats(&q)
	fmt.Printf(s+" = %d\n", q.Alloc)
}

ちゃんとマークされてGCに回収されなかった。
おかしな挙動をというわけではないが、例えば巨大な構造体のポインタを保持していないのにGCしてもメモリが空かないなーみたいなハマり方をするかもしれない。頭の片隅にでも入れておくぐらいがよい。

おしまい

いくつかの疑問点は解消された。無論、GoのRuntimeの細かいところをつつけばもっと微妙なところ(正確性が保証できないタイミングとか)を叩けるのだろうが、そういうのはもっと詳しくならないと無理である。
まあとりあえず一歩前進ということで。

Discussion