GOGCとGOMEMLIMITによるGoのGC頻度の調整とtraceでの動作確認

golang

A Guide to the Go Garbage Collector によると Go の GC はグローバル変数やローカル変数であるルートのオブジェクトやポインタから参照されているものを mark していき mark されなかったものを sweep する mark-sweep と呼ばれる形式で行われる。これにかかるコストはヒープサイズが大きいほど大きくなるが、新しく割り当てられたメモリだけでなく既存のライフタイムの長いものも対象となるので頻度を下げることで CPU への負荷を抑えることができる。

頻度を調整するパラメータとして GOGC があり、ヒープサイズが次の式で計算される閾値を超えると GC が走る。デフォルト値は 100 なので、大体前回のGC後のヒープサイズである Live heap の倍が閾値となり、増やすとGCの頻度を下げられる。

Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100

もう一つ、Go 1.19 から追加された GOMEMLIMIT も GC の閾値としてはたらく。これを利用可能なメモリ量に基づいた値にすることで、メモリが枯渇しそうになるときに GC の頻度を上げられるが、これはあくまで soft limit となっている。 また、GC によって処理が進まないことを防ぐため、最大で 50% の CPU しか使わない制限がかかっている。

go tool trace でこれらの値を変更したときに実際に GC の頻度が変わることを確認する。

package main

import (
	"fmt"
	"time"
)

func main() {
	var heapValues []*int64

	ticker := time.Tick(50 * time.Nanosecond)
	for {
		<-ticker
		if len(heapValues) > 1000 {
			fmt.Println(len(heapValues))
			heapValues = heapValues[1000:]
		}
		heapValues = append(heapValues, new(int64))
	}
}

値がヒープに格納されることを確認する。

$ go build -gcflags '-m'
./main.go:11:21: inlining call to time.Tick
./main.go:15:15: inlining call to fmt.Println
./main.go:15:15: ... argument does not escape
./main.go:15:19: len(heapValues) escapes to heap
./main.go:18:38: new(int64) escapes to heap

トレースファイルを出力してパースする。

$ cat main_test.go
package main

import (
	"testing"
	"time"
)

func TestMain(t *testing.T) {
	go main()
	time.Sleep(10 * time.Second)
}

$ go test -trace trace.out .
$ go tool trace trace.out

完了するとブラウザが開き可視化されたトレースを確認できる。

Heap のオレンジで表されている Allocated が上昇していき、大体 340ms ごとに NextGC が少なくなって GC が走り Allocated が減って NextGC が回復している。

GOGC=50 にすると間隔が 130ms ほどになった。

さらに GOMEMLIMIT=10MiB を設定すると、40ms ほどで GC が走るようになった。