プログラミング
Goroutineリークプロファイル
Goroutine Leak Profiles (go.dev)
要約
Goの並行処理機能は強力ですが、デバッグが難しいGoroutineのリークを引き起こすことがあります。Go 1.27で導入された新しいGoroutineリークプロファイラーは、本番環境でもこれらのリークを特定するのに役立ちます。この記事では、その使い方と検出可能なリークの例、および実装の仕組みについて解説します。
全文翻訳
Goの並行処理機能は強力で使いやすいですが、その使いやすさゆえに、経験豊富な開発者でさえ間違いを犯すことがあります。幸いなことに、Goエコシステムにはレース検出器のようなデバッグ用の便利なツールが備わっていますが、既存のツールでも、この記事のトピックであるGoroutineリークのような一部の並行処理バグを見逃すことがあります。
Goroutineは、チャネル、ロック、ウェイトグループなどの共有並行プリミティブを介して同期または情報を交換します。通信中、Goroutineはこれらのプリミティブでブロックされることがよくあります。つまり、ある条件が満たされるまで待機します。一般的な例としては、保持されているミューテックスの取得を待つ、チャネルからメッセージを受信するなどが挙げられます。Goroutineは、ネットワークソケットやファイルからの読み取りなどのオペレーティングシステム操作でブロックされることもあります。
Goroutineがブロックされているが、ブロックを解除するために必要な条件が決して満たされない場合、そのGoroutineはリークしていると見なすことができます。時間の経過とともに、リークしたGoroutineの蓄積は、リークしたGoroutine自体やそれらが参照するメモリによる過剰なメモリ使用量、およびガベージコレクタによるCPU使用量(特にGOMEMLIMITが使用されている場合)を通じてパフォーマンスを低下させます。
Goroutineリークの検出は非常に困難な場合があります。単体テストでは、最も重要な進歩の1つはオープンソースライブラリgoleakです。これは個々のテストを計装して、テスト終了後に終了していないGoroutineを疑わしいものとして通知できます。同様に、Go 1.25では標準ライブラリにsynctestパッケージが導入されました。これは、並行イベントの順序をより細かく制御できるようにすることで、並行コードの単体テストの品質を大幅に向上させ、再現が難しいシナリオを確実にテストできるようにします。
残念ながら、どちらのアプローチも本番システムでのGoroutineリークをチェックすることはできません。特に大規模なシステムでは、テストでは考慮されない方法で動作する可能性があります。
Goroutineプロファイルは、多すぎるGoroutineをブロックしている操作をチェックしたり、成長トレンドを分析したりするための基本的な方法です。しかし、Goroutineプロファイルは、リークしているGoroutineと、設計上(例えばマイクロサービスのトラフィック増加による)一時的に多数ブロックされているGoroutineを区別できません。同様に、数が少ないリークは何年も検出されないまま見過ごされる可能性があります。
Go 1.27では、実行中のGoプログラム(本番システムを含む)でGoroutineリークを見つけるための柔軟で軽量なメカニズムであるGoroutineリークプロファイラーが導入されました。以前のアプローチとは異なり、人間の分析を必要とするこれらのアプローチとは異なり、このメカニズムは正確であり、ほとんどまたは全く偽陽性を生成しません。トレードオフとして、チャネルまたはsyncパッケージのプリミティブで永続的にブロックされているGoroutineのサブセットに限定されます。幸いなことに、例で示すように、これはすでにGoroutineリークの非常に大きなサブセットをカバーしています。
次のセクションでは、この機能の使用方法、検出可能なリークの追加例、および基盤となる実装とトレードオフの説明を紹介します。
例: 並行ワーカー
並行して作業項目を処理する関数を考えます。
code
type result struct {
res workResult
err error
}
func processWorkItems(ws []workItem) ([]workResult, error) {
// Process work items in parallel, aggregating results in ch.
ch := make(chan result)
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err}
}()
}
// Collect the results from ch, or return an error if one is found.
var results []workResult
for range len(ws) {
r := <-ch
if r.err != nil {
// This early return may cause goroutine leaks.
return nil, r.err
}
results = append(results, r.res)
}
return results, nil
}
code
chはバッファなしチャネルであるため、各ワーカーGoroutineは、メインGoroutineがチャネルから受信するまで、結果を送信する際にブロックされます。processWorkItemsがエラーのために早期に返された場合、受信ループは終了し、残りのすべての送信者Goroutineは永遠にブロックされます。この例は、Uberのプロダクションサービスを含む実際のGoプログラムで見られる一般的な間違いの象徴です。新しいGoroutineリークプロファイラーを使用して、これらのリークをどのように見つけることができるかを見てみましょう。
Goroutineリークプロファイラーによるデバッグ
プロファイルは、runtime/pprofパッケージを通じてgoroutineleakプロファイルタイプとして、またはnet/http/pprofパッケージで定義されたプロファイルハンドラをインストールすることで利用できます。サービスにnet/http/pprofが既に設定されている場合は、他に何もする必要はありません!プロファイルは、ハンドラがインストールされているホストとポートの/debug/pprof/goroutineleakエンドポイントで自動的に収集できるようになります。
並行性のバグをコンテキストに置き、net/http/pprofパッケージを設定しましょう。これにより、自分で試すことができます!
code
package main
import (
"errors"
"log"
"net/http"
_ "net/http/pprof"
"time"
)
type workItem int
type workResult int
func processWorkItem(w workItem) (workResult, error) {
time.Sleep(10 * time.Millisecond)
if w == 5 {
return 0, errors.New("simulated error")
}
return workResult(w * 2), nil
}
type result struct {
res workResult
err error
}
func processWorkItems(ws []workItem) ([]workResult, error) {
ch := make(chan result)
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err}
}()
}
var results []workResult
for range len(ws) {
r := <-ch
if r.err != nil {
return nil, r.err
}
results = append(results, r.res)
}
return results, nil
}
func main() {
// Start pprof server
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Repeatedly trigger the leak
for {
items := []workItem{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
_, err := processWorkItems(items)
if err != nil {
log.Printf("Error processing items: %v", err)
}
time.Sleep(time.Second)
}
}
code
上記のプログラムをビルドし、実行します。
code
$ go build -o leaky
$ ./leaky
code
プロファイルの収集
プログラムがリークを蓄積し始めるまで、それほど時間はかかりません。その後、http://localhost:6060/debug/pprofでWeb UIを使用して表示できます。または、curlを使用してGoroutineリークプロファイルを収集し、go tool pprofで調べることができます。
code
$ curl http://localhost:6060/pprof/goroutineleak > leak.prof
$ go tool pprof leak.prof
Type: goroutineleak
Time: 2026-03-01 13:19:49 UTC
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) list processWorkItems
Total: 116
ROUTINE ========================
main.processWorkItems.func1 in .../main.go
0 116 (flat, cum) 100% of Total
. .
31: go func() {
. .
32: res, err := processWorkItem(w)
116 33: ch <- result{res, err}
. .
34: }()
code
プロファイルは、ch <- result{res, err}(33行目)でリークしたGoroutineを示しており、原因となっている操作を特定します。注目すべきは、プログラムが実行されている時間が長いほど、リークしたGoroutineの数が増加することです。
リークの修正
このリークは、chにバッファを与えることで簡単に修正できます。
code
ch := make(chan result, len(ws))
code
これにより、processWorkItemsの早期リターンが発生した場合でも、すべての作業項目Goroutineがブロックせずにメッセージを送信できるようになります。このセクションでは、さらに多くの実世界の例をリストします。
実装
このセクションは、リーク検出がGoroutineリークプロファイラーの内部でどのように機能するかに関心のある方向けです。パフォーマンスオーバーヘッドと制限に関する詳細については、このセクションに進んでください。
コアコンセプト
まず、初期の観察から始めましょう。Goroutineが、他のGoroutineがアクセスできない(この場合はメモリ内の参照を介して)並行プリミティブでブロックされている場合、それは明らかにリークしています。これはすでに強力な手がかりであり、Goroutineがリークしていない場合の定義に一般化できます。このプロパティをliveness(生存性)と呼びます。livenessを帰納的なプロパティとして正式に定義します。
Goroutineはライブ(生存している)です。もし:
* 並行プリミティブによってブロックされていない場合、または
* それをブロックしている並行プリミティブの少なくとも1つが、別のライブGoroutineによって参照されている場合。
自明なケースでは、ブロックされていないGoroutineは明らかにリークしていません。帰納的なケースでは、基盤となる...