Skip to content

필사 모드: Go 1.26 の goroutineleak プロファイル — GCのマーキングでゴルーチンリークを捕まえる

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — ゴルーチンリークはなぜプロファイルに映らなかったのか

ゴルーチンリークをデバッグしたことがある人なら、この苛立ちを知っています。サービスのメモリが右肩上がりに増え続けます。ゴルーチンプロファイルを取ります。ゴルーチンが4万個あります。そのうちどれが永遠に目覚めない奴でしょうか。

プロファイルは答えてくれません。goroutine プロファイルはすべてのゴルーチンのスタックをダンプするだけで、そこに現れる [chan receive] は「200ms後に目覚める正常なワーカー」と「誰も参照していないチャネルを握って永遠に眠るゾンビ」を区別してくれません。どちらも同じくチャネル受信でブロックされているからです。Go 1.26提案書の表現は的確です — ゴルーチンリークは悪名高くデバッグが難しく、メモリ・ゴルーチンプロファイルのようなかなり徹底した診断情報を持っていても、その存在自体に気づきにくいことがあります。

そのため実務ではuber-go/goleakのようなツールをテストに組み込んできました。テストの前後でゴルーチンのスタックを比較し、残っている奴を捕まえる方式です。よく機能しますが、テストの中でしか機能しません。プロダクションで本当に漏れているゴルーチンは、依然として手の届かないところにありました。

Go 1.26が2026年2月10日にリリースされ、この穴に一つの道具が入りました。runtime/pprof の新しいプロファイルタイプ、goroutineleak です。

Go 1.26 が追加したもの

リリースノートの定義から見てみましょう。リークしたゴルーチンとは、「チャネル、sync.Mutexsync.Cond など何らかの並行プリミティブにブロックされていて、決してブロックが解けることのないゴルーチン」です。ここで重要なのは「決して解けない」という点です — 今ブロックされているという事実ではなく、未来のどの実行でも目覚めないという事実です。

ランタイムがこれをどう判定するか、リリースノートが直接説明しています。ゴルーチンGが並行プリミティブPにブロックされていて、Pがどの実行可能なゴルーチンからも、そしてそのゴルーチンたちが起こしうるどのゴルーチンからも到達不能であれば、Pは決して解けないため、Gは決して目覚められません。

Go 1.26では実験的機能なので、ビルド時に有効化する必要があります。

# プロファイルを有効にしてビルド/実行
GOEXPERIMENT=goroutineleakprofile go run ./cmd/server

# 実験フラグなしでビルドすると pprof.Lookup("goroutineleak") は nil を返す

実験を有効にすると、net/http/pprof のエンドポイント /debug/pprof/goroutineleak も一緒に登録されます。

リリースノートがこの機能に付けた2つの注記が印象的です。一つは、実装自体はプロダクションレディであり、実験扱いにしたのは純粋にAPIについてのフィードバック — 具体的にはこれを新しいプロファイルタイプにした選択が正しいか — を得るためだということ。もう一つは、実際に使用していなければ追加のランタイムオーバーヘッドが発生しないように設計されているということです。つまり、有効にしたままプロファイルを取らなければ、ほぼ無料に近いという意味です。

そしてGo 1.27ドラフトリリースノートはすでにこう書いています — goroutineleak プロファイルはもう一般提供(generally available)であり、goroutineleakprofile のGOEXPERIMENT設定は削除されたと。Go 1.27は2026年8月にリリースされる予定なので、この機能は来月にはもう有効になっているわけです。今のうちに理解しておく価値がある理由です。

核心アイデア — 到達可能性を生存性の近似として使う

この機能の美しい部分はここです。新しい追跡インフラは作りませんでした。すでにあるGCマーキングを再利用します。

論理はこうです。あるゴルーチンGがチャネル ch にブロックされているとします。Gを起こすには、誰かが ch に送信するか閉じる必要があります。そのためには、その誰かが ch への参照を手に入れられなければなりません。ところが参照を手に入れられるということは、まさにGCの言うメモリ到達可能性です。

そこで論文の表現では、メモリ到達可能性が並行操作の生存性を健全に過大近似(soundly over-approximate)します。過大近似という言葉が核心です — GCが「到達可能」と判定した集合は、実際に生きているゴルーチンを漏れなく含みます。生きているのに到達不能と誤分類されることはありません。

これがなぜ重要かというと、この方向の健全性が偽陽性がないという保証につながるからです。提案書が明示しています — 理論的に健全であり、つまり偽陽性がないと。プロファイルが「このゴルーチンはリークだ」と言えば、それは本物のリークです。推測ではありません。

論文はこの保証がなぜ必須なのかも説明します。偽陽性はまだ使用中のメモリを解放させ、予期しないクラッシュを引き起こしかねないからです。この手法はGCの中で動くため、判定を誤れば単なる誤報ではなく、メモリ安全性の事故になります。健全性は選択ではなく要求事項だったわけです。

アルゴリズム — マーキングを再び回る6段階

公式提案書がGCサイクルの変更を6段階で記しています。要約するとこうです。

  1. マークルートの準備。通常のGCはすべてのゴルーチンをルートにします。リーク検出サイクルは実行可能な(runnable)ゴルーチンだけをルートにします。この一行がすべての出発点です。
  2. マーキング。このルート集合から到達可能なメモリをマークします。
  3. 検査。マーキングが終わったら、まだマークされていないゴルーチンの中に、2段階でマークされた並行プリミティブにブロックされている奴がいないか見ます。
  4. 昇格。そういうゴルーチンは「いずれ実行可能」(eventually runnable)とみなしてルートに追加し、2段階に戻ってマーキングを再開します。
  5. 不動点と報告。この過程を繰り返して不動点に達したら、最後までルートになれなかったゴルーチンをリークとして報告します。そしてリークしたゴルーチンをルートにしてもう一度マーキングを行い、通常のGCのように到達可能なすべてのメモリがマークされるようにします。
  6. スイープは通常どおり進みます。

3〜4段階の繰り返しが核心です。「AがBを起こし、BがCを起こす」という連鎖を不動点に達するまで追いかけるわけです。一度のマーキングで終えれば、「今はブロックされているがすぐに目覚めるゴルーチン」をリークと誤判定してしまいます。繰り返しこそが偽陽性を消す仕掛けです。

5段階の最後の一文も読み飛ばしてはいけません。後でまた触れますが、リークしたゴルーチンのメモリは回収されません。報告するだけで、マークはそのままにします。

実装面のディテールも一つ触れておく価値があります。このアプローチが成立するには、特定のポインタが条件付きでのみ追跡される必要があります。*sudog オブジェクト(ブロックされたゴルーチンが待機キューに掛ける構造体)は allgssemtable を通じてグローバルに到達可能なので、放っておくとまだ到達可能と判定されていないチャネルまでGCがマークしてしまいます。そこでランタイムにmaybe-traceableポインタという概念を導入し、リーク検出サイクルの間は *sudog の該当フィールドを追跡不可の状態に戻します。タダで得られた機能ではなく、ランタイム内部をかなり慎重に触って作られた機能だということです。

使ってみる

典型的なリークを一つ作ってみましょう。

package main

import (
	"os"
	"runtime/pprof"
	"time"
)

func leak() {
	ch := make(chan int) // バッファなしチャネル
	go func() {
		<-ch // 誰も送信しない -> 永遠にブロック
	}()
	// ch はここでスコープを外れ、実行可能なゴルーチンからは参照されない
}

func main() {
	leak()
	time.Sleep(100 * time.Millisecond) // ゴルーチンが実際にブロックされる時間を与える

	f, _ := os.Create("leak.pprof")
	defer f.Close()
	pprof.Lookup("goroutineleak").WriteTo(f, 1) // debug=1: 人が読める要約
}

GOEXPERIMENT=goroutineleakprofile go run main.go で動かすとリークが捕まります。実験フラグなしで動かすと pprof.Lookup("goroutineleak")nil を返してそのままパニックするので注意してください。

debug レベルの意味は提案書に定義されています — debug < 2 なら、リーク状態のゴルーチンだけをフィルタリングし、debug >= 2 ならすべてのゴルーチンの全スタックダンプを受け取ります。そして debug=0 なら、通常のプロファイルと同じくgzip圧縮されたprotobufが出るので、go tool pprof で開けます。

サーバーなら、HTTPエンドポイントの方が便利です。

# net/http/pprof をimportするとデフォルトのmuxに登録される
curl 'localhost:6060/debug/pprof/goroutineleak?debug=1'

# go tool pprof で直接つないでもよい
go tool pprof -top 'http://localhost:6060/debug/pprof/goroutineleak'

debug=2 の出力は見慣れたゴルーチンダンプとほぼ同じですが、状態表示に一つタグが付きます。

goroutine 21 [chan receive (leaked)]:
main.leak.func1()
	/path/main.go:13 +0x6c

(leaked)。この一語がこの機能のすべてです。これまでのゴルーチンダンプでは [chan receive] としか見えず、それが正常かゾンビか分かりませんでしたが、今はランタイムが代わりに判定してくれます。

ここで重要な挙動が一つあります — プロファイルを取る行為は単なるスナップショットの読み取りではありません。提案書によれば、プロファイル抽出はリーク検出GCサイクルをキューに入れて完了を待った後、ゴルーチンプロファイルを取ってリーク状態にフィルタリングします。つまり curl /debug/pprof/goroutineleak を一回呼ぶことが、特殊なGCサイクルを一回強制することになります。ダッシュボードに繋げて10秒ごとに引っ張る代物ではないということです。

何を捕まえられないか — 偽陰性の2つのパターン

ここからは正直になる部分です。偽陽性がないという保証の裏側には、偽陰性はいくらでもあるという事実があります。リリースノートもこれを隠していません — 永久にブロックされたゴルーチンをすべてのケースで検出するのは不可能であり、このアプローチはそうしたリークの大きな部類を検出すると書いています。「大きな部類」であって「すべて」ではありません。

提案書は限界を一行でまとめています — ヒープ資源がメモリ上で互いに過度に露出している、つまり互いに到達可能であるほど効果が落ちます。これが実際にどんなコードを意味するのか、論文が実コードから見つけた2つのパターンで示しています。

第一に、グローバルチャネル。 チャネルがグローバル変数にぶら下がっていれば、常にメモリから到達可能です。そのため、そのチャネルにブロックされたゴルーチンは常に「いずれ目覚めうる」と分類されます — 実際には誰もそのチャネルを二度と使わないとしても。

var gch = make(chan int) // グローバル -> 常に到達可能

func neverDetected() {
	go func() {
		<-gch // 本物のリークだが、プロファイルには映らない
	}()
}

リリースノートも同じことを言っています — この手法は到達可能性の上に成り立っているため、グローバル変数や実行可能なゴルーチンのローカル変数を通じて到達可能な並行プリミティブにブロックされて生じたリークは識別できないことがあります。

第二に、こちらの方がより厄介です — 論文の表現で「暴走する生きているゴルーチン」(runaway live goroutines)。論文が実コードから抜き出した例を移すとこうなります。

type dispatcher struct {
	ch    chan struct{}
	ticks int
}

func newDispatcher() *dispatcher {
	d := &dispatcher{ch: make(chan struct{})}
	go func() { // ハートビートゴルーチン
		for ; ; time.Sleep(time.Second) {
			d.ticks++ // dを握り続ける
		}
	}()
	return d
}

func main() {
	d := newDispatcher()
	go func() { d.ch <- struct{}{} }() // このゴルーチンがリークしうる
	if someCondition {
		return // 誰も d.ch を受信しない
	}
	<-d.ch
}

何が起きているか見てください。ハートビートゴルーチンは time.Sleep ループを回るので常に実行可能です。そしてそれが d を参照しているため、d が到達可能になり、したがって d.ch も到達可能になります。GCの立場では、「ハートビートゴルーチンがいつか d.ch に送信するかもしれない」と判断するしかありません。ハートビートは d.ticks にしか触れず d.ch は見向きもしないのに、です。到達可能性はフィールド単位ではなくオブジェクト単位だからです。

このパターンが厄介なのは、ハートビート・メトリクスレポーター・バックグラウンドリフレッシャーのように、きちんと作られたサービスによくある構造だからです。そういうゴルーチンが一つ構造体を握っていれば、同じ構造体にぶら下がったチャネルのリークはまるごと透明人間になります。あなたのコードベースがこのプロファイルできれいに出たとしても、それがリークがないという意味なのか、ハートビートが全部隠しているだけなのかは、別途考える必要があります。

コスト — オーバーヘッドはどこに付くか

数字は2つの出典を区別して見る必要があります。

アップストリームが言っていること。 Go 1.26のリリースノートは、この機能が実際に使用中でなければ追加のランタイムオーバーヘッドを発生させないように設計されていると書いています。具体的な数値は示していません。提案書もプロトタイプに触れるだけで、性能データは載せていません。

論文が測定したもの(著者自身による測定、ASPLOS 25)。ここからはSaioc らの論文Golf というツール — Go 1.22.5のGCを拡張した実装 — で測った数値であり、Go 1.26に入ったものと同じ構成ではありません(なぜ違うのかは以下で説明します)。そのまま引用するとこうです。

  • マイクロベンチマークでGCマーキング段階のスローダウンの中央値は、正常なプログラム基準で0.96倍、つまりむしろ少し速くなりました。最小で0.13倍(876µs → 112µs)まで下がりました。ただし最悪のケースでは4.8倍(113µs → 538µs)まで遅くなりました。
  • リークのあるプログラムでは中央値0.71倍でさらに速くなります。マークするメモリが減るためです。最悪は5.87倍(136µs → 798µs)。
  • 絶対時間では、マーキング段階は依然として10ms以内に終わりました。正常な例では最大2ms(ベースライン619µsに対して3.27倍)。
  • Uberの実サービス(170万行)を制御された環境で動かしたところ、主なコストはGCの一時停止時間でした。GCサイクルあたりの平均一時停止がベースラインの約2.5倍。ただし、リーク10%のシナリオでもGCの総一時停止時間は、30秒の実行時間の0.65%程度にとどまりました。

スローダウンの倍率だけ見ると恐ろしく感じますが、絶対値がミリ秒単位であること、そして倍率が大きく揺れることも一緒に見る必要があります。そして、これらの数値はすべて著者たちが自分のツールを自分のベンチマークで測ったものです。

実務的に覚えておくべきことは、むしろ単純です。プロファイルを取る瞬間に、特殊なGCサイクルが強制的に回ります。 取らなければ(リリースノートの設計意図どおりなら)ほぼ無料です。だからコストモデルは「有効にしておくコスト」ではなく「呼び出すコスト」です。

goleak との関係 — 代替ではなく補完

uber-go/goleak をすでに使っているなら、乗り換える準備をする必要はありません。論文が直接比較していますが、結果は控えめです。

Uberのコードベースのテストスイートで、goleakは個別のリークを29,513件(重複排除で357件)報告しました。同じ条件で Golf は17,872件(60%)を検出し、重複排除すると180件 — 357件の50%程度でした。論文の表現どおり、goleakはテストスイートでは設計上より効果的です。

もう少し掘り下げると、欠陥のあるコードがテストでリークを露呈させた場合、Golf はgoleakが見つけたもののうち82%を見つけ(レポートごとの比率の曲線下面積基準)、重複排除された180件のレポートのうち103件(55%)では、goleakが報告した個別のリークをすべて見つけました。マイクロベンチマークでは全体の検出率が94.75%とはるかに良い結果でした。

数字がこう分かれる理由が重要です。論文が指摘するとおり、Golf の効き目はGCサイクルがリーク発生時点とテスト終了時点に対していつ回るかに懸かっています。テストは短く、GCが回らないこともあります。一方goleakはGCとは無関係に、テスト終了時点のスタックを直接比較します。

そこで役割分担は自然です。

  • テスト・CIではgoleak。 短く決定的な環境では、スタック比較の方がより緻密に捕まえます。
  • プロダクションとその周辺ではgoroutineleakプロファイル。 goleakが行けない場所であり、偽陽性がないという保証がオンコール状況で価値を発揮します。夜中の3時に「これは本当にリークなのか」を確かめ直す必要がないというのは、思ったより大きなことです。

リリースノートもテスト、CI、プロダクションのすべてで試してみることを勧めているので、どちらか一つを選べというフレーミング自体が間違った問いです。

論文とアップストリームの違い — 回収は抜け落ちた

これはリリースノートだけ読むと見落としやすい部分なので、別に記しておきます。

論文のタイトルは "Dynamic Partial Deadlock Detection and Recovery via Garbage Collection" です。検出だけでなく、回収が半分を占めます。論文の Golf はデッドロックしたゴルーチンをstop-the-world条件下で報告し、強制終了します。その結果、リーク10%のシナリオでサーバーのメモリ使用量が約49倍低くなり、スループットが9%上がり、テール遅延が約1.5倍減ったと報告しています(これも著者自身による測定)。

ところがGo 1.26に入ったのはその半分です。提案書の5段階をもう一度見てください — リークを報告した後、リークしたゴルーチンをルートにしてもう一度マーキングを行い、通常のGCのように到達可能なすべてのメモリがマークされるようにします。つまりアップストリームの実装は、リークしたゴルーチンが握っているメモリをわざと生かしたままにします。リリースノートも、プロファイルがリークを報告するとしか言っておらず、回収やゴルーチンの強制終了については触れていません。

これは妥協というより、合理的な設計判断として読めます。言語ランタイムがユーザーのゴルーチンを勝手に殺してメモリを回収するのは、診断ツールが越えてはいけない一線です。健全性の証明がどれだけ堅牢でも、プロダクションランタイムがその判定でコードを強制終了することと、レポートを一つ出すこととでは、リスクの重さが違います。

実務的な含意ははっきりしています。このプロファイルはリークを直してはくれません。 メモリも返してくれません。リークがどこにあるかをスタックトレースで教えてくれるだけで、直すのは依然としてあなたの仕事です。論文の印象的な49倍のメモリ削減のような数字をGo 1.26に期待してはいけません — それは回収まで行うツールの数字です。

いつ使い、いつ使わないか

価値を発揮する場合

  • 長時間稼働するサービスでメモリが右肩上がりなのに原因がつかめないとき。特にゴルーチン数も一緒に増えているなら、最初に取ってみるべきプロファイルです。
  • オンコール中に「このゴルーチンたちは本当にゾンビなのか」を確信なく推測しているとき。偽陽性がないという保証がまさにこの瞬間に価値を発揮します。
  • goleakをすでにCIに組み込んでいるが、プロダクションでしか現れない非決定的なリークを追っているとき。

過剰、または不適切な場合

  • 短く決定的な単体テスト。GCが回る保証がないため検出がまばらになります — goleakが正しい道具です。
  • 常時稼働の監視ダッシュボード。プロファイルを取るたびにGCサイクルが強制されます。インシデント中に何度か取る道具であって、定期的に引っ張るメトリクスではありません。
  • コードベースがグローバルチャネルとハートビートゴルーチンで密に絡み合っているとき。偽陰性が支配的になり、「きれい」という結果から何の情報も得られないことがあります。
  • このプロファイルを通過したからリークがないと結論づけたいとき。そんな結論をこの道具は支持しません。

おわりに

まとめるとこうです。Go 1.26の goroutineleak プロファイルは、GCのマーキング段階を再利用してゴルーチンリークを検出します。実行可能なゴルーチンだけをルートにしてマークし、マークされたプリミティブにブロックされたゴルーチンをルートに昇格させることを不動点まで繰り返し、最後まで残ったゴルーチンをリークとして報告します。メモリ到達可能性が生存性の健全な過大近似であるという洞察のおかげで偽陽性がなく、まさにその到達可能性に基づいているがゆえに、グローバル変数と暴走する生きているゴルーチンの前では偽陰性が生じます。

個人的にこの機能が良いと思う理由は、性能の数字のためではありません。すでにあるメカニズムからほぼ無料の情報を引き出したという点のためです。GCはもともと「誰が何に到達できるか」を毎サイクル計算していました。その計算が実は「誰が誰を起こせるか」への答えでもあると見抜いたことが、この研究のすべてです。そしてその洞察は実際に博士研究からGoランタイムまで届きました — ASPLOS 25論文が1年足らずで標準ライブラリに入ったわけです。

Go 1.27が2026年8月に出れば、フラグなしでそのまま使えるようになります。それより前に、今のうちに GOEXPERIMENT=goroutineleakprofile で自分のサービスに一度取ってみることをお勧めします。何か出てくれば本物です。何も出てこなければ — それはハートビートゴルーチンを一度数えてみる理由です。

参考資料

현재 단락 (1/133)

ゴルーチンリークをデバッグしたことがある人なら、この苛立ちを知っています。サービスのメモリが右肩上がりに増え続けます。ゴルーチンプロファイルを取ります。ゴルーチンが4万個あります。そのうちどれが永遠に...

작성 글자: 0원문 글자: 11,241작성 단락: 0/133