エラーの内容
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**フラグでテストを実行してください。ローカルでは問題なく見えるタイミングバグも、負荷がかかると表面化します。

