Go言語のGoroutineリーク防止策とContextキャンセルのパターン

Go Backend Concurrency Performance
結論

バッファ付きチャネル(make(chan T, 1))か select<-ctx.Done() を監視してリークを防ぎます。

// Go公式仕様:context.Context による安全な Goroutine キャンセルパターン
package main

import (
	"context"
	"time"
)

function fetchData(ctx context.Context) (string, error) {
	// ⭕ バッファ数 1 を確保し、呼び出し側が先に早期 return しても送信ブロックを起こさない
	ch := make(chan string, 1)

	go func() {
		// 重い処理...
		res := "data"
		select {
		case ch <- res:
		case <-ctx.Done(): // ⭕ 親がキャンセルされた場合、即座に Goroutine を終了
			return
		}
	}()

	select {
	case res := <-ch:
		return res, nil
	case <-ctx.Done():
		return "", ctx.Err() // タイムアウト等のエラー
	}
}

Go公式仕様:なぜ Goroutine リークが発生するのか?

Go公式ドキュメント(go.dev/doc/diagnostics)の規定通り、Goのガベージコレクタ(GC)は、チャネル(Channel)の送信・受信待ちで無限ブロック(Blocked)された Goroutine のスタック領域および内部変数を回収(Reclaim)できません

呼び出し側がエラー等で途中で return してしまい、取り残された Goroutine が終了条件を満たせない場合、その Goroutine は永久にメモリ内に居座り続けます。


実際に起こる障害:非バッファチャネルと早期 return によるメモリリーク

// ❌ 事故:Goroutine リークを起こすダメな実装
func badFetch() string {
	ch := make(chan string) // バッファなしチャネル

	go func() {
		ch <- "result" // ❌ 受信者がいなくなると永久にブロック! (Goroutine Leak)
	}()

	if someCondition {
		return "default" // 早期 return すると、上の Goroutine が死ぬまでブロックされる
	}
	return <-ch
}

このリクエストが1秒間に数百回呼ばれるWebサービスで発生すると、数万個の Goroutine がメモリを食い潰し、Goプロセスが OOM (Out of Memory) で突然クラッシュする大障害 が発生します。


予防手順

  1. 1回限りの結果受信(Goroutine から1つだけ値を受け取る)の場合、必ず make(chan T, 1) のように容量1以上のバッファ付きチャネルを使用する
  2. 長時間動作する非同期処理には context.WithTimeout または context.WithCancel を伝播させる
  3. 子 Goroutine 内のチャネル送信部で select を使い、case <-ctx.Done(): のキャンセル通知を必ず監視して脱出させる