Buffered Channels, Closing, and the Rules of the Drain
How a buffered channel decouples sender from receiver until the buffer fills, what close actually signals, the panic you get for sending on a closed channel, the comma-ok receive that tells a closed channel from a live one, draining with range, and the rule that only the sender closes. Compiled and run against Go 1.26.5.
The unbuffered channel from the last chapter forces a rendezvous: send and receive meet at the same instant, and neither side runs ahead. Sometimes that lockstep is exactly what you want. Often it is too strict. If a producer generates a burst of values and the consumer is momentarily busy, you would like the producer to keep working rather than block on every single item. A buffered channel gives the channel room to hold values, which loosens the coupling between the two sides. This chapter covers buffering, then the other half of a channel’s life: closing it to signal that no more values are coming, and the several rules and panics that surround doing so correctly.
A buffer decouples sender from receiver
You add a buffer by giving make a capacity:
ch := make(chan int, 2) // holds up to 2 values before a send blocks
The rule changes in a precise way. A send on a buffered channel blocks only when the buffer is full; a receive blocks only when the buffer is empty. Between those extremes, sender and receiver operate independently. A send drops its value into the buffer and returns immediately as long as there is room, so the producer can get ahead of the consumer by up to the buffer’s capacity.
package main
import "fmt"
func main() {
ch := make(chan int, 2) // capacity 2
fmt.Printf("len=%d cap=%d\n", len(ch), cap(ch))
ch <- 10 // does not block: buffer has room
ch <- 20 // does not block: buffer now full
fmt.Printf("after two sends: len=%d cap=%d\n", len(ch), cap(ch))
// a third send here (ch <- 30) WOULD block: buffer is full.
fmt.Println("received", <-ch)
fmt.Println("received", <-ch)
fmt.Printf("after draining: len=%d cap=%d\n", len(ch), cap(ch))
}
len=0 cap=2
after two sends: len=2 cap=2
received 10
received 20
after draining: len=0 cap=2
Two sends complete with no receiver in sight, something an unbuffered channel could never allow. cap(ch) is the fixed capacity, and len(ch) is how many values are currently sitting in the buffer, so len climbs to 2 as we fill and falls back to 0 as we drain. A third send, with the buffer full and nobody receiving, would block exactly the way an unbuffered send does. And notice the ordering: 10 comes out before 20. A channel is a FIFO queue, first in, first out.
A word of caution, because buffering is easy to over-apply. A buffer is not a performance setting you sprinkle on to make things faster, and picking a size to “avoid blocking” is usually a sign you have not thought about backpressure. Blocking is often the point: it is how a fast producer is told to slow down and match a slow consumer. Reach for a buffer when you have a concrete reason (a known burst size, decoupling a producer from a consumer with predictable timing), not as a reflex. The default choice is unbuffered.
close: signalling that no more values are coming
Sending values is half the story. The other half is telling the receiver when the stream is over. That is what close does:
close(ch)
Closing a channel is a broadcast: it announces that no more values will ever be sent. It does not throw away buffered values, and it does not stop receivers from draining what is already there. It changes what happens after the last value is received, and it interacts with sends in a way that has a hard edge.
Send on a closed channel panics
Once a channel is closed, sending on it is a programming error, and Go treats it as one:
package main
func main() {
ch := make(chan int, 1)
close(ch)
ch <- 1 // panic: send on closed channel
}
panic: send on closed channel
goroutine 1 [running]:
main.main()
/.../c2-03-sendclosed/main.go:6 +0x3a
This is a panic, not the fatal deadlock error from the previous chapter, which means it is catchable with recover in principle. But if you are catching “send on closed channel” you have already lost; the panic is telling you your code sent after it promised, via close, that it would not. The right response is to fix the ownership of the channel so this cannot happen, and the rule further down handles exactly that. The process exits with status 2.
Receiving from a closed channel: the comma-ok form
Receiving from a closed channel is not an error. It is defined and useful. Once a closed channel’s buffer is drained, every further receive returns immediately with the element type’s zero value. The question is how a receiver distinguishes a real zero that was genuinely sent from the zero that means “closed and empty.” The answer is the two-value receive, the comma-ok form:
v, ok := <-ch
ok is true if v came from an actual send, and false if the channel is closed and drained. Watch it flip:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
// Buffered values still drain out AFTER close.
v1, ok1 := <-ch
v2, ok2 := <-ch
// Now empty AND closed: zero value, ok == false.
v3, ok3 := <-ch
fmt.Printf("recv1: v=%d ok=%t\n", v1, ok1)
fmt.Printf("recv2: v=%d ok=%t\n", v2, ok2)
fmt.Printf("recv3: v=%d ok=%t\n", v3, ok3)
}
recv1: v=10 ok=true
recv2: v=20 ok=true
recv3: v=0 ok=false
The two buffered values come out even though the channel was closed before we received them, each with ok true. Closing does not discard what is already in the buffer. The third receive finds the channel empty and closed, so it returns 0 (the zero value for int) with ok false, and it does so immediately without blocking. That last property is what makes a closed channel a broadcast: any number of receivers can keep receiving from it forever, and every one of them gets the zero-and-false signal.
range drains until closed
Writing the comma-ok loop by hand for every consumer would be tedious, so Go gives you range over a channel. It receives values one at a time and stops cleanly when the channel is closed and drained:
package main
import "fmt"
// generate sends n values then closes. The SENDER closes, signalling "no more".
func generate(out chan<- int, n int) {
for i := 1; i <= n; i++ {
out <- i * i
}
close(out)
}
func main() {
ch := make(chan int)
go generate(ch, 5)
// range receives until the channel is closed and drained, then stops.
for v := range ch {
fmt.Println("got", v)
}
fmt.Println("range ended: channel closed and drained")
}
got 1
got 4
got 9
got 16
got 25
range ended: channel closed and drained
The loop body runs once per received value and never sees the final zero-and-false; range consumes that signal itself and exits. This is the standard producer-consumer shape: a goroutine generates values and closes when done, and the consumer ranges. It is also why closing matters so much. Without the close inside generate, the range would drain the five values and then block forever waiting for a sixth that never comes. In a program where that blocked goroutine is main, you would get the deadlock fatal from the last chapter; forget to close a channel someone is ranging over and a hang is the usual result.
Only the sender closes, and only once
Two rules keep all of this safe, and they are worth stating as absolutes.
Only the sender closes a channel, never the receiver. Close is a promise that no more values are coming, and only the code doing the sending is in a position to make that promise. If a receiver closed the channel, a sender might still be about to send, and it would then panic with “send on closed channel.” So closing is the sender’s job and the sender’s alone. When multiple goroutines send on one channel, no single one of them can safely close it, and coordinating the close becomes a real design problem, usually solved by a sync.WaitGroup and a separate closer goroutine, which we build later in the series.
Close a channel exactly once. Closing an already-closed channel panics:
package main
func main() {
ch := make(chan int)
close(ch)
close(ch) // panic: close of closed channel
}
panic: close of closed channel
goroutine 1 [running]:
main.main()
/.../c2-03-closetwice/main.go:6 +0x30
Same panic family as send-on-closed, same status-2 exit, same underlying message: your ownership of the channel is muddled. A channel has one closer, and it closes once. If you find yourself guarding a close with “did someone already close this?”, the structure is wrong; step back and give the channel a single, clear owner responsible for closing it.
A final subtlety many people carry a wrong belief about: you do not have to close every channel. Close is a signal, not a cleanup step, and channels are garbage-collected like any other value once they are unreachable. You close a channel when a receiver needs to know the stream has ended, and not otherwise. A channel used for a single request-response, or one whose lifetime simply ends when the program does, needs no close at all. Close for the signal, not out of habit.
Final thoughts
A buffered channel holds up to its capacity before a send blocks, decoupling producer from consumer over short bursts, but blocking is backpressure and the default should stay unbuffered. close broadcasts that no more values will come: a send afterward panics with “send on closed channel,” a receive afterward returns the zero value with ok == false, and range drains a channel and stops the moment it closes. The discipline that keeps this from blowing up is ownership: only the sender closes, exactly once, and not every channel needs closing at all. With channels understood in both timings and both directions, we can ask the next question, which is what to do when a goroutine needs to wait on several channels at once and act on whichever is ready first.
Next: select: waiting on many channels — the statement that lets one goroutine juggle multiple channels.
Comments