0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go 1.27のgoroutine leak profile、Countは0でも5件検出した

0
Posted at

はじめに

goroutineがどこかで詰まっている気がするのに、プロファイラで見ても確証が持てない。そんな状況を機械的に判定できないものかと思っていたところ、Go 1.27で goroutine leak profile という機能が正式版になっているのを見つけました。

Go 1.27は2026年8月19日に正式リリースされました1。目玉機能はいくつかありますが、今回は encoding/json/v2 以外の2つ、generic methods(メソッド自身が独自の型パラメータを持てるようになった言語機能)と goroutine leak profileruntime/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で実測しているのであわせて参照してください。

関連記事

  1. Go 1.27 Release Notes - Go公式ドキュメント 2 3

  2. Accepted proposal: a goroutine leak profile in the Go standard library - WriteTo がリーク検出のGCサイクルを実行してから書き出す旨の解説記事

  3. Go 1.27のjson/v2で重複キーを黙って上書きしなくなった - 同じGo 1.27の別機能を検証した記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?