The Go gopher was designed by Renee French.
Go 1.27リリース連載の5本目です。
はじめに
Go 1.26で実験的に導入されたgoroutineリーク検出プロファイルgoroutineleakが、Go 1.27で正式機能になりました。
goroutineリークの基本原理や、従来手法(goleakなど)との比較は、Go 1.26 リリース連載 Goroutine Leak Profilesで解説いただいています。今回は、正式化後の使い方と、検出したリークをどう修正するかを中心に紹介します。あわせて、検出の土台になった論文と、Goに取り込まれた範囲にも触れます。
Go 1.27での変更
Go 1.26ではgoroutineleakは実験的機能であり、ビルド時に次の指定が必要でした(Go 1.26リリースノート)。
GOEXPERIMENT=goroutineleakprofile go build |
Go 1.27では正式機能(generally available)になり、実験フラグなしで利用できます。goroutineleakprofileというGOEXPERIMENT設定自体も削除されました。
利用箇所は次の2つです。
runtime/pprofのgoroutineleakプロファイルnet/http/pprofの/debug/pprof/goroutineleakエンドポイント
検出の仕組み
goroutineリークとは、goroutineがチャネルなどの同期プリミティブで待機したまま、その待機を解除する手段が失われた状態です。プログラム全体が停止する通常のデッドロックとは異なり、一部のgoroutineだけが残るため、発見しにくい問題です。なお、待機中であること自体はリークではありません。I/OやHTTP接続を待つgoroutineのように、将来再開できるものは正常です。
正常な待機・goroutineリーク・通常のデッドロックの関係を整理すると、次のとおりです。
| 状態 | 待機の解除経路 | プログラム全体 |
|---|---|---|
| 正常な待機(I/OやHTTP応答待ちなど) | 残っている(いずれ再開する) | 動き続ける |
| goroutineリーク | 失われている(永久に再開しない) | 動き続けるため、発見しにくい |
| 通常のデッドロック | 失われている | 全体が停止するため、すぐ気づく |
発見しにくい理由は、リークしていない部分が正常に動き続けることにあります。アプリケーションは応答を返し続ける一方で、リークしたgoroutineはスタックを保持したまま残り、そこから参照されるチャネルや変数もGCの視点では到達可能なため、回収されません。リークが蓄積するほど解放されないメモリが増え、GCがマークすべきオブジェクトも増えるため、メモリ使用量とGC負荷が少しずつ上がっていきます。症状がメモリ使用量の緩やかな増加などとして遅れて現れるうえ、再起動すると一時的に解消してしまうため、原因の特定が難しくなります。
goroutineleakは、GCの到達可能性解析を「そのgoroutineは再開できるか」という判定に応用します(Proposal、Design Document)。goroutineがチャネルで受信待ちしているとき、実行可能なgoroutine(今後実行可能になり得るものを含みます)のどれからもそのチャネルへ到達できなければ、待機を解除するコードは二度と実行されません。これをリークと判定します。検出専用のGCサイクルはプロファイル取得時にだけ実行され、利用しない間は追加の実行時オーバーヘッドが生じないように設計されています。
flowchart TB
runnable["実行可能なgoroutine"]
ch["channel"]
blocked["blocked goroutine"]
leaked["leaked"]
runnable -.->|"到達不能"| ch
blocked -->|"受信待ち"| ch
blocked ==>|"リークと判定"| leaked
この検出は、偽陽性(誤検出)を出さないことを重視して設計されています。一方で、すべてのリークを検出できるわけではありません。グローバル変数や、実行可能なgoroutineのローカル変数から到達可能なチャネルで待機している場合、実際には誰も解除しなくても「到達可能」であるため、検出されないことがあります。偽陰性(見逃し)はあり得るため、プロファイルの検出件数が0件(後述するdebug=1出力ではtotal 0と表示されます)であっても、リークが存在しないことの証明にはなりません。
なお、GCのマーキングの仕組みを詳しく知りたい方はGo 1.26の新GC「Green Tea(緑茶)」解説もあわせてご覧ください。ただし、Green Teaはマーキングを高速化するGCの最適化であり、goroutineリーク検出とは別の機能です。
論文からGoへの取り込み
この検出理論は、Saiocらによる論文「Dynamic Partial Deadlock Detection and Recovery via Garbage Collection」(ASPLOS 2025)で提案されました。論文では、プログラム全体ではなく一部の並行処理だけが永久にブロックされる状態をpartial deadlock(部分デッドロック)と呼んでいます。Goでは、このような状態は一般にgoroutine leakとも呼ばれます。
検出では、実行中または実行可能なgoroutineを起点としてGCのマークを開始します。そこから到達可能な並行処理プリミティブによって再開し得るgoroutineを新たにliveとみなし、そのスタックからさらにマークを続けます。この処理を固定点まで繰り返し、最後までliveと判定されなかったgoroutineをpartial deadlockとします。
研究実装はGolf(Goroutine Leak Fixer)と呼ばれ、検出にとどまらず、リークしたgoroutineを停止させ、スタックやそこからのみ到達可能なオブジェクトをGCの回収対象にすることまで扱っていました。ただし、これらを回収すると、通常のGoでは生存していたはずのオブジェクトが回収され、finalizer(runtime.SetFinalizerで登録された後始末処理)が実行される可能性があります。これは副作用やpanicなど、本来観測されなかった挙動を生じさせます。そのためGolfでも、回収候補からfinalizer付きオブジェクトへ到達できる場合は、検出結果の報告にとどめて回収しない処理が設けられています。
一方、Proposal #74609を経てGoへ取り込まれた機能では、自動回復を採用していません。Proposalでは、論文の実装からの主な変更として、リークしたgoroutineとそのスタックからのみ到達可能なリソースを強制回収しないこと、goroutineleakプロファイルの取得を契機に検出用GCサイクルをオンデマンドで実行することが明示されています。なお、Go 1.27リリースノートには「Special thanks to Vlad Saioc at Uber for contributing this work.」と、この機能に貢献したSaioc氏への謝辞が記されています。
| 観点 | 論文のGolf | Goに取り込まれた機能 |
|---|---|---|
| 目的 | 検出と自動回復 | 検出と診断情報の提供 |
| 起動方法 | 拡張したGCで検出 | プロファイル取得時にオンデマンド実行 |
| 検出後 | 対象goroutineの停止とメモリ回収を試みる | プロファイルへ報告し、GCでトレースする |
| スタック | 回収対象になり得る | 通常のGCと同様にトレースする |
| 出力 | Golf独自の報告 | runtime/pprofとnet/http/pprof |
| 既存プログラムへの影響 | 回収で挙動が変わり得るため、finalizerを考慮した回復処理が必要 | 強制回収せず、既存の挙動を変えない |
Goへの取り込みで自動回復を見送ったのは、前述のとおり、強制回収がfinalizerの実行などを通じてプログラムの挙動を変えてしまうためです。そのためGoは、リークと判定したgoroutineもそれまでどおりGCの管理下に残したまま、プロファイルで報告するだけにとどめています。この機能が担うのはリークの検出と診断までであり、報告されたリークを修正するのは、そのコードを書いた私たち開発者自身です。
実際に試す
ここからは検証編です。意図的にgoroutineをリークさせる小さなプログラムを用意し、goroutineleakプロファイルでリークが検出される様子を確認します。
再現コード
package main |
chを受信するgoroutineは存在しますが、chへ送信もcloseもしていません。ループを抜けた後は実行可能なコードのどこからもchへ到達できず、3つのgoroutineは再開不能、つまりリークです。
net/http/pprofのブランクインポートと、ListenAndServeの第2引数のnilにより、pprofのハンドラーが登録されたhttp.DefaultServeMuxが使われます。
ビルドと実行
検証には、正式リリース前のGo 1.27 RC2を使用しました。
$ go1.27rc2 build -trimpath -o leakdemo . |
実験フラグの指定は不要です。
プログラムを実行したまま、別のターミナルから/debug/pprof/goroutineleakエンドポイントにアクセスすると、リーク検出の結果を取得できます。出力形式はdebugパラメーターで切り替わります。
debug=1: 件数と集約スタック
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutineleak?debug=1' |
goroutineleak profile: total 3 |
total 3は検出されたリークgoroutineの合計です。行頭の3は同じスタックに集約されたgoroutine数、@以降は各スタックフレームのPC値(プログラムカウンタ)、#行が関数名とソース位置を示します。3つのgoroutineが同じ箇所(main.go:15の受信待ち)でリークしているとわかります。
debug=2: 完全なスタックダンプと(leaked)
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutineleak?debug=2' |
debug=2では、リークしたgoroutineだけでなく、取得時点で存在するすべてのgoroutineの完全なスタックダンプが出力されます。以下はリーク部分の抜粋です。
goroutine 36 [chan receive (leaked)]: |
状態表示のchan receiveはチャネル受信待ちを、(leaked)はリーク判定を表します。main.go:15が実際に待機している位置、created by以下がこのgoroutineを生成した位置です。待機箇所と生成元の両方を特定できます。
goroutineプロファイルとの違い
pprofには従来から、取得時点に存在するすべてのgoroutineのスタックを一覧するgoroutineプロファイルがあります。ただし、その出力から、待機中のgoroutineが「将来再開する正常な待機」なのか「解除されないリーク」なのかを判断するのは読み手の仕事です。goroutineleakは、この判断を到達可能性解析で肩代わりし、解除不能と判定されたgoroutineだけを報告します。両者の用途を整理すると次のとおりです。
| プロファイル | 用途 |
|---|---|
goroutine |
取得時点で存在するgoroutineの状態とスタックを確認する |
goroutineleak |
解除不能と判定されたgoroutineを確認する |
参考として、goroutineプロファイルは次のコマンドで取得できます。
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2' |
前述のとおり、debug=2ではgoroutineleakもリークだけでなく完全なスタックダンプを出力するため、出力の行数はgoroutine?debug=2と同程度または同じになる場合があります。検証では、どちらも81行でした。ただし、取得処理自身のスタックや取得タイミングが異なるため、内容が完全に同一になるとは限りません。
リークを修正する
修正の共通原則は次のとおりです。
goroutineを待機させるなら、その待機を解除できるコードパスを用意する。
解除経路を用意できないのであれば、待機そのものを無くします。この原則に沿って、再現コードを4つの方針で修正します。使い分けは次のとおりです。
| 方針 | 適する状況 |
|---|---|
| 方針1: 待機を削除 | 同期が不要 |
方針2: closeまたは送信 |
明確な完了通知がある |
方針3: contextでキャンセル |
外部からキャンセルしたい |
| 方針4: タイムアウト | 相手の応答が保証されない |
方針1: 待機を削除
同期が不要なら、チャネルと受信待ちを削除します。
package main |
方針2: closeまたは送信
完了通知が必要なら、チャネルを閉じる処理を用意します。
package main |
この例は、待機と解除を対にする構造を確認するための最小例です。実務では、実際の処理主体が完了時にチャネルを閉じます。1回限りの通知であれば、closeの代わりにチャネルへの送信でも待機を解除できます。
方針3: contextでキャンセル
外部から停止したい場合はcontext.Contextを使用します。
package main |
cancel()によってctx.Done()が受信可能になり、workerが終了します。キャンセル関数が存在するだけではリークは防げません。必要なタイミングで確実に呼び出す必要があります。
方針4: タイムアウト
相手の応答が保証されない場合は、待機に期限を設けます。
package main |
time.Afterはchとは別のチャネルを返します。selectは、chまたはタイマー用チャネルのうち、先に受信可能になったcaseを1つ選びます(複数のcaseが同時に受信可能な場合は、ランダムに1つ選ばれます)。実務ではcontext.WithTimeoutを使い、キャンセルとタイムアウトをまとめて扱う方法もあります。
いずれの方針でも、修正後にgoroutineleak?debug=1を再取得すると、リークが検出されなくなったことを確認できます。
まとめ
- Go 1.27では、
goroutineleakプロファイルを実験フラグなしで利用できます(GOEXPERIMENT=goroutineleakprofileは削除されました) - 論文の研究実装Golfが目指した自動回復は採用されておらず、
goroutineleakは修正すべきコードを発見するための診断機能です debug=1は件数と集約スタックの確認に向きますdebug=2は完全なgoroutineスタックダンプを出力し、リークには(leaked)が付きます- 到達可能性に基づく検出のため偽陰性はあり得ます。
total 0はリークが存在しないことの証明にはなりません - 修正の基本は、不要な待機を削除するか、待機を解除する経路を用意することです
参考
- Go 1.27リリース連載(インデックス)
- Go 1.26 リリース連載 Goroutine Leak Profiles
- Go 1.26の新GC「Green Tea(緑茶)」解説
- Go 1.27 Release Notes
- Go 1.26 Release Notes
- proposal: runtime/pprof,runtime: new goroutine leak profile · golang/go#74609
- Design Document: Goroutine leak detection via garbage collection
- Saioc et al., Dynamic Partial Deadlock Detection and Recovery via Garbage Collection (ASPLOS 2025)