Goのgoroutineリーク対策—pprofとGo 1.26新機能の使い分け

Goのgoroutineリーク対策—pprofとGo 1.26新機能の使い分け | mohablog

goroutineリーク対策の核は、突き詰めると「起動する前に止め方を決めておく」に尽きると思います。ネットワーク呼び出しやチャネル送受信が絡むと設計で詰まる場面は多いのですが、方針だけは最初に固めておくと後で効いてきます。

Go 1.26で runtime/pprofgoroutineleak プロファイルが追加されたのをきっかけに、自分の小さなプロジェクトで goroutine 管理を一度棚卸ししました。そこで整理した検出と設計のパターンをまとめます。動作確認は Go 1.22 の通常ビルドと、Go 1.26 の GOEXPERIMENT=goroutineleakprofile ビルドの両方で取っています。

前提知識として goroutine と channel の基本形は Go言語の並行処理入門:goroutineとchannelの使い方を完全解説 にまとめてあるので、そちらも合わせて読むと入りやすいはずです。

目次

goroutineリークが本番で表面化する瞬間

goroutine は初期スタックが 2KB 前後と軽く、数千個走らせても平気という感覚でつい雑に扱いがちです。ところが本番に出すと、静かに1つずつ積み上がってメトリクスが右肩上がりになり、ある日GCが暴れ始める——というパターンを何度か見てきました。まずは「なぜリークするのか」と「どう気づくのか」を押さえておきます。

goroutineは軽量だがGCされない

よく誤解されるのですが、実行中の goroutine はGCの対象になりません。スタック自体はもちろん、スタックやクロージャ経由で参照しているヒープ上のオブジェクトも回収されません。runtime の仕様上、goroutine が終了するまでそのメモリは解放されない設計になっています。

つまり「ブロックしたまま戻ってこない goroutine」は、スタックの数KBだけでなく、そこに紐づくバッファやリクエスト構造体まで掴み続けます。リクエスト1件あたり十数KB掴んだまま、毎秒1個ずつリークすれば、1時間で数十MB、1日で1GB超えも十分あり得る数値です。

メモリ肥大と応答遅延の因果

リークが進むと、最初に現れるのはメトリクスの異常です。Prometheusで収集している go_goroutines が階段状に増えていく、ヒープ使用量が下がらない、GC間隔が短くなる、といった兆候が出ます。アプリ側のレイテンシ悪化として体感できる頃には、プロセス全体がかなり重くなっているケースがほとんどですね。

実行例として、リーク有無で runtime.NumGoroutine() を観察するとこうなります。

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    for i := 0; i < 5; i++ {
        leakyWorker()
        time.Sleep(200 * time.Millisecond)
        fmt.Printf("goroutines=%d\n", runtime.NumGoroutine())
    }
}

func leakyWorker() {
    ch := make(chan int)
    go func() {
        ch <- 1 // 受信者がいないので永遠にブロック
    }()
}
goroutines=2
goroutines=3
goroutines=4
goroutines=5
goroutines=6

明らかに1周ごとに1本ずつ増えていますね。本番でも構造は同じで、トラフィックの量だけ増えていきます。

リークを生みやすいアンチパターン3選

遭遇頻度の高い3パターンを、NGコードとその症状から見ていきます。どれも「止め方を決めていない」ことが根っこの原因です。

受信者不在のチャネル送信

先ほどの例と同型ですが、現場ではもう少し複雑な形で混ざります。

// NG: 早期returnで受信者がいなくなる
func fetchWithTimeout(url string) (Result, error) {
    ch := make(chan Result)
    go func() {
        ch <- heavyHTTP(url) // unbufferedなので受信者必須
    }()

    select {
    case r := <-ch:
        return r, nil
    case <-time.After(1 * time.Second):
        return Result{}, fmt.Errorf("timeout") // ここでreturnするとgoroutineが取り残される
    }
}

タイムアウトで return した瞬間、バックグラウンドの goroutine は ch <- ... で永久ブロックします。修正は単純で、チャネルをバッファ1にするか、context.Context で送信側も止めます。

// OK: バッファ1にすれば受信者が消えても送信は完了する
ch := make(chan Result, 1)

contextを持たない無限ループ

バックグラウンドのポーリング処理にありがちな形ですね。

// NG: 外から止められない
func pollLoop() {
    t := time.NewTicker(5 * time.Second)
    for range t.C {
        fetchJobs()
    }
}

グレースフルシャットダウンが効かず、プロセス終了まで走り続けます。テストで起動したまま終わらない、という地味な副作用にもつながります。

タイムアウトなしのネットワーク呼び出し

意外と見落としがちなのが、http.Client{} をデフォルトのまま使うケースです。

// NG: Timeoutが0 = 無制限
client := &http.Client{}
go func() {
    resp, err := client.Get(url)
    if err != nil {
        return
    }
    defer resp.Body.Close()
    _, _ = io.ReadAll(resp.Body)
}()

相手サーバーがTCPコネクションを切らずに黙り込むと、この goroutine は永久待ちになります。最低でも Timeout を入れるか、http.NewRequestWithContext で context.Context を渡して外部から止められるようにします。

contextとselectで安全に止める基本形

アンチパターンの共通点は「停止シグナルを受け取る口がない」こと。逆に言えば、context.Context を受け取って select で捌くという型に揃えるだけで、大半は片付きます。Goで構築するマイクロサービス向けコンテキストの設計パターン でも触れた通り、context は「キャンセル伝播の唯一の経路」として使うのが前提です。

ctx.Done()を必ずselectに入れる

先ほどの無限ループを context 対応にします。

// OK: ctx.Done()で抜けられる
func pollLoop(ctx context.Context) error {
    t := time.NewTicker(5 * time.Second)
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-t.C:
            if err := fetchJobs(ctx); err != nil {
                log.Printf("fetch: %v", err)
            }
        }
    }
}

呼び出し側は context.WithCancelsignal.NotifyContext を使って、シグナル受信時に cancel() を呼べばこのループも綺麗に止まります。

errgroupで複数goroutineを束ねる

複数ワーカーを並列で動かすときは、golang.org/x/sync/errgroup が便利です。WaitGroup と違って、どれか一つがエラーを返すと全体の context がキャンセルされる点が効きます。

import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) error {
    g, gctx := errgroup.WithContext(ctx)
    for _, u := range urls {
        u := u // Go 1.22以降はループ変数のコピー不要だが安全側で残す
        g.Go(func() error {
            return fetchOne(gctx, u)
        })
    }
    return g.Wait()
}

g.Wait() が返った時点で全ての goroutine は終了しているので、「起動したまま忘れる」事故が起きにくい設計になっています。

pprofで今あるgoroutineを眺める

設計で予防した上で、動いているプロセスの状態を観察する仕組みも必要です。標準の net/http/pprof を入れるだけで、HTTP経由で goroutine のスタック一覧が取れます。

net/http/pprofの最小構成

アンダースコアimportでハンドラが登録されるので、別goroutineで listen するだけです。

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

func init() {
    go func() {
        _ = http.ListenAndServe("localhost:6060", nil)
    }()
}

本番で開ける場合は、localhost 以外に晒さないか、認証付きリバースプロキシの裏に置くのがおすすめです。公開ポートにそのまま並べるとスタックトレースが漏れます。

debug=1とdebug=2の読み分け

取得は curl で十分です。

curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=1' | head -n 20
goroutine profile: total 142
120 @ 0x42b678 0x43a94a 0x440020 0x4712dc 0x471f2f
#    0x471f2e    main.pollLoop+0x2e    /app/main.go:34
22 @ 0x42b678 0x43a94a 0x45ce7a 0x7010f1
#    0x7010f0    net/http.(*conn).serve+0x310  /usr/local/go/src/net/http/server.go:1930

同じスタックトレースを持つgoroutineが120本もいる、というのが一目で見えます。ここまで偏っていれば、まずリークの有力候補です。

パラメータの違いは次の通りです。

debug値内容用途
0(省略時)バイナリ形式のプロファイルgo tool pprof に流し込んで可視化
1件数付きの簡易テキスト偏り検知・ざっくり現状把握
2全goroutineの完全スタック原因特定・個別デバッグ

debug=2 は出力が大きくなるので、まず debug=1 で偏りを見てから、特定のスタックが気になったら debug=2 を取る、という手順がスムーズです。

Go 1.26のgoroutineleakプロファイル

pprof の goroutine は「今ブロックしている goroutine」を見せてくれますが、それがリーク(絶対に起きない待ち)なのか正常な待機(起きうる待ち)なのかは判断してくれません。Go 1.26 で追加された goroutineleak プロファイルは、この判定をruntime側で行ってくれる新しい仕組みです。

GOEXPERIMENTで有効化する手順

2026年4月時点では実験的機能で、ビルド時に環境変数を渡す必要があります。

GOEXPERIMENT=goroutineleakprofile go build -o app ./cmd/app
./app
# 別ターミナルから
curl -s 'http://localhost:6060/debug/pprof/goroutineleak?debug=2'
goroutine 21 [chan send, 12 min] (leaked):
main.fetchWithTimeout.func1()
    /app/main.go:23 +0x4a
created by main.fetchWithTimeout in goroutine 1
    /app/main.go:22 +0x7c

注目は末尾の (leaked) マーカー。通常のブロッキング goroutine と違い、runtime が「この goroutine はもう絶対に進めない」と判定したものだけが付きます。ノイズの多い本番環境で「本当に直すべきもの」にいきなり絞り込めるのが強みです。

検知できるケース・できないケース

公式の実装思想として、「参照が誰からも届かず、進行不可能なもの」だけがリーク判定されます。具体的には次のような違いが出ます。

  • 検知される: 送信側だけ残ったunbufferedチャネル、他goroutineがロックを解放せず永遠に待つMutex、クローズされないまま参照が失われたチャネル受信
  • 検知されない: タイマー待ち(time.Sleepやtime.After)、sync.Condで条件を待つもの、まだ外部から起こしうる条件を待っているセマフォ取得

つまり「ロングポーリングのように意図的に長期ブロックしている goroutine」はリーク扱いされません。ここは pprof の goroutine と使い分けポイントになります。

goleak vs 標準goroutineleak どう使い分けるか

同じ名前で紛らわしいのですが、uber-go/goleak と Go 1.26 の goroutineleak プロファイルは別物です。目的も判定ロジックも違うので、まとめて比較しておきます。

項目uber-go/goleakGo 1.26 goroutineleak
種別サードパーティライブラリ標準runtimeの実験的機能
有効化importするだけGOEXPERIMENT必須・要再ビルド
主な用途テスト終了時の残存goroutine検知稼働中プロセスの診断
判定ロジックテスト前後のスタックトレース差分runtime内部の「進行不可能」判定
除外設定goleak.IgnoreTopFunctionで柔軟に調整判定ロジック固定(2026/04時点)
成熟度v1.3.0 安定版実験的

テスト層は goleak が現実解

CI でリークを検知したい場合、現時点で一番導入しやすいのは uber-go/goleak です。TestMain に1行入れるだけで、テスト実行後に残っている goroutine を失敗扱いにしてくれます。

package myapp_test

import (
    "testing"

    "go.uber.org/goleak"
)

func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)
}

実行結果はこんな感じ。

goleak: Errors on successful test run: found unexpected goroutines:
[Goroutine 8 in state chan send, with example_test.startJob.func1 on top of the stack:
goroutine 8 [chan send]:
example_test.startJob.func1()
        /app/example_test.go:17 +0x3a
]
FAIL    example

どの goroutine がどこに取り残されているかが一目で出るので、修正箇所の特定が速いです。除外したい goroutine(database/sqlのコネクション管理など)は goleak.IgnoreTopFunction でスキップできます。

本番の深掘りは goroutineleak

一方、プロダクション稼働中のプロセスで「いま実際にリークしているもの」を抜き出したいときは、Go 1.26 の goroutineleak プロファイルが向きます。通常の goroutine プロファイルだと、正常な待機中のものも大量に混ざるので、数百本の中からリークだけを拾うのは骨が折れます。runtime の判定を通した (leaked) マーカー付きの出力は、その選別を肩代わりしてくれる感じですね。

現時点では実験的機能なので、本番全台で常用するのは尚早だと思っています。ステージングや特定の診断用インスタンスで、通常のpprofと併用して使うのが現実的な落とし所かなと。

まとめ

goroutine のリーク対策は、設計・検出・運用の3層で組み立てると整理しやすいです。今回の要点を振り返ります。

  • goroutine は軽量だが、実行中はGCされないので掴んだメモリは解放されない
  • アンチパターンは3つ: 受信者不在のチャネル送信、contextを持たない無限ループ、タイムアウトなしのネットワーク呼び出し
  • 基本形は context.Context を受け取り、selectctx.Done() を必ず入れる。複数goroutineは errgroup で束ねる
  • pprof の /debug/pprof/goroutine は、debug=1 で偏りを探し、debug=2 で詳細スタックを取るのが基本フロー
  • Go 1.26 の goroutineleak プロファイルは GOEXPERIMENT=goroutineleakprofile で有効化。(leaked) マーカー付きでリークだけ抽出できる
  • テスト層では uber-go/goleak、稼働中の診断では標準 goroutineleak と使い分ける

「起動する前に止め方を決める」という習慣が全てのベースになります。そこさえ守れていれば、検出ツールはあくまで保険として機能する形になるので、運用もだいぶ楽になるはずです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次