Go Channels and Select
"Do not communicate by sharing memory; instead, share memory by communicating." — Effective Go
"This close is effectively a broadcast signal to the senders." — Go Concurrency Patterns: Pipelines and cancellation
"Channels orchestrate; mutexes serialize." — Go Proverbs
A channel is a typed conduit with exact, specified rules: who may send, who may close, what a receive returns after a close, and when an operation blocks. Most channel bugs are not subtle — they are one of those rules violated. This skill owns channel mechanics. Goroutine lifetime (who starts and stops the goroutine on each end) is owned by go-concurrency-goroutines; cancellation via ctx.Done() is owned by go-context; lock-based sharing is owned by go-sync-primitives.
1. The Rules, and Where Each Is Stated
Every rule below is a hard fact of the language spec or established guidance, not a preference. The owning section holds the depth.
| Rule | The mechanic | Source |
|---|---|---|
| Declare direction in signatures | chan<- T send-only, <-chan T receive-only — documents and enforces the role |
"A channel may be constrained only to send or only to receive by assignment or explicit conversion" (Spec — Channel types) |
| Only the sender closes | Close says "no more values," from the side that produces them | "stages close their outbound channels when all the send operations are done" (Pipelines) |
| Send on closed panics | Never close from a receiver or twice | "A send on a closed channel proceeds by causing a run-time panic" (Spec — Send); "Sending to or closing a closed channel causes a run-time panic" (Spec — Close) |
| Receive from closed is not an error | It yields the zero value with ok == false |
"A receive operation on a closed channel can always proceed immediately, yielding the element type's zero value" (Spec — Receive) |
| Unbuffered = rendezvous | Send and receive complete together | "communication succeeds only when both a sender and receiver are ready" (Spec — Channel types) |
| Buffer size 1 or none | Any larger buffer needs justification | "Channels should usually have a size of one or be unbuffered ... Any other size must be subject to a high level of scrutiny" (Uber) |
| nil channel blocks forever | Used to disable a select case | "a select with only nil channels and no default case blocks forever" (Spec — Select) |
| default makes select non-blocking | Otherwise select blocks until a case is ready | "if there is a default case, that case is chosen" (Spec — Select) |
2. Declare Channel Direction in Signatures
A bidirectional chan T in a parameter says nothing about the function's role. A directional type does: "A channel may be constrained only to send or only to receive by assignment or explicit conversion" (Spec — Channel types) — chan<- float64 "can only be used to send," <-chan int "can only be used to receive" (Spec — Channel types). The compiler then enforces the role, and the signature documents it. A bidirectional channel converts to a directional one implicitly at the call site.
// WRONG — both params are chan T; nothing stops produce from receiving or consume from closing
func produce(ch chan int) { /* could accidentally <-ch or close from the wrong side */ }
func consume(ch chan int) { /* could accidentally close(ch) — a receiver must never close */ }
// RIGHT — direction is part of the contract; misuse is a compile error
func produce(out chan<- int) { out <- 1; close(out) } // send-only: can send and close
func consume(in <-chan int) { for v := range in { use(v) } } // receive-only: cannot close(in)
close(in) where in is <-chan int does not compile — "It is an error if ch is a receive-only channel" (Spec — Close) — so directionality turns the "only the sender closes" rule (§3) into a checked guarantee.
3. Who Closes: The Sender, Once, and Never the Receiver
Closing is not cleanup and it is not a free()-style "I'm done with this." It is a one-time announcement to receivers that no more values are coming: close(ch) "records that no more values will be sent on the channel" (Spec — Close). So it belongs to whoever sends: "stages close their outbound channels when all the send operations are done" (Pipelines).
Closing is a broadcast. Every blocked receiver unblocks at once, because "a receive operation on a closed channel can always proceed immediately" (Pipelines); "This close is effectively a broadcast signal to the senders" (Pipelines). That is the whole point of the done-channel pattern (§6) — one close wakes N goroutines.
Two corollaries, both enforced by the runtime:
- A receiver must never close. It does not own the send side, and a later send from the real owner would panic (§4).
- With multiple senders, no single sender may close — a second send (or second close) panics: "Sending to or closing a closed channel causes a run-time panic" (Spec — Close). Coordinate shutdown elsewhere (a
sync.WaitGroup/errgroup) and close once after all senders stop — that coordination is goroutine lifetime, owned bygo-concurrency-goroutines.
A channel need not be closed at all: close only to signal "no more values" to a ranging receiver; an unclosed channel is garbage-collected normally.
4. Send on Closed Panics; Receive From Closed Returns zero, ok=false
These two rules are asymmetric, and the asymmetry is the source of most close-related bugs.
Sending on a closed channel is fatal: "A send on a closed channel proceeds by causing a run-time panic" (Spec — Send). There is no recover-and-continue idiom for it; the fix is to never let a send race a close (§3).
Receiving from a closed channel is safe and silent — it hands back the zero value. So the bare v := <-ch cannot tell "real zero that was sent" from "channel closed." Use the comma-ok form: "The value of ok is true if the value received was delivered by a successful send operation to the channel, or false if it is a zero value generated because the channel is closed and empty" (Spec — Receive).
// WRONG — once ch is closed, this loops forever handing 0 to process(); the zero is not real data
for {
v := <-ch // closed channel returns 0, ok discarded
process(v)
}
// RIGHT — comma-ok distinguishes a sent value from a close
for {
v, ok := <-ch
if !ok {
break // channel closed and drained
}
process(v)
}
// RIGHT (idiomatic) — range receives until the channel is closed, then stops
for v := range ch {
process(v)
}
for v := range ch is the same rule packaged: it pulls values until the channel is closed and drained, then ends. Ranging a channel that is never closed blocks forever once the buffer empties — a "why does my range over a channel never end" hang is almost always a missing close on the sender side.
5. Unbuffered Is Synchronization; Buffered Is Decoupling
The capacity argument to make is a semantic choice, not a tuning knob.
An unbuffered channel is a rendezvous: "communication succeeds only when both a sender and receiver are ready" (Spec — Channel types). The send and the receive happen together, so it also synchronizes: "Unbuffered channels combine communication—the exchange of a value—with synchronization—guaranteeing that two calculations (goroutines) are in a known state" (Effective Go). Reach for unbuffered first — it gives you a handoff guarantee.
A buffered channel decouples sender from receiver up to the buffer's size: "communication succeeds without blocking if the buffer is not full (sends) or not empty (receives)" (Spec — Channel types). That is useful for a known, bounded count (a semaphore limiting concurrency, or collecting exactly N results), but it removes the handoff guarantee.
The size you pick must mean something: "Channels should usually have a size of one or be unbuffered ... Any other size must be subject to a high level of scrutiny. Consider how the size is determined, what prevents the channel from filling up under load and blocking writers" (Uber).
// WRONG — a big buffer chosen to make a deadlock "go away"; it only delays the block and hides backpressure
results := make(chan Result, 1000) // why 1000? what happens at 1001?
// RIGHT — unbuffered: the receiver is guaranteed to have the value before the sender proceeds
results := make(chan Result)
// RIGHT — buffer sized to a real bound: exactly len(jobs) results, never more
results := make(chan Result, len(jobs))
A buffer chosen to paper over "send blocks forever" hides a missing receiver — see the deadlock case in references/common-mistakes.md.
6. chan struct{} for Pure Signals
When a channel carries no data — only the fact that an event happened — its element type should be struct{}, which occupies zero bytes. The done/quit pattern is the canonical use: closing it broadcasts "stop" to every receiver (§3). The pipelines blog uses exactly this — done := make(chan struct{}) — and notes that closing it unblocks all waiters at once (Pipelines).
// RIGHT — a signal-only channel; the value never matters, only "did it fire"
done := make(chan struct{})
go func() {
defer close(done) // broadcast completion to every <-done waiter
work()
}()
<-done // blocks until work() finishes and close(done) fires
Prefer close(done) (a broadcast, readable any number of times) over sending one value per waiter (which you would have to count). Use struct{} not bool or int: the type itself says "this is a signal, there is no payload." For cancellation specifically, you usually want ctx.Done() rather than a hand-rolled done channel — owned by go-context.
7. select Chooses a Ready Case; default Makes It Non-Blocking
A select "chooses which of a set of possible send or receive operations will proceed" (Spec — Select). The rules are precise:
- One ready case: it runs.
- Several ready cases: "a single one that can proceed is chosen via a uniform pseudo-random selection" (Spec — Select) — you cannot rely on priority order.
- No ready case, with a
default: "if there is a default case, that case is chosen" (Spec — Select) — the select does not block. - No ready case, no
default: "the select statement blocks until at least one of the communications can proceed" (Spec — Select).
So default is the non-blocking switch. Use it for a "try" operation — but never inside a tight for with nothing else, or you get a busy-loop spinning the CPU (see references/common-mistakes.md).
// Non-blocking send: drop the value if no receiver is ready, instead of blocking
select {
case ch <- v:
// delivered
default:
// nobody ready; skip rather than block
}
An empty select{} has no cases that can ever proceed, so it blocks the goroutine forever — occasionally used in a main that should park while background goroutines run, but usually a sign something is wrong.
8. The nil-Channel Trick: Disable a select Case
A nil channel never communicates: "Receiving from a nil channel blocks forever" (Spec — Receive) and "A send on a nil channel blocks forever" (Spec — Send). In a select, a case whose channel is nil can never be chosen — "communication on nil channels can never proceed" (Spec — Select). Setting a channel variable to nil therefore removes its case from the select, dynamically, without restructuring the loop.
The classic use: when an input channel is drained (closed), set it to nil so the loop stops selecting on it but keeps serving the other cases.
// RIGHT — once `in` is closed, nil it out so this select stops spinning on the always-ready closed case
func merge(in <-chan int, out chan<- int, done <-chan struct{}) {
for in != nil {
select {
case v, ok := <-in:
if !ok {
in = nil // disable this case; a closed channel is "always ready" and would busy-loop
continue
}
out <- v
case <-done:
return
}
}
}
Without the in = nil, a closed in is permanently ready and <-in keeps firing with the zero value — the busy-loop from §4 wearing a select. Niling the case is the idiomatic disable.
9. select for Timeout and Cancellation
select composes a channel operation with an escape hatch. The two escape hatches are a timer and a context:
// Timeout: race the work against a deadline
select {
case v := <-work:
use(v)
case <-time.After(2 * time.Second):
return errTimeout
}
// Cancellation: race the work against the caller giving up
select {
case v := <-work:
use(v)
case <-ctx.Done():
return ctx.Err()
}
Prefer ctx.Done() for anything request-scoped or cancellable — it propagates and composes — and reserve time.After for a genuine local timeout. The ctx.Done() channel, ctx.Err(), and the context.Canceled / context.DeadlineExceeded values are owned by go-context; this skill owns only the select mechanic that consumes them. (A time.After in a hot loop allocates a timer per iteration that lives until it fires — for a repeated timeout reset a time.Timer, owned by go-time.)
10. Who Suffers When Channels Are Done Badly
The victim is never the author at write time:
- The on-call engineer paged at 3am by a deadlock: every goroutine blocked sending to a channel whose only receiver returned early, the process wedged with no error and no log.
- The teammate chasing a worker that "sometimes" processes a phantom zero record — a
v := <-chthat lost the, okand read a closed channel's zero value as data (§4). - The whole service, crashed by a
close of closed channelpanic because shutdown let two senders both close — a rule the spec makes fatal precisely so it cannot be ignored (Spec — Close).
"Channels orchestrate; mutexes serialize" (Go Proverbs) is also a warning: a channel reached for where a sync.Mutex would do (guarding one shared field) buys a concurrency primitive's full failure surface — deadlocks, leaks, panics — to do a lock's job. When the answer is "protect this state," not "hand off this value," that is go-sync-primitives.
11. Routing to Related Skills
go-idiomatic-discipline— the policy root; this skill is the channel-mechanics depth behind its "don't fight Go."go-concurrency-goroutines— who owns the channel: goroutine lifetime, who starts/stops each end, closing once after N senders stop,WaitGroup/errgroup. The §3 coordination lives here.go-context—ctx.Done()as the cancellation channel in §9, andcontext.Canceled/DeadlineExceededas values.go-sync-primitives— when aMutexbeats a channel (§10): guarding shared state vs handing off values.go-race-and-memory-model— why a channel send/receive establishes happens-before, and how to detect the races a misused channel leaves behind (go test -race).go-iterators-rangefunc— the channel-vs-iterator choice for producing a sequence.go-time—time.Timer/Tickerlifetime behind the §9 timeout.
12. Reference Files
High-frequency channel and select anti-patterns in LLM-generated Go, each with wrong/right code and citations:
references/common-mistakes.md
Source provenance for every claim in this skill:
references/sources.yaml