できない.dev

Go の goroutine がリークして終了しない

受信側 goroutine が読み続けるべき channel を送信側が close せず、受信側が永久にブロックする状態が goroutine リーク。
context で停止信号を渡すか、送信終了側で `close(ch)` を呼んで for-range を抜けさせる。

#goroutine#leak#channel#context#concurrency

公開:

要約

Go の goroutine リークは、ほぼ全てが 「読み手が永久に待つ channel」「停止信号を受け取れない無限ループ」 が原因。
プロセスが終わるまで増え続けるとメモリと CPU を蝕む。
基本対策は 送信完了側で close(ch) を呼ぶ ことと、context.Context を渡して select で離脱可能にする ことの 2 つ。

よくある原因

  1. close されない channel: ワーカーが for v := range ch で待っているのに、送信側が close(ch) を忘れている
  2. context 未配線: 親が cancel() を呼んでも、goroutine 内で <-ctx.Done() を select していなければ離脱できない
  3. ticker の作り直し: ループ内で <-time.Tick(d) を書くと毎周新しい Ticker が生まれ、GC されない
  4. WaitGroup の数ずれ: wg.Add(1) の前に goroutine を起動するとレースになり、Done の数が足りなくて Wait() が戻らない

解決策

1. 送信完了で close する

func produce(ch chan<- int) {
    defer close(ch)
    for i := 0; i < 10; i++ {
        ch <- i
    }
}
 
func consume(ch <-chan int) {
    for v := range ch {
        fmt.Println(v)
    }
}

rangeclose 後に自動で抜ける。
送信者が複数いる場合は sync.WaitGroup で同期し、最後の送信者が close を呼ぶ。

2. context で停止可能にする

func worker(ctx context.Context, ch <-chan int) {
    for {
        select {
        case <-ctx.Done():
            return
        case v, ok := <-ch:
            if !ok {
                return
            }
            handle(v)
        }
    }
}

context パッケージ を読み、子 goroutine には常に ctx を渡すパターンを徹底する。
キャンセル伝播のパイプライン全体は Go Concurrency Patterns: Pipelines and cancellation に網羅的に書かれている。

3. Ticker はループ外で作る

ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
    select {
    case <-ctx.Done():
        return
    case <-ticker.C:
        do()
    }
}

time.TickStop が呼べないため、長寿命のループでは time.NewTicker を使う。

4. リーク検出

before := runtime.NumGoroutine()
// 検証したい処理
after := runtime.NumGoroutine()
if after > before {
    t.Errorf("goroutine leak: %d -> %d", before, after)
}

テスト前後で runtime.NumGoroutine() を比較するヘルパーを CI に仕込むと、リーク混入 PR を早期に検知できる。
本番では net/http/pprof/debug/pprof/goroutine で増加傾向を観察する。

この記事は役立ちましたか?