【Go】Go 1.27 の goroutineleak を GC で検出する方法

Go 1.27 goroutineleak プロファイル深掘りを表したアイキャッチ画像
目次

この記事が解決する悩み

  • goroutineリークを疑っているけど、どのgoroutineがどこで止まっているのか特定できない
  • 通常のgoroutineプロファイルは全goroutineを出すだけで、リークの判別は人力になる
  • 本番サーバに手軽なリーク検知を仕込みたい
対象読者: Goでchannelやsync.Mutex、sync.Condを使う並行処理を書いている人。 前提知識: goroutineとchannelの基本、GCが「届くポインタ」でオブジェクトの生存を決めているイメージがあれば読めます。

結論

Go 1.27でgoroutineleakプロファイルが正式公開になりました。GCが「二度と再開できないgoroutine」を自動で見つけてくれます。
time.Sleep(500 * time.Millisecond) // goroutineがブロックに入ってからGCを回す
runtime.GC()

p := pprof.Lookup("goroutineleak")

p.WriteTo(os.Stdout, 1)
// 実行結果(リーク4件のプログラムでの例)
// goroutineleak profile: total 4
Go 1.26ではGOEXPERIMENT=goroutineleakprofileを付けた実験的機能でしたが、1.27からはオプション不要です。この設定値自体が1.27で削除済みなので、実験用のビルド設定を消してOKです。

リーク検出の仕組み — GCの到達可能性

公式リリースノートでのリークの定義は「channel、sync.Mutex、sync.Condなどでブロックされたまま、そのプリミティブが二度と利用可能にならないgoroutine」です。 検出はGCの仕事です。goroutine G がプリミティブ P でブロックしているとき、P が「実行中のgoroutine、およびそれらが再開できるgoroutine」のどこからも到達できないなら、P は二度と空かないので G は永遠に止まったまま、と判定できます。 裏を返すと、判定は到達可能性ベースなので P がグローバル変数から届いてしまうと検出を逃します。この限界は後ほど実測で確かめますね。

実測1: 早期returnでgoroutineを置き去りにする

リリースノートが挙げている典型例を再現します。ワーカーgoroutineがエラーを送ってきたら早期returnして、残りの結果を受け取らないパターンです。
func processWorkItems(ws []workItem) ([]workResult, error) {
	ch := make(chan workResult)
	for _, w := range ws {
		go func(w workItem) {
			res, err := processWorkItem(w)

			ch <- workResult{id: res.id, err: err}
		}(w)
	}

	var results []workResult
	for range len(ws) {
		r := <-ch

		if r.err != nil {
			// 早期returnで残りのgoroutineを置き去りにする
			return nil, r.err
		}

		results = append(results, r)
	}
	return results, nil
}

func main() {
	_, err := processWorkItems([]workItem{{1}, {2}, {3}, {4}, {5}})
	fmt.Println("returned:", err)

	time.Sleep(500 * time.Millisecond) // 全goroutineが送信ブロックに入ってから
	runtime.GC()

	p := pprof.Lookup("goroutineleak")
	fmt.Print("goroutineleak: ")
	p.WriteTo(os.Stdout, 1)
	// 実行結果
	// returned: item 1 failed
	// goroutineleak: goroutineleak profile: total 4
	// 4 @ 0x7ff61afcc52a 0x7ff61af60f5c 0x7ff61af60b57 0x7ff61b038229 0x7ff61afd2601
	// #  0x7ff61b038228  main.processWorkItems.func1+0x48  C:/Workspace/eeat-verify/goroutineleak/leak/main.go:34
}
5件のうち1件目がエラーで即returnするので、残り4件のワーカーが送信待ちで置き去りになります。プロファイルは total 4 を返し、スタックは ch <- の送信行(main.go:34)を指していました。 リークしたgoroutineの特定まで自動でやってくれるのは助かりますね。

実測2: 全結果を受け切るように直す

直し方はシンプルで、channelをバッファ付きにして全結果を受け切ってから返すだけです。
	ch := make(chan workResult, len(ws)) // バッファ付きにする
	var failed error

	for _, w := range ws {
		go func(w workItem) {
			res, err := processWorkItem(w)

			ch <- workResult{id: res.id, err: err}
		}(w)
	}

	var results []workResult
	for range len(ws) {
		r := <-ch

		if r.err != nil && failed == nil {
			failed = r.err // 記録して受け続ける
		}

		results = append(results, r)
	}

	if failed != nil {
		return nil, failed
	}
	return results, nil

	// 実行結果
	// returned: item 1 failed
	// goroutineleak: goroutineleak profile: total 0
エラーを記録して受け続けるので、ワーカーは全員終了できています。プロファイルは total 0 で、リークが消えたことをランタイム側から確認できました。

実測3: グローバル変数に置くと検出を逃す

到達可能性ベースの限界です。channelをパッケージ変数にすると、GCから見て常に「届く」場所にあるのでリーク判定が出ません。
var ch chan int // パッケージレベルのグローバル変数

func main() {
	ch = make(chan int)
	go func() {
		ch <- 42 // 誰も受け取らない
	}()

	time.Sleep(500 * time.Millisecond)
	runtime.GC()

	p := pprof.Lookup("goroutineleak")
	fmt.Print("goroutineleak: ")
	p.WriteTo(os.Stdout, 1)
	// 実行結果
	// goroutineleak: goroutineleak profile: total 0
}
このgoroutineは実際には誰にも受け取れないのに、プロファイルは total 0 を返します。グローバルなchannelやmutexで止まっているgoroutineは、従来どおり goroutine プロファイルの目視と突き合わせが必要ですね。

net/http/pprof のエンドポイントで取る

net/http/pprofをimportしておくだけで、/debug/pprof/goroutineleakエンドポイントが使えるようになります。
curl -s "http://localhost:8791/debug/pprof/goroutineleak?debug=1"
# 実行結果(抜粋・HTTP 200)
# goroutineleak profile: total 2
# 1 @
# #	0x0
#
# 1 @ 0x7ff6f6fa798a 0x7ff6f6f34b3c 0x7ff6f6f34737 0x7ff6f71a1fbe 0x7ff6f6fae8c1
# #	0x7ff6f71a1fbd	main.main.func1+0x1d	C:/Workspace/eeat-verify/goroutineleak/httpleak/main.go:14
リークしたgoroutine(main.go:14の ch <- 42 で送信待ち)がスタック付きで出てきました。1 @ 0x0 のようにシンボルが解決できないエントリが混ざることもあります。 本番サーバでもこのエンドポイントを定期的に見れば、リークの早期発見につながりますよ。

つまずきポイント

  • 検出はGCのタイミングで更新されます。短いバッチやテストでは runtime.GC() を明示的に呼んでから読むのが確実です(実測でもGCを挟むまで total 0 のままでした)。
  • goroutineがtime.Sleep中の間は検出されませんでした。検出条件は「channelやmutexなどでブロック中」であることです。
  • WriteToのdebug引数に0を渡すとgzip圧縮のprotobufが出ます。テキストで読むなら1以上を渡してください。
  • GOEXPERIMENT=goroutineleakprofileは1.27で削除済みです。設定していても効果はありません。

まとめ

項目 goroutine プロファイル goroutineleak プロファイル
出力対象 現在の全goroutine 検出されたリークgoroutineのみ
リークの判別 人力(スタックを読む) ランタイムが自動判定
限界 件数が多くて読みにくい グローバル変数から届くプリミティブは検出を逃す
取得方法 pprof.Lookup(“goroutine”) pprof.Lookup(“goroutineleak”) または /debug/pprof/goroutineleak
「置き去りにされたgoroutine」をランタイムが教えてくれるのは、並行処理を書く人にとって純粋に嬉しい改善です。1.26時代の実験から1年、本番でも安心して使える段階に入りましたね。

【広告】お名前.comならドメイン取得が格安。

検証環境

OS Windows 11 (build 10.0.26200)
CPU AMD Ryzen 9 PRO 8945HS w/ Radeon 780M Graphics
メモリ 28GB
言語/ツール go1.27.1 windows/amd64
使用コマンド go run ./leak / ./noleak / ./globalleak / httpleak に対する curl
測定日 2026-09-25
上記の環境で実際に実行して確認した結果です。環境が異なる場合は挙動が変わることがあります。
Go 1.27.1 の goroutineleak プロファイル実測ログ。早期returnでgoroutineを置き去りにすると total 4 で検出され、バッファ付き+全結果受信で total 0、グローバル変数では検出を逃すこと、/debug/pprof/goroutineleak エンドポイント(HTTP 200)までを1枚にまとめたもの
Go 1.27.1 の goroutineleak プロファイル実測ログ。早期returnでgoroutineを置き去りにすると total 4 で検出され、バッファ付き+全結果受信で total 0、グローバル変数では検出を逃すこと、/debug/pprof/goroutineleak エンドポイント(HTTP 200)までを1枚にまとめたもの

あわせて読みたい

参考

【広告】このサイトはConoHa WINGで運営しています。安定した高速サーバーで快適にブログを書けています。いつもありがとう!!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次