Channels: synchronization points, bounded queues, and the deadlocks between them
An unbuffered channel is a synchronization point; a buffered one is a bounded queue. Axioms: nil blocks forever, closed reads zero values, send on closed panics. select, fan-out/fan-in, worker pools, context through pipelines — and the deadlock from a forgotten close.
The nightly export job died at 03:12 with the runtime’s bluntest message: fatal error: all goroutines are asleep - deadlock! The pipeline was textbook: one producer reading rows into a channel, eight workers transforming them, one collector writing the output file. Three weeks earlier someone had added an error path to the workers — on a bad row, the worker logged and returned. That night, all eight workers hit bad rows and exited. The producer kept sending into a channel with capacity 64; sixty-four sends later it blocked forever, the collector blocked reading from workers that no longer existed, and the runtime — seeing every goroutine parked with no possible waker — killed the process. The buffer hadn’t prevented the deadlock. It had only delayed it by exactly 64 rows, long enough to pass every test with ten-row fixtures.
Unbuffered is a rendezvous, buffered is a queue
An unbuffered channel (make(chan T)) has no storage: a send blocks until a receiver is ready, and the value is handed directly from one goroutine’s stack to the other’s. That makes it a synchronization point — when ch <- v returns, you know a receiver has the value, and the send is guaranteed to happen-before the receive completes. A buffered channel (make(chan T, n)) is a bounded FIFO queue: sends complete immediately while there is capacity, block when full; receives block when empty. The semantics differ in kind, not degree: an unbuffered channel transfers and synchronizes; a buffered channel mostly just transfers, telling you nothing about when the other side ran.
The capacity is a tuning knob with a sharp edge. Capacity 0 couples producer and consumer beat-for-beat. A small buffer (the size of a real burst) absorbs jitter and decouples their schedules. An “infinite” buffer (make(chan T, 1_000_000)) is how you convert visible backpressure into invisible memory growth — the producer never feels the consumer falling behind until the process OOMs. Senior rule: choose 0 unless you can say which measured burst the buffer absorbs, and treat a full buffer as signal (the consumer is too slow), not as an obstacle to bypass with a bigger number.
The channel axioms
Four behaviors define every channel bug you will ever debug:
- Send to a nil channel blocks forever. Same for receive. A
var ch chan intnever made — or a map lookup that returned the zero value — produces a goroutine parked permanently. Deliberately useful insideselect: setting a case’s channel to nil disables that case. - Receive from a closed channel returns immediately with the element type’s zero value; the two-value form
v, ok := <-chreportsok == false. Afor range chloop exits when the channel is closed and drained — which is whyrangeover a channel nobody closes hangs forever. - Send to a closed channel panics. Always, immediately, unrecoverable in the sense that it means a protocol violation: two parties believed they owned the channel.
- Close of a closed (or nil) channel panics. Close is a one-shot broadcast, not a cleanup you sprinkle defensively.
Together these four axioms define the ownership rule and explain every mysterious deadlock or panic you’ll encounter: a nil channel silently absorbs, a closed one silently drains, and a second writer silently corrupts — three different failure modes, all invisible without the axioms. The sender closes, never the receiver, and exactly one party closes. Close means “no more values will ever arrive” — it is a message from the producer. With multiple producers, none of them can safely close; the idiom is a sync.WaitGroup over the producers and one supervising goroutine that calls close after wg.Wait().
// Fan-out / fan-in with correct close discipline and cancellation.
func process(ctx context.Context, rows <-chan Row) <-chan Result {
out := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < 8; i++ { // fan-out: 8 workers share one input channel
wg.Add(1)
go func() {
defer wg.Done()
for r := range rows { // exits when rows is closed and drained
select {
case out <- transform(r):
case <-ctx.Done(): // unblocks the send if downstream is gone
return
}
}
}()
}
go func() { // exactly one closer, after ALL senders are done
wg.Wait()
close(out)
}()
return out // fan-in: the consumer ranges over a single merged stream
}A for range loop reads from a channel fed by three producer goroutines. The loop never exits, even after all producers return. The most likely cause?
select: composing channels, and the default escape hatch
select waits on several channel operations and runs one that is ready, choosing uniformly at random among ready cases — randomness is deliberate, preventing starvation of a always-also-ready case. With a default clause it becomes non-blocking: try the operation, fall through if it would block. That is the building block for try-send patterns — shed load instead of queuing it:
select {
case events <- e: // delivered
default:
droppedTotal.Inc() // buffer full: drop and count, don't block the hot path
}The honest tradeoff: default converts blocking into data loss, which is right for metrics and wrong for money. The middle option is a timeout case (case <-time.After(d) — or a reusable time.Timer in hot loops, since time.After allocates a timer that lives until it fires).
Cancellation flows down the pipeline
A pipeline is only as robust as its exit story. The standard contract: every stage takes a context.Context, and every send sits inside a select with case <-ctx.Done(). Cancellation flows downward from the caller; close flows forward with the data; and goroutines unblock no matter which side disappears. Skip the select on send and you recreate the Hook: a downstream stage that stops reading leaves every upstream sender parked forever — the leak from the previous lesson, manufactured wholesale. The runtime only declares deadlock! when every goroutine is parked; in a real server with one live HTTP listener, your deadlocked pipeline just sits there silently, indistinguishable from idle except in the goroutine profile.
A producer sends to a channel with capacity 64; its eight consumers all crashed. The producer's tests (10 items) pass, but production (millions of items) hangs. Why did the buffer not prevent the hang?
▸lesson.inset.deep-dive
Worker pools are fan-out with a budget. The pool size bounds concurrent work (and therefore memory, downstream connections, and CPU contention); the input channel’s capacity bounds queued work; and the two together define the system’s backpressure. A pool of 8 workers over an unbuffered input means at most 8 items in flight and the producer feels every stall instantly. Adding capacity 100 lets the producer run ahead through bursts but adds up to 100 items of latency to the oldest queued item. What you must never do is grow either number reactively under load — that is how a slow database turns into a slow database plus ten thousand goroutines holding request buffers. Decide the budget when you design the pool, expose the queue depth as a metric, and shed or reject above it.
- 01State the four channel axioms and the ownership rule they force.
- 02Why does a buffered channel not protect a pipeline from a dead consumer, and what does?
Channels come in two semantically different kinds. Unbuffered channels are rendezvous points: the send blocks until a receiver takes the value hand-to-hand, so completion of the send synchronizes the two goroutines. Buffered channels are bounded FIFO queues that decouple schedules while capacity lasts; their fullness is backpressure information, and replacing a full buffer with a huge one merely trades visible blocking for invisible memory growth. Four axioms govern everything: nil channels block forever (and disable select cases on purpose), closed channels read zero values with ok false and terminate range once drained, sends on closed channels panic, and double-close panics. Those panics force the ownership rule — the sender closes, exactly once, and with multiple producers a supervisor closes after a WaitGroup confirms all of them finished. select composes channel operations, picking uniformly at random among ready cases; with default it becomes a non-blocking try-send for load shedding, and with a timeout case it bounds waits. Fan-out spreads one input channel across a worker pool whose size budgets concurrency, fan-in merges results into one stream, and cancellation flows opposite to the data: every send in every stage selects on ctx.Done, so a vanished downstream cannot park upstream senders forever. The nightly-job failure is the canonical trap — workers that exit on error while the producer keeps sending into a capacity-64 channel; the buffer delays the hang by exactly 64 rows, tests with small fixtures never reach it, and the runtime announces deadlock only when the whole process is parked, while in a real server the same bug hides as a quiet goroutine leak. Now when you see a pipeline that hangs under production load but passes all tests, count the workers, check for a missing select ctx.Done() on sends, and verify one supervisor closes the fan-in channel — the channel axioms tell you exactly which step was skipped.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.