はじめに
goroutineがどこかで詰まっている気がするのに、プロファイラで見ても確証が持てない。そんな状況を機械的に判定できないものかと思っていたところ、Go 1.27で goroutine leak profile という機能が正式版になっているのを見つけました。
Go 1.27は2026年8月19日に正式リリースされました1。目玉機能はいくつかありますが、今回は encoding/json/v2 以外の2つ、generic methods(メソッド自身が独自の型パラメータを持てるようになった言語機能)と goroutine leak profile(runtime/pprof に追加されたリーク検出プロファイル)を実際にインストールして動かしました。対象読者は、Goで並行処理を書いていて「goroutineがどこかで詰まっている気がするが確証がない」という状況に心当たりがあるエンジニアです。
TL;DR
- Go 1.27で解禁された generic methods は、Go 1.24ではコンパイルエラーになることを対照実験で確認した
-
pprof.Lookup("goroutineleak").Count()は 常に0を返す。件数はプロファイルをWriteTo()した瞬間にしか確定しない - 意図的に作った5個のリークgoroutineは、
WriteTo()後の出力ファイルには正確に5件・発生行まで記録されていた
環境構築
クラウド環境にはGo 1.24.7が入っていたため、Go 1.27を別ディレクトリに展開して共存させました。
curl -fsSL -o /tmp/go1.27.0.linux-amd64.tar.gz https://go.dev/dl/go1.27.0.linux-amd64.tar.gz
mkdir -p /tmp/go127 && tar -C /tmp/go127 -xzf /tmp/go1.27.0.linux-amd64.tar.gz
/tmp/go127/go/bin/go version
go version go1.27.0 linux/amd64
既存の go コマンドはGo 1.24.7のまま残しておき、/tmp/go127/go/bin/go を新バージョン専用の呼び出しパスとして使い分けました。
検証1: generic methods は本当にGo 1.24で弾かれるのか
Go 1.27の言語仕様変更点の1つが、メソッド宣言が自分自身の型パラメータを持てるようになったことです1。次のコードは、Box[T] の要素を別の型 U に変換する MapTo メソッドに、レシーバの型パラメータ T とは独立した U を宣言しています。
package main
import "fmt"
type Box[T any] struct {
items []T
}
// Go 1.27: メソッド自身が型パラメータを宣言できる
func (b *Box[T]) MapTo[U any](f func(T) U) *Box[U] {
result := make([]U, len(b.items))
for i, v := range b.items {
result[i] = f(v)
}
return &Box[U]{items: result}
}
func (b *Box[T]) Items() []T {
return b.items
}
func main() {
ints := &Box[int]{items: []int{1, 2, 3, 4, 5}}
strs := ints.MapTo(func(n int) string {
return fmt.Sprintf("n=%d", n)
})
fmt.Println(strs.Items())
}
Go 1.27で実行すると、意図どおり動きます。
/tmp/go127/go/bin/go run main.go
[n=1 n=2 n=3 n=4 n=5]
同じファイルを既存のGo 1.24.7でビルドすると、対照実験としてどう失敗するかを確認しました。
go version
go mod init genmethod-test
go build .
go version go1.24.7 linux/amd64
# genmethod-test
./main.go:10:23: syntax error: method must have no type parameters
エラーメッセージ method must have no type parameters がそのままGo 1.27で解禁された制約を言い当てています。「型パラメータを持てるようになった」という説明文だけでは実感が薄いですが、直前のバージョンで同じコードが構文エラーとして弾かれるのを見ると、何が変わったのかが一目で分かります。
検証2: goroutine leak profileでCount()が0を返す
runtime/pprof に追加された goroutineleak プロファイルは、Go 1.26で実験的に導入され、Go 1.27で正式機能になりました1。チャネルや sync.Mutex でブロックされたまま、実行可能な他のgoroutineから到達できなくなったgoroutineを検出します。
わざと5個のgoroutineをチャネル待ちのままリークさせるコードを書きました。
package main
import (
"fmt"
"os"
"runtime"
"runtime/pprof"
"sync"
"time"
)
func leakGoroutine() {
ch := make(chan int) // 誰も送信しないチャネル
go func() {
<-ch // 永久にブロックする(リーク)
}()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
}(i)
}
wg.Wait()
for i := 0; i < 5; i++ {
leakGoroutine()
}
time.Sleep(100 * time.Millisecond)
runtime.GC()
profile := pprof.Lookup("goroutineleak")
if profile == nil {
fmt.Println("goroutineleak profile not found")
os.Exit(1)
}
fmt.Printf("検出されたリークgoroutine候補数: %d\n", profile.Count())
f, err := os.Create("goroutineleak.prof")
if err != nil {
panic(err)
}
defer f.Close()
if err := profile.WriteTo(f, 1); err != nil {
panic(err)
}
fmt.Println("プロファイルを goroutineleak.prof に出力しました")
}
実行結果は次のとおりでした。
検出されたリークgoroutine候補数: 0
プロファイルを goroutineleak.prof に出力しました
profile.Count() が0を返しています。5個のgoroutineは確実にチャネル待ちでブロックしたままのはずなのに、検出数がゼロと表示されると「実装ミスか」「検出条件を満たしていないのか」と疑いたくなります。
そこで出力された goroutineleak.prof の中身を確認しました。
cat goroutineleak.prof
goroutineleak profile: total 5
5 @ 0x47daaa 0x41512e 0x414c72 0x4df7d9 0x483981
# 0x4df7d8 main.leakGoroutine.func1+0x18 /tmp/go127-test/leakdemo/main.go:15
total 5 となっており、main.go:15(<-ch の行)でブロックしている5個のgoroutineが正確に検出されていました。つまり Count() はスキャン前の値を返す一方、実際のリークスキャンは WriteTo() を呼び出したタイミングで実行される、という順序依存の挙動です。
著者視点の一次所見
Count() を先に呼んで0件だったので「このプロファイルは動いていないのでは」と一瞬止まりましたが、WriteTo() の出力を見て初めて実装の意図に気づきました。pprof.Lookup() が返す *pprof.Profile は多くの場合「現在保持しているサンプル数」を Count() で返しますが、goroutineleak は事前に蓄積されたサンプル集合を持つプロファイルではなく、呼び出し時にランタイムをスキャンして結果を確定させる 設計だと分かります。この挙動は go1.27 の公式リリースノートには明記がなく、今回の実機観測で確認した実装上の性質です2。監視ツールに組み込む場合、Count() だけを見て「リークなし」と判断すると誤検知(見逃し)になるため、実際に使うときは WriteTo() の出力サイズやパース結果で判定する必要があります。
まとめ
- Go 1.27で解禁されたgeneric methodsは、Go 1.24でのコンパイルエラー(
method must have no type parameters)との対照実験で挙動差を確認できた - goroutine leak profileは
Count()が常に0を返す設計になっており、リーク検出の確定はWriteTo()呼び出し時に行われる - 監視・アラートに組み込む際は
Count()を使わず、WriteTo()の出力を実際にパースして判定する必要がある
「goroutineが詰まっている気がするが確証がない」という状況は、goroutineleak プロファイルの WriteTo() を実際に呼んで出力ファイルを読めば確認できます。Count() の戻り値だけを信じないというのが、今回の検証で持ち帰った一番の教訓です。
encoding/json/v2 の重複キー検出やUnmarshal速度差については、既存記事3で実測しているのであわせて参照してください。
関連記事
- Claude Code headersHelper、非対話セッションでは走らず401になる
- Codex CLIの--approve-for-me、-sも-aも併用できなかった
- Codexのプラグインマーケットプレイス、同名衝突は優先順位ではなくエラーで止まった
-
Go 1.27 Release Notes - Go公式ドキュメント ↩ ↩2 ↩3
-
Accepted proposal: a goroutine leak profile in the Go standard library -
WriteToがリーク検出のGCサイクルを実行してから書き出す旨の解説記事 ↩ -
Go 1.27のjson/v2で重複キーを黙って上書きしなくなった - 同じGo 1.27の別機能を検証した記事 ↩