Go's Nil Channel Trick: Disabling a select Case at Runtime
A select statement's cases are fixed at compile time. You cannot append a case to a select while a goroutine is running, and you cannot remove one either. That sounds like a real limitation if you're writing a loop that needs to stop listening on one channel once it's drained, or only wants to attempt a send once it actually has something to send. The fix Go programs reach for isn't a dynamic select at all: it's exploiting what a nil channel does.
What a nil channel actually does
Sending or receiving on a nil channel blocks forever. Not "returns an error", not "panics": the goroutine just parks and never wakes up, because nothing can ever make a nil channel ready. The language spec is blunt about it: a nil channel is never ready for communication.
Inside a bare send or receive that's usually a bug: var ch chan int; <-ch deadlocks the goroutine outright. But inside a select, a case that can never become ready is exactly as good as a case that isn't there. The runtime evaluates every channel operand in the statement once, and a case whose channel is nil simply never gets picked. The other cases behave completely normally. So if you keep a channel in a variable and set that variable to nil, you've turned its case off without touching the shape of the select at all.
The problem this solves: fan-in from a channel that closes
Take a classic fan-in of two producers into one output:
func merge(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
select {
case v, ok := <-a:
if !ok {
return
}
out <- v
case v, ok := <-b:
if !ok {
return
}
out <- v
}
}
}()
return out
}
This looks reasonable and is wrong in a way that only shows up under load: the moment either a or b closes, the whole merge stops, silently dropping whatever the other channel still had queued up. Fine for a toy example, a real bug in anything that treats the two inputs as independent streams.
The obvious next attempt is to continue instead of returning:
case v, ok := <-a:
if !ok {
continue
}
out <- v
That keeps the other channel alive, but introduces a different problem. A closed channel is always ready and always returns immediately with the zero value and ok == false. Once a closes, the case v, ok := <-a branch is ready on every single iteration of the loop, so select picks it (or races to pick it) constantly. The goroutine spins, burning a full core doing nothing useful, for as long as b keeps the loop alive. On a busy server this is the kind of thing that shows up as one goroutine pegging a CPU in a profile, with no allocation and no obvious cause, weeks after the code that introduced it shipped.
The nil channel trick fixes both problems at once:
func merge(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil
continue
}
out <- v
case v, ok := <-b:
if !ok {
b = nil
continue
}
out <- v
}
}
}()
return out
}
When a closes, the receive case sets the local variable a to nil. From that point on, case v, ok := <-a can never fire again: it just sits there, permanently not-ready, costing nothing. The loop naturally keeps draining b until it closes too, at which point both variables are nil, the loop condition is false, and the goroutine exits cleanly. No busy spin, no dropped data.
It works on sends too
The same idea flips around for output. Say you want to coalesce a fast producer into a slow consumer, always handing over the most recent value rather than queuing every one:
func coalesce(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
var pending int
var send chan<- int
for in != nil || send != nil {
select {
case v, ok := <-in:
if !ok {
in = nil
continue
}
pending = v
send = out
case send <- pending:
send = nil
}
}
}()
return out
}
send starts nil, so the send case is disabled until there's actually a value worth offering. As soon as a value arrives on in, send is pointed at out, arming that case. Once the value is delivered, send goes back to nil, disarming it until the next value shows up. Notice the variable being nilled is typed chan<- int, a plain assignable channel variable, not the underlying channel itself: out never becomes nil, only the local reference used to gate the case does.
Why not a default case instead
You could try to get similar behaviour with a non-blocking attempt and a default:
select {
case v := <-in:
pending, have = v, true
default:
}
if have {
select {
case out <- pending:
have = false
default:
}
}
This compiles and sort of works, but now the outer loop has to run continuously to re-check both selects, which means another busy spin unless you bolt on a separate blocking wait somewhere. default is for "don't block, try once and move on"; it's the wrong tool when what you actually want is "block efficiently until one of several conditions becomes true, some of which don't currently apply". A nil channel lets the scheduler do that blocking for you at zero cost, which is precisely what select is designed to do well.
The one thing worth double-checking whenever you see this pattern: make sure every path that nils a channel variable is matched by a loop condition or exit check that accounts for it, as in for a != nil || b != nil above. It's easy to nil a variable, forget to also update the thing deciding whether to keep looping, and end up with a goroutine that blocks forever on a select where every single case is now nil. That one doesn't spin the CPU like the closed-channel bug does; it just quietly leaks a goroutine, which is arguably worse to track down because nothing shows up in a CPU profile at all.