15.5 Closing Channels: Signaling Completion
Alright, let’s talk about closing channels. This is where you move from simply passing data around to signaling that the party’s over and no more data is coming. It’s one of the most powerful, and most frequently bungled, concepts in Go’s concurrency model. Get this right, and your programs become elegant and robust. Get it wrong, and you’ll have goroutines leaking like a sieve or panicking all over the place.
The rule is simple, but absolute: you should only ever close a channel from the sender’s side, never the receiver’s. Why? Because closing a channel is a broadcast signal to all receivers that no more values will be sent on this channel. The receiver has no business making that declaration; only the sender knows when it’s done sending. If you close a channel from a receiver, you’re lying to the other receivers. The real sender might still be trying to send, causing a panic. Just don’t do it.
Think of a channel like a tap. The sender is the water company controlling the flow. The receiver is you, filling your glass. You wouldn’t walk to the water treatment plant and shut off the main valve for everyone just because your glass is full, would you? That’s what closing from the receiver is like.
The Mechanics of a Closed Channel
So what actually happens when a channel is closed? Let’s break it down for receivers.
- Any receive operation on a closed channel immediately gets the channel’s zero value. No blocking, no waiting. It just returns
0for anint,falsefor abool,""for astring, etc. - There’s a second, optional return value. This is a
boolthat tells you whether the channel is open (true) or closed (false). This is your signal to know if you got real data or just the zero value because the channel is closed.
Here’s the classic way to use this in a loop:
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch) // Sender closes it
// The 'for' loop relies on this behavior to exit
for {
value, ok := <-ch
if !ok {
fmt.Println("Channel is closed, we're done here.")
break
}
fmt.Printf("Got value: %d\n", value)
}
// Output:
// Got value: 1
// Got value: 2
// Channel is closed, we're done here.
But there’s a much nicer syntactic sugar for this exact pattern.
The For-Range Shortcut
This value, ok := <-ch pattern is so common that Go built a shortcut right into the for range loop. A for range loop over a channel will automatically iterate until the channel is closed and then exit cleanly. It’s the idiomatic and preferred way to consume all values from a channel until completion.
ch := make(chan string)
go func() {
ch <- "Hello"
ch <- "World"
close(ch) // Again, sender closes
}()
// So much cleaner.
for value := range ch {
fmt.Println(value)
}
fmt.Println("Channel closed, loop ended.")
// Output:
// Hello
// World
// Channel closed, loop ended.
This is beautiful, reliable, and the number one reason you close channels. It provides a clean, synchronized way for a sender to tell a receiver, “I’m done, you can stop waiting now.”
The Panic You Absolutely Must Avoid
Here is the cardinal sin of channel operations, the one that will cause an instant runtime panic:
Sending on a closed channel.
ch := make(chan int)
close(ch)
ch <- 42 // panic: send on closed channel
This is why the “only the sender closes” rule is so critical. If any other part of your program could close the channel, the sender becomes a ticking time bomb. The solution is to give control of the channel’s lifecycle to a single owner, usually the goroutine that is the sender.
A Common Pattern: Using a Done Channel
How do you tell a sender to stop sending and close the channel? You signal it with another channel. This is a fantastically common pattern for graceful shutdown.
func sender(ch chan<- int, done <-chan struct{}) {
defer close(ch) // Guarantees we close the channel when we return
for i := 0; i < 5; i++ {
select {
case ch <- i:
fmt.Printf("Sent %d\n", i)
case <-done:
fmt.Println("Told to stop, exiting.")
return // This triggers the 'defer close(ch)'
}
}
}
func main() {
ch := make(chan int)
done := make(chan struct{})
go sender(ch, done)
// Let's receive a couple values then tell it to stop
for i := range ch {
fmt.Printf("Received %d\n", i)
if i == 2 {
fmt.Println("That's enough, telling sender to close up shop.")
close(done) // Signal the sender to stop
break // Break out of our receive loop
}
}
// Drain the remaining values from the channel until it's closed.
// The sender got our signal and closed it, so this loop will exit.
for range ch {}
fmt.Println("Main done.")
}
In this pattern, the done channel is the off-switch. The sender goroutine owns the ch channel and is the only one responsible for closing it (via the defer statement). The main function just requests a shutdown by closing done; it doesn’t risk a panic by touching ch itself. This is clean, safe, and how most production-grade Go concurrency is structured.
The takeaway: use closing as a broadcast signal of completion. Let the sender own it, use for range to consume it, and use a separate channel to request that the sender do its job. It seems like a dance at first, but once you get the rhythm, it’s the most robust way to coordinate your goroutines.