Goのゴルーチンで「panic: send on closed channel」を修正する方法

intermediate🔷 Go2026-03-21| Go 1.18+、Linux / macOS / Windows、ゴルーチンとチャネルを使用するすべてのプログラム

Error Message

panic: send on closed channel
#ゴルーチン#チャネル#並行処理#パニック

エラーの内容

Goプログラムが次のエラーでクラッシュします:

goroutine 1 [running]:
main.main()
        /home/user/app/main.go:25 +0x68
goroutine 6 [running]:
main.worker()
        /home/user/app/main.go:14 +0x44
panic: send on closed channel

あるゴルーチンが、すでにクローズされたチャネルに値を送信しようとしました。クローズされたチャネルからの読み取りは問題ありません — Goはゼロ値を返して処理を続けます。送信は別の話です。クローズされたチャネルへの送信は、例外なく即座にパニックを引き起こします。

根本原因

このパニックを確実に引き起こす4つのパターンがあります:

  • あるゴルーチンがチャネルをクローズしている最中に、別のゴルーチンがそのチャネルに送信しようとする。
  • チャネルが2回クローズされる(こちらもpanic: close of closed channelでパニックになります)。
  • コーディネーターがclose(ch)を呼び出した後も、ワーカーゴルーチンが動き続ける。
  • タイムアウトやキャンセルによって、プロデューサーが送信中にパイプラインが停止する。

最小の再現例 — 一目瞭然です:

package main

func main() {
    ch := make(chan int)
    close(ch)   // ここでチャネルをクローズ
    ch <- 1     // panic: send on closed channel
}

実際のコードベースではより微妙なケースが起きます。ゴルーチンが起動する前にクローズが発生するパターンです:

package main

import (
    "fmt"
    "sync"
)

func main() {
    ch := make(chan int, 10)
    var wg sync.WaitGroup

    // 早まったクローズ — ワーカーはまだ起動していない
    close(ch)

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            ch <- id // panic: send on closed channel
        }(i)
    }

    wg.Wait()
    fmt.Println("done")
}

応急処置:パニックからのリカバリー(本番環境には不向き)

GoにはisClosed()チェックが組み込まれていません。defer recover()で送信をラップしてパニックを吸収することはできます:

func safeSend(ch chan int, val int) (closed bool) {
    defer func() {
        if r := recover(); r != nil {
            closed = true
        }
    }()
    ch <- val
    return false
}

動きはします。しかしこれは本質的な問題 — チャネルの所有権が不明確なこと — の上に貼られた絆創膏に過ぎません。設計そのものを直しましょう。

恒久的な修正:送信者がクローズを担う

Goの暗黙のルール:**チャネルをクローズするのは送信者だけで、受信者は行わない。**すべての送信が終わった後に、一度だけクローズします。

パターン1:シングルプロデューサー

1つのゴルーチンがチャネルを完全に所有します。送信してからクローズします。シンプルで明快です:

package main

import "fmt"

func produce(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch) // 安全:このゴルーチンだけが送信する
}

func main() {
    ch := make(chan int)
    go produce(ch)

    for v := range ch {
        fmt.Println(v)
    }
}

パターン2:複数プロデューサー — コーディネーター + sync.WaitGroup

5つのゴルーチンが1つのチャネルに送信する場合、どのゴルーチンもクローズを担うべきではありません。その役割をコーディネーターに委ねましょう:

package main

import (
    "fmt"
    "sync"
)

func main() {
    ch := make(chan int, 10)
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            ch <- id // 安全:チャネルはまだオープン
        }(i)
    }

    // このゴルーチンが5つの送信者を待ち、それからクローズする
    go func() {
        wg.Wait()
        close(ch)
    }()

    for v := range ch {
        fmt.Println(v)
    }
}

パターン3:context.Contextによるキャンセル

300msのデッドラインなどでワーカーを早期停止させたい場合はcontext.Contextを使いましょう。受信側からデータチャネルをクローズするのは避けてください:

package main

import (
    "context"
    "fmt"
    "time"
)

func worker(ctx context.Context, ch chan<- int, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d: stopping\n", id)
            return
        case ch <- id:
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
    defer cancel()

    ch := make(chan int, 5)

    go worker(ctx, ch, 1)
    go worker(ctx, ch, 2)

    for {
        select {
        case v := <-ch:
            fmt.Println("received", v)
        case <-ctx.Done():
            fmt.Println("main: done")
            return
        }
    }
}

各ワーカーはctx.Done()を監視し、自分でexitします。すべての送信者が抜けるまでチャネルはオープンのまま — パニックは発生しません。

パターン4:doneチャネル(停止シグナル)

contextを使わない場合は、専用のdoneチャネルが同じ役割を果たします。クローズすることで、すべてのワーカーに一斉に停止シグナルをブロードキャストします:

package main

import (
    "fmt"
    "sync"
)

func worker(done <-chan struct{}, ch chan<- int, id int, wg *sync.WaitGroup) {
    defer wg.Done()
    for {
        select {
        case <-done:
            return
        case ch <- id:
        }
    }
}

func main() {
    ch := make(chan int, 10)
    done := make(chan struct{})
    var wg sync.WaitGroup

    for i := 0; i < 3; i++ {
        wg.Add(1)
        go worker(done, ch, i, &wg)
    }

    // 10個の値を受け取る
    for i := 0; i < 10; i++ {
        fmt.Println(<-ch)
    }

    // 全ワーカーにシグナルを送り、終了を待ってからデータチャネルをクローズ
    close(done)
    wg.Wait()
    close(ch) // 安全:すべての送信者がすでに終了している
}

テストでのパニック検出

旧コードがパニックを起こしていたことと、修正が正しく機能することを確認するリグレッションテストを書きましょう:

func TestSafeSend(t *testing.T) {
    ch := make(chan int)
    close(ch)

    // 修正後はパニックしてはいけない
    closed := safeSend(ch, 42)
    if !closed {
        t.Error("expected closed=true")
    }
}

レースディテクターを早期に活用する

-raceフラグはpanic: send on closed channelを直接検出できませんが、その原因となるデータレースを浮き彫りにします。本番環境でパニックが発生する前に実行しておきましょう:

go run -race main.go
go test -race ./...

20以上のゴルーチンを持つ実際のプロジェクトでは、レースディテクターが手動レビューでは見逃すタイミングバグを日常的に発見します。CIパイプラインに組み込みましょう。

検証

修正を適用したら、このチェックリストを確認してください:

  • go test -race ./...を実行 — レース条件がゼロであることを確認。
  • go run main.goで数回ストレステスト — パニック出力がないことを確認。
  • contextを使った場合、キャンセル時にワーカーがクラッシュではなく"stopping"をログ出力することを確認。
  • go vet ./...を実行して、実行前に静的なチャネルの誤用を検出。

まとめ

  • クローズするのは送信者で、受信者は行わない — このルール1つでチャネルパニックのほとんどを防げます。
  • 複数の送信者がいる場合は、コーディネーターゴルーチン + sync.WaitGroupを使って最後の送信後にクローズします。
  • 早期シャットダウンが必要な場合は、データチャネルのクローズではなく、context.Contextまたはdoneチャネルを使いましょう。
  • 常に**-race**フラグでテストを実行してください。ローカルでは問題なく見えるタイミングバグも、負荷がかかると表面化します。

Related Error Notes