select Deep Dive
select waits on multiple channel operations and proceeds with whichever is ready first. Add a default for non-blocking operations; combine with time.After for timeouts; with ctx.Done() for cancellation. The cornerstone of channel composition.
What select does in plain English
select is Go's channel multiplexer. It looks at several channel operations at once and runs whichever one becomes ready first. The closest analogy in other languages is switch, but instead of matching on a value, select matches on which channel is ready to send or receive.
Every time a select block runs, the Go runtime walks through a small decision tree:
Three things to notice in that decision tree.
- The runtime checks readiness atomically. It is not a poll loop; if zero channels are ready and there is no
default, the goroutine is parked on all of them at once and woken up by whichever channel becomes ready first. - The "pick one at random" step is the fairness guarantee. When more than one case is ready,
selectdoes not pick the first one in source order. It picks at random, so a fast channel cannot permanently starve a slower one just by being ready more often. defaultis what makes aselectnon-blocking. Without it, aselectwith no ready cases parks the goroutine. With it, the goroutine takes thedefaultbranch immediately and keeps running.
The three idioms that cover 90% of usage
Almost every real select in Go production code is one of three shapes.
| Idiom | What it does | Typical form |
|---|---|---|
| Multiplex | Wait for any of N channels to become ready | case v := <-ch1: ..., case v := <-ch2: ... |
| Timeout | Give an operation a deadline, return an error if the deadline fires first | case ch <- v:, case <-time.After(d): |
| Cancellation | Stop the goroutine when the context is cancelled | case <-ctx.Done():, case work := <-jobs: |
The cancellation idiom is the one most worth committing to muscle memory. Every long-running goroutine in a Go service should have a select that watches ctx.Done() somewhere in its main loop. Without it, the goroutine has no way to be told to stop and will keep running after the rest of the service has shut down.
The cancellation idiom
Every long-running goroutine in a Go service should select on ctx.Done(). This is the pattern for graceful shutdown. Goroutines that do not respect context cancellation are goroutine leaks waiting to happen.
The five most useful select shapes
// 1. Wait for any of several inputs
select {
case v := <-ch1: handle1(v)
case v := <-ch2: handle2(v)
}
// 2. Send with timeout
select {
case ch <- v:
case <-time.After(d):
return ErrTimeout
}
// 3. Cancellation-aware long-running operation
select {
case <-ctx.Done():
return ctx.Err()
case work := <-jobs:
process(work)
}
// 4. Non-blocking try-send
select {
case ch <- v: return true
default: return false
}
// 5. Disable a case dynamically (nil channel)
var sendCh chan<- int // nil = disabled
// later: sendCh = realChan to enable
select { case sendCh <- v: ... }
Random selection, the fairness guarantee
Why random and not first-listed If select picked the first ready case, a fast input could starve a slower one. Random selection means no input is permanently starved. Fairness for free.
Caveat: real priority (urgent vs normal) must be implemented explicitly with a two-step select.
time.After, the timer leak gotcha
time.After creates a one-shot Timer behind the scenes. Each call allocates. Calling it in a tight loop (e.g., per-iteration retry deadline) lets Timers accumulate until they fire (which might be far in the future). Memory grows.
Fix: time.NewTimer + Stop when no longer needed:
timer := time.NewTimer(d)
defer timer.Stop()
select {
case v := <-ch: return v, nil
case <-timer.C: return zero, ErrTimeout
}
Stop releases the Timer; defer ensures it runs even on early return.
The deadlock select
An empty select{} blocks forever. Sometimes intentional (block main forever after spawning workers); usually a mistake. Always have at least one case unless "block forever" is the intent.
Primitives by language
- select { case <-ch: ... case ch <- v: ... case <-time.After: ... default: ... }
- time.After / time.NewTimer
- context.Context Done() channel
Implementation
Wait for whichever input arrives first. If both arrive simultaneously, Go picks randomly to prevent starvation.
1 package main
2
3 import "fmt"
4
5 func main() {
6 ch1 := make(chan string)
7 ch2 := make(chan string)
8
9 go func() { ch1 <- "from ch1" }()
10 go func() { ch2 <- "from ch2" }()
11
12 for i := 0; i < 2; i++ {
13 select {
14 case msg := <-ch1:
15 fmt.Println(msg)
16 case msg := <-ch2:
17 fmt.Println(msg)
18 }
19 }
20 }time.After(d) returns a channel that produces a value after duration d. Common idiom: select between the work channel and the timeout channel.
1 package main
2
3 import (
4 "fmt"
5 "time"
6 )
7
8 func fetchWithTimeout(ch <-chan string, timeout time.Duration) (string, error) {
9 select {
10 case result := <-ch:
11 return result, nil
12 case <-time.After(timeout):
13 return "", fmt.Errorf("timed out after %v", timeout)
14 }
15 }default makes select non-blocking. If no case is ready, default runs immediately. Used for "try send" or "try receive" without blocking.
1 package main
2
3 import "fmt"
4
5 // Try send, drop if full
6 func trySend(ch chan<- int, value int) bool {
7 select {
8 case ch <- value:
9 return true
10 default:
11 return false // channel full or no receiver
12 }
13 }
14
15 // Try receive, return immediately if empty
16 func tryReceive(ch <-chan int) (int, bool) {
17 select {
18 case v := <-ch:
19 return v, true
20 default:
21 return 0, false
22 }
23 }The canonical Go cancellation pattern. select between work and ctx.Done(). When context is cancelled, the goroutine returns. Use in EVERY long-running goroutine.
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "time"
7 )
8
9 func worker(ctx context.Context, jobs <-chan int) {
10 for {
11 select {
12 case <-ctx.Done():
13 fmt.Println("worker: cancelled,", ctx.Err())
14 return
15 case job, ok := <-jobs:
16 if !ok {
17 return // channel closed
18 }
19 process(ctx, job)
20 }
21 }
22 }
23
24 func main() {
25 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
26 defer cancel()
27
28 jobs := make(chan int, 100)
29 go worker(ctx, jobs)
30 // ... feed jobs ...
31 }A nil channel blocks forever. Setting a case's channel to nil disables it without removing the case, useful for dynamic state machines.
1 package main
2
3 import "fmt"
4
5 func dynamicSelect(in <-chan int, out chan<- int) {
6 var pending int
7 var sendCh chan<- int // nil, disabled until we have something to send
8
9 for {
10 select {
11 case v, ok := <-in:
12 if !ok { return }
13 pending = v * 2
14 sendCh = out // enable the send case
15 case sendCh <- pending:
16 sendCh = nil // disable until next input
17 }
18 }
19 }Key points
- •select picks ONE ready case; if multiple ready, randomly chosen
- •default makes select non-blocking, if no case ready, default runs
- •time.After(d) returns a channel that fires once after d → easy timeouts
- •ctx.Done() returns a channel, select on it for cancellation
- •select on nil channel = case never fires → useful for dynamic channel disabling
Follow-up questions
▸What if multiple cases are ready simultaneously?
▸How is priority between cases implemented?
▸Why does time.After leak under heavy use?
▸When should select have a default?
Gotchas
- !select with no cases blocks forever (used to park the main goroutine intentionally)
- !select with only default is just an immediate non-blocking check
- !time.After in tight loops leaks timers, use NewTimer + Stop
- !default in select on multiple channels can cause starvation if always-ready cases dominate
- !Nil channel cases never fire, both feature and footgun