goroutine設計レビュー完全ガイド:競合・リーク・設計崩壊を読み解く

Go言語における goroutine は極めて強力な並行処理機構である。
「簡単に並列化できる」という利便性ゆえ、レビューアーにとっては危険信号を読み解く目が問われる領域でもある。

goroutineとは

goroutineはGoにおける軽量スレッド。goキーワードを付けて関数やクロージャを起動するだけで並行処理が開始される。
しかし、終了待機・エラー伝播・キャンセル制御は自動では行われず、実装者が全て設計しなければならない。

本記事では以下の構成でレビューアーの実務判断スキル養成を目的に整理していく。

  • 良い実装例の技術背景解説(指摘コメントなし)
  • 問題のある実装例とレビュー指摘
  • チェックリスト
  • ケーススタディ
  • 最後にレビュー観点まとめ

1. 正しいgoroutine設計例とその設計意図

まずは良い実装例から、どのような観点で「問題がない」と判断できるのかを整理する。

type Worker struct {
  jobs    <-chan int
  results chan<- int
  ctx     context.Context
}

func (w *Worker) Start() {
  go func() {
    for {
      select {
      case job, ok := <-w.jobs:
        if !ok {
          return
        }
        result := w.process(job)
        w.results <- result
      case <-w.ctx.Done():
        return
      }
    }
  }()
}

func (w *Worker) process(job int) int {
  time.Sleep(100 * time.Millisecond)
  return job * 2
}

技術的ポイント整理

  • 終了保証の明示性
    • w.ctx.Done()w.jobsclose検知により確実にループ終了できる設計。
  • チャネル駆動型の責務分離
    • jobの投入・処理・結果返却がチャネル単位で明確に分離。
  • ループ内クロージャ変数の正当性
    • ループ変数を直接使わず、クロージャキャプチャの競合を生んでいない。
  • キャンセル設計の伝播
    • context.Contextを外部から渡して統制可能。
  • エラーを握りつぶさない構造
    • process()が失敗を返さない構造。必要なら返却型変更で通知設計可能。

この例のように"ライフサイクルが完全に統制されたgoroutine"が設計上の理想である。


2. 問題のある実装とレビュー指摘集

2-1. fire-and-forgetの安易な使用

go log.Println("処理開始")
@Reviewer
非同期化の目的が不明確です。fire-and-forget型ですが、ログの順序保証や出力漏れが発生する可能性があります。同期的に処理するか、明示的に非同期目的を整理してください。

2-2. WaitGroupによる「意味のない同期化」

var wg sync.WaitGroup

for _, id := range ids {
  wg.Add(1)
  go func(id int) {
    defer wg.Done()
    process(id)
  }(id)
}

wg.Wait()
@Reviewer
全ての処理が完了するまで強制的に待機する構造になっています。同期化による性能向上の効果が薄くなる場合があるため、逐次実行と比較検討してください。

2-3. context未伝播

func Handle(ctx context.Context) {
  go worker()
}
@Reviewer
`ctx` が子goroutineに渡されていません。キャンセル設計が機能不全になります。workerに`ctx`を渡してキャンセル制御を統一してください。

2-4. クロージャ変数競合

for _, user := range users {
  go func() {
    fmt.Println(user.Name)
  }()
}
@Reviewer
クロージャ内でループ変数`user`を参照しています。goroutine起動タイミングで競合が生じる可能性があります。ループ内で`user := user`とスコープ固定してください。

2-5. goroutineリークパターン

go func() {
  <-ctx.Done()
}()
@Reviewer
`ctx.Done()`待機だけでは、親での`WithCancel()`実装が漏れるとリークします。必ず親のキャンセル設計が存在するか確認してください。

2-6. エラー握りつぶし

go func() {
  err := external()
  if err != nil {
    log.Println("fail")
  }
}()
@Reviewer
ローカルでエラーログ出力のみ行われています。上位へのエラー通知手段がないため、channel通知やcallback設計で回収ルートを整備してください。

3. チェックリスト:レビュー観点整理

観点 確認内容
終了保証 context伝播/チャネルclose判定
エラー処理 上位層通知設計有無
クロージャ競合 ループ変数捕捉の競合有無
同期制御 WaitGroup・select設計妥当性
リーク対策 永続待機ブロック排除
責務分離 異層混在排除
実質目的 本当に非同期が必要か設計意図確認

4. ケーススタディ:文法上は正しいが設計破綻する例

func Watch(ctx context.Context, ch <-chan string) {
  go func() {
    for {
      select {
      case msg := <-ch:
        log.Println("received:", msg)
      case <-ctx.Done():
        return
      }
    }
  }()
}
@Reviewer
`ch`がcloseされた場合の処理が考慮されていません。またこの関数が複数回呼ばれるとgoroutineが積み上がる構造です。close検知追加およびgoroutine統制設計を検討してください。

5. context終了伝播モデル

UML Diagram

6. 実運用で多発するgoroutine系障害

事例A: HTTPクライアント呼び出しの非同期乱用

go func() {
  resp, err := http.Get(url)
  if err == nil {
    cache.Set(key, resp)
  }
}()
@Reviewer
I/O処理はgoroutine大量生成するとソケット逼迫・FD上限超過が発生します。Pool化・同時実行数制限・timeout設計を併用してください。

7. ワーカーパターンの安定設計

type Job struct{ ID int }

func worker(jobs <-chan Job) {
  for job := range jobs {
    process(job)
  }
}

func main() {
  jobs := make(chan Job)
  for i := 0; i < 5; i++ {
    go worker(jobs)
  }
}
  • goroutine数が制御可能
  • Channel閉じが終了判定になる
  • FD/CPU等のリソース管理が安定する

8. 代替設計検討の視点

goroutine 代替可能設計例
軽量通知系 time.AfterFunc
スケジューラ cronライブラリ等
ワーカープール channel駆動設計
非同期I/O context+http.Client

9. レビューアー育成用思考テンプレート

  • 本当に非同期が要るのか?
  • goroutineの起動数を制御可能か?
  • キャンセル手段が存在するか?
  • 外部資源(I/O/FD/メモリ)の開放保証は?
  • エラーは握り潰されていないか?
  • 他層責務との境界は明確か?

10. 最後に:レビューとは「設計の物語」を読む訓練

goroutineレビューは構文ではなく設計を読む行為である。
非同期化の背後にある意図・制約・妥協点を読解し、事故の芽を摘む。
これこそが現場のレビューアーに必要な「構造的読解力」である。

UML Diagram