Sending on a Go Channel You Never Close Is Fine, Until It Isn't
Plenty of Go code never calls close on its channels, and plenty of that code is correct. Plenty of other code hangs forever for exactly the same omission. The difference is not about the channel at all; it is about who is waiting on it.
Close is a message, not a free()
A channel is an ordinary heap object. When nothing can reach it any more, the garbage collector reclaims it, closed or not. There is no file descriptor, no OS handle, nothing that needs releasing.
Closing a channel only tells receivers that no more values are coming. That is its entire job. If no receiver needs that news, skipping it costs you nothing:
func square(n int) int {
ch := make(chan int, 1)
go func() { ch <- n * n }()
return <-ch
}
One value, one receive, no loop. Adding close(ch) here would be noise.
Where it breaks: a receiver that waits for the end
The first failure is for range over a channel. The loop only ends when the channel is closed and drained, so a sender that forgets to close leaves the receiver waiting for a "that's all" it will never get:
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
}()
for v := range ch {
fmt.Println(v)
}
}
This prints 0, 1, 2 and then dies with fatal error: all goroutines are asleep - deadlock!. The fix is one line in the sender, defer close(ch), placed in the goroutine that does the sending.
Note that the runtime only noticed here because every goroutine was stuck. In a real server, with HTTP handlers and timers alive, the same bug produces no crash and no message, just a goroutine that never returns.
Quick detour: why a blocked goroutine is never collected
Hang on, if the GC collects unreachable channels, why doesn't it collect a channel nobody else references? Because the goroutine blocked on it is not garbage. A goroutine's stack is a root, and that stack references the channel, so both stay alive. The collector will not reap a blocked goroutine, however hopeless its situation. That is why leaks of this kind just grow until something notices memory.
The mirror image: a sender with no receiver
Here closing would not help at all, because the problem is on the sending side:
func fastest(urls []string) string {
ch := make(chan string)
for _, u := range urls {
go func() { ch <- fetch(u) }()
}
return <-ch
}
The function takes the first answer and returns. Every other goroutine then blocks on ch <- for ever, because the unbuffered channel has no receiver left. Call this per request and you leak len(urls) - 1 goroutines each time.
Closing cannot fix this; sending on a closed channel panics. Instead, give the sends somewhere to land:
ch := make(chan string, len(urls))
Now every sender completes, drops its value into the buffer, and exits. When fastest returns, the channel and its leftover values become unreachable and the GC takes them. Still never closed, and now correct.
A quick way to decide
- Does any receiver loop with
range, or wait for the "ok" value fromv, ok := <-ch? Then something must close it. - Does a receiver exit early (first result wins, context cancelled, error)? Then senders need a buffer or a
selectonctx.Done(). - Is it exactly one send and one receive? Do not bother closing.
If you have several senders, nobody can safely close from the sending side alone. Count them with a sync.WaitGroup and close from one extra goroutine that waits:
go func() {
wg.Wait()
close(out)
}()
The receiver can then range over out and finish cleanly.
Finding the leak when you have one
Blocked goroutines show up clearly in a goroutine dump. Hit /debug/pprof/goroutine?debug=2 if you have net/http/pprof enabled, and look for many identical stacks parked in chan send or chan receive, with a long wait time. The line number in the stack points at the exact send or receive nobody will ever match.
The rule I keep is short: close when a receiver needs to know the stream ended, and only from the sender. For everything else, make sure no goroutine can be left holding the other end.