フューチャー技術ブログ

Go 1.26 リリース連載 Goroutine Leak Profiles

はじめに

Go 1.26 リリース連載の 2 本目です。

本記事では Go 1.26 で追加された Goroutine Leak Profiles について紹介します。

アップデートの概要

goroutine のリークを検知するためのプロファイルが runtime/pprof に追加されました。
これまでは Uber 社が公開している goleak などを用いてリークを検知するのが一般的なプラクティスでしたが、Go 標準のプロファイル機能を利用して検知ができるようになりました。

リリースノート を参照し、ポイントだけ先にまとめると次の通りです。

  • runtime/pprof に新しいプロファイル goroutineleak が追加された
  • net/http/pprof のエンドポイントとして /debug/pprof/goroutineleak も追加された
  • Go 1.26 では GOEXPERIMENT=goroutineleakprofile を付けてビルドした場合のみ利用可能となる(実験的機能)
  • Go 1.27 でデフォルト有効化が検討されている

関連する主要な Proposal や PR は次の通りです。

そもそもの話

本題へ入る前に、初心者向けに goroutine リークと Go のプロファイリング(runtime/pprof)について、軽くおさらいします。

goroutine リークとは

goroutine リーク(goroutine leak)は、ざっくり言うと「本来は不要になった goroutine が、適切に終了されず放置されたまま(実質的に)生き続ける」状態です。

リークした goroutine はスタックや参照しているヒープオブジェクトを保持し続け、長期的にはメモリ使用量や GC 負荷に影響します。通常 goroutine リークは、プログラムがすぐに停止したりエラーになったりしないため、問題に気付きにくいという点でやっかいです。

典型例は次のような3ケースです。

① 受信側がいないのに送信する
func leak() {
ch := make(chan int)
go func() {
// 送信を待つ
ch <- 1
}()

// 何かしらの処理でエラーが発生した場合、受信が行われず goroutine がリークする
if err := something(); err != nil {
return
}
fmt.Println(<-ch)
}
② 送信側がいないのに受信する
func leak() {
ch := make(chan int)

go func() {
// 受信を待つ
fmt.Println(<-ch)
}()

// 何かしらの処理でエラーが発生した場合、送信が行われず goroutine がリークする
if err := something(); err != nil {
return
}

ch <- 1
}
③ close されない channel を使う
func leak() {
ch := make(chan int)

go func() {
// channel が close されるまで受信し続ける
for x := range ch {
fmt.Println(x)
}
}()

ch <- 1
}

プロファイリング(runtime/pprof)とは

runtime/pprof は、Go ランタイムが持つ各種プロファイルを取得するための標準ライブラリです。
具体的には次のようなプロファイルが用意されています。

名前 種別 説明
goroutine スナップショット型 現在存在するすべての goroutine のスタックトレース
heap スナップショット型 生存オブジェクトに対するメモリ割り当てのサンプリング
allocs スナップショット型 過去に行われたすべてのメモリ割り当てのサンプリング
block スナップショット型 channel やロックなどの同期処理で待ち状態になったスタックトレース
mutex スナップショット型 競合しているミューテックスを保持している側のスタックトレース
threadcreate スナップショット型 OS スレッド生成時のスタックトレース
profile 期間収集型 一定期間にわたって収集された CPU プロファイル
trace 期間収集型 一定期間の実行トレース(スケジューラ/ネットワーク等)

スナップショット型のプロファイルは取得時点におけるプログラムの状態を切り取って記録するのに対し、期間収集型のプロファイルは一定期間にわたる実行状況を収集・記録します。

各プロファイルを取得する方法はいくつかありますが、net/http/pprof を使うと、これらを HTTP のエンドポイント経由で公開・取得できます。

import (
"net/http"
_ "net/http/pprof"
)

func main() {
http.ListenAndServe("127.0.0.1:6060", nil)
}

各種プロファイルは go tool pprof コマンドで HTTP エンドポイント(/debug/pprof/${プロファイル名})を指定することで確認できます。
たとえば heap プロファイルを確認するコマンドは次の通りです。

$ go tool pprof http://localhost:6060/debug/pprof/heap
Fetching profile over HTTP from http://localhost:6060/debug/pprof/heap
...
File: main
Build ID: 0692a5c786cbac9e9d379f2de2112b6066d8673c
Type: inuse_space
Time: 2026-01-19 15:15:56 UTC
Entering interactive mode (type "help" for commands, "o" for options)

このコマンドを実行すると、指定した HTTP エンドポイントからプロファイルを取得し、解析用の対話モード(pprof シェル)が起動します。

対話モードでは、toplist などのコマンドを使ってプロファイルの内容を確認できます。

(pprof) top
Showing nodes accounting for 3636.87kB, 100% of 3636.87kB total
Showing top 10 nodes out of 38
flat flat% sum% cum cum%
902.59kB 24.82% 24.82% 2097.87kB 57.68% compress/flate.NewWriter
650.62kB 17.89% 42.71% 1195.29kB 32.87% compress/flate.(*compressor).init
544.67kB 14.98% 57.68% 544.67kB 14.98% compress/flate.newDeflateFast (inline)
513.12kB 14.11% 71.79% 513.12kB 14.11% compress/flate.(*huffmanEncoder).generate
513kB 14.11% 85.90% 513kB 14.11% runtime.mallocgc
512.88kB 14.10% 100% 512.88kB 14.10% sync.(*Pool).pinSlow
0 0% 100% 513.12kB 14.11% compress/flate.(*Writer).Close
0 0% 100% 513.12kB 14.11% compress/flate.(*compressor).close
0 0% 100% 513.12kB 14.11% compress/flate.(*compressor).encSpeed
0 0% 100% 513.12kB 14.11% compress/flate.(*huffmanBitWriter).indexTokens

なお go tool pprof を利用せずとも、debug=1debug=2 を指定すれば、ブラウザや curl からプロファイルをテキスト形式で確認できます。

$ curl http://localhost:6060/debug/pprof/heap?debug=1
heap profile: 2: 2064 [14: 3192928] @ heap/1048576
0: 0 [1: 48] @ 0x200a9c 0x200ac1 0x213a50 0x206bcc 0x20ad60 0xa9654
# 0x200a9b net/textproto.NewReader+0x8b /usr/local/go/src/net/textproto/reader.go:38
# 0x200ac0 net/http.newTextprotoReader+0xb0 /usr/local/go/src/net/http/request.go:1044
# 0x213a4f net/http.readRequest+0x2f /usr/local/go/src/net/http/request.go:1080
# 0x206bcb net/http.(*conn).readRequest+0x1db /usr/local/go/src/net/http/server.go:1005
# 0x20ad5f net/http.(*conn).serve+0x31f /usr/local/go/src/net/http/server.go:1995
...

実際に試してみる

前提となる goroutine リークやプロファイルが理解できたところで、実際に goroutineleak プロファイルを試してみましょう。
先ほどリークの例として挙げた「close されない channel を使う」コードを利用します。

package main

import (
"fmt"
"net/http"
_ "net/http/pprof"
)

func leak() {
ch := make(chan int)
go func() {
// channel が close されるまで受信し続ける
for x := range ch {
fmt.Println(x)
}
}()
ch <- 1
}

func main() {
leak()
if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil {
panic(err)
}
}

Go 1.26 では実験的な機能(Experimental)となるため、まず GOEXPERIMENT=goroutineleakprofile を付けてビルド後、起動します。
まずは debug=1 を付与して簡易的なテキスト形式で確認してみましょう。

$ curl http://localhost:6060/debug/pprof/goroutineleak?debug=1
goroutineleak profile: total 1
1 @ 0xa261c 0x26e20 0x26994 0x244f6c 0xa9ab4
# 0x244f6b main.leak.func1+0x6b /workspaces/tech-blog-go-1.26-feature/profile/main.go:13

1つの goroutine がリークしていることがわかります。

debug=2 を付与して確認すると、現在存在する goroutine の完全なスタックトレースの一覧を確認できます。
リークを確認するには (leaked) が付いている goroutine を見つけることが重要です。
goroutine 21 [chan receive (leaked)]: という出力のとおり、channel の受信でブロックが発生し、リークしていることがわかります。

curl http://localhost:6060/debug/pprof/goroutineleak?debug=2
...
goroutine 21 [chan receive (leaked)]:
main.leak.func1()
/workspaces/tech-blog-go-1.26-feature/profile/main.go:13 +0x6c
created by main.leak in goroutine 1
/workspaces/tech-blog-go-1.26-feature/profile/main.go:12 +0x74
...

それでは次に、先ほどのコードにクローズ処理を入れることでリークを解消して試してみます。

package main

import (
"fmt"
"net/http"
_ "net/http/pprof"
)

func leak() {
ch := make(chan int)
// クローズ処理を追加
defer close(ch)
go func() {
for x := range ch {
fmt.Println(x)
}
}()
ch <- 1
}

func main() {
leak()
if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil {
panic(err)
}
}

プロファイルからも goroutine リークが発生していないことが確認できます。

$ curl http://localhost:6060/debug/pprof/goroutineleak?debug=1
goroutineleak profile: total 0

リーク検知の背後にある思想やしくみ

リーク検知の設計思想などをつかむために Proposal(#74609)や Design Doc を見てみます。

リークを検知するためのアプローチのざっくりとした理解としては、通常の GC とは異なるリーク検知用の GC サイクルを実行し、到達可能な(実行される可能性のある)goroutine をマークすることで、どこからも到達できない goroutine を検出するようなイメージになります。

リーク判定のために GC サイクルを走らせるため、本番環境における利用は原則さけ、ローカルや本番に近しい環境での調査に使うのがよさそうです。

なお、このアプローチは「検知されたものは確実にリークである(偽陽性が出ない)」ことを重視しており、逆に「リークしているけど検知できない(偽陰性)」ケースはあり得るとされています。

The advantage of this approach over other goroutine leak detection techniques is that it can be leveraged, with a minimal performance cost, in regular Go systems, e.g., production services. It is also theoretically sound, i.e., there are no false positives. Its primary limitation is that its effectiveness is reduced the more heap resources are over-exposed in memory, i.e., pair-wise reachable.

ここまで見るとテストの実行前後で goroutine スタックを比較することでリークを検知する goleak のアプローチとは根本的に異なることがわかります。

その意味ではどちらかがどちらかに取って代わるものではなく、もしかしたら相互補完的な関係性にあると言えるかもしれません(例. ユニットテストや CI では goleak を利用し、より本番に近い環境での診断には goroutineleak プロファイルを使用するなど)。

リーク検知されないケース

先ほど説明した通り goroutineleak プロファイルによる検知は「偽陽性を出さない」方向に設計されており、リークしているけど検知できないケースがあります。
たとえばグローバル変数として channel を保持する場合は、到達可能(実行される可能性がある)と判断されます。

package main

import (
"fmt"
"net/http"
_ "net/http/pprof"
)

// グローバルに保持される channel
var gch = make(chan int)

func leak() {
go func() {
for x := range gch {
fmt.Println(x)
}
}()
gch <- 1
}

func main() {
leak()
if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil {
panic(err)
}
}

次のとおりこのコードはリークとして検知されません。

$ curl http://localhost:6060/debug/pprof/goroutineleak?debug=1
goroutineleak profile: total 0

おわりに

ここまで見てきたとおり、Go 1.26 で追加された goroutineleak プロファイルは、なかなか追いづらかった goroutine リークを検知できる標準機能です。

Go 1.26 時点では実験的機能という位置付けですが、Go 1.27 でデフォルトで有効になるのが待ち遠しいですね。

参考