Why Cannot Close a Go Channel Twice
·Every time someone asks why closing a channel twice panics, the real question is usually about ownership.
A channel has exactly two states: open and closed. close(ch) flips a
bit in the runtime and wakes up any blocked receivers. That’s it.
There’s no “half-closed” state, no reference counting, no reopening.
After it’s closed:
- receives keep working (draining buffer, then zero value)
- sends panic
- closing again panics
The important part is that closing mutates shared state. It’s not a local operation. If two goroutines both think they’re responsible for closing the same channel, one of them is wrong.
Go chooses to panic instead of silently ignoring the second call. That’s intentional. If double-close were ignored, ownership bugs would quietly survive until something worse happened.
Why There’s No isClosed
You’ll sometimes see people wishing for:
if !isClosed(ch) {
close(ch)
}
Even if such a function existed, it wouldn’t solve anything. Between the check and the close, another goroutine could close it. The race is still there.
The only real solution is serialization.
Single Producer: Easy Case
If one goroutine produces values, it owns the close. That’s the clean model:
func producer(ch chan<- int) {
defer close(ch)
for i := 0; i < 10; i++ {
ch <- i
}
}
Receivers just range:
for v := range ch {
fmt.Println(v)
}
Nothing controversial here.
Multiple Producers: Where It Breaks
This is wrong:
go func() {
ch <- 1
close(ch)
}()
go func() {
ch <- 2
close(ch) // panic, eventually
}()
Neither goroutine has exclusive ownership of the lifecycle.
The usual pattern is to separate production from closure:
var wg sync.WaitGroup
ch := make(chan int)
producer := func(id int) {
defer wg.Done()
ch <- id
}
wg.Add(2)
go producer(1)
go producer(2)
go func() {
wg.Wait()
close(ch)
}()
The close happens exactly once, after all producers finish. Ownership is explicit.
“Just Ignore the Second Close”
You can’t.
Not safely.
If you need “at most once” semantics, you serialize the mutation:
var once sync.Once
func safeClose(ch chan struct{}) {
once.Do(func() {
close(ch)
})
}
Now the close has a clear synchronization boundary.
Data Completion vs Cancellation
Closing a data channel signals “no more values.” Cancellation is different.
For cancellation, use context:
select {
case ch <- value:
case <-ctx.Done():
return
}
ctx.Done() is a channel that the runtime guarantees will be closed
once. It avoids the ownership ambiguity entirely.
The panic on double close isn’t about being strict. It’s about forcing you to define who owns the channel’s lifecycle.
If more than one goroutine might close it, the design isn’t finished.