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 つ。
よくある原因
- close されない channel: ワーカーが
for v := range chで待っているのに、送信側がclose(ch)を忘れている - context 未配線: 親が
cancel()を呼んでも、goroutine 内で<-ctx.Done()を select していなければ離脱できない - ticker の作り直し: ループ内で
<-time.Tick(d)を書くと毎周新しい Ticker が生まれ、GC されない - 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)
}
}range は close 後に自動で抜ける。
送信者が複数いる場合は 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.Tick は Stop が呼べないため、長寿命のループでは 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 で増加傾向を観察する。