Chapter 4: Select
Chapters 2 and 3 gave you the building blocks: goroutines for
concurrent execution and channels for communication. You can spawn
work, send results, and coordinate completion. But there’s a
fundamental limitation:
a goroutine can only wait on one channel operation at a
time.
The select statement removes this restriction, letting a
goroutine wait on multiple channels simultaneously and respond to
whichever becomes ready first.
- Select syntax and execution semantics
- Multiplexing multiple channel operations
- Non-blocking operations with the
defaultcase -
Timeout patterns with
time.After()andtime.NewTimer() - Random selection when multiple cases are ready
- Using nil channels to dynamically enable/disable cases
- Essential patterns: done channels, heartbeats, first-response-wins
The select patterns in this chapter are fundamental
building blocks. You’ll apply them in channel-based patterns
like pipelines and fan-out/fan-in (Chapter 7), context-based
cancellation (Chapter 13), and error handling in concurrent code
(Chapter 14).
You should understand channel creation (Section 3.1), send/receive operations and blocking (Section 3.2), channel closing and lifecycle (Section 3.3), and nil channel behavior (Section 3.4).
Consider a worker that needs to respond to multiple events:
// Illustrative snippet — not a complete program
func worker(tasks <-chan Task, done <-chan struct{}) {
// How do we wait for EITHER a task
// OR a shutdown signal?
<-tasks // Blocks here—deaf to done
// What if done closes while waiting?
}
While blocked on <-tasks, this worker cannot check
done. If shutdown is signaled, the worker remains stuck
waiting for a task that may never arrive. Sequential channel operations
force you to commit to one channel, potentially missing critical signals
on others.
Real concurrent systems need to:
- Wait for work or cancellation (whichever comes first)
- Receive from multiple producers simultaneously
- Implement timeouts (proceed if no result within a deadline)
- Send results or abandon if the requester disconnected
The select statement makes this possible. It multiplexes
channel operations—waiting on multiple channels simultaneously and
proceeding with whichever becomes ready first:
// Illustrative snippet — not a complete program
func worker(tasks <-chan Task, done <-chan struct{}) {
select {
case task := <-tasks:
process(task)
case <-done:
return // Respond to shutdown immediately
}
}
Now the worker responds to either event—whichever happens first.
This is select’s essence:
responsive concurrency.
A comparison of two approaches. Sequentially, a goroutine blocked on a receive from ch1 cannot look at ch2, ch3 or done at all until that first receive completes. With select, all four channels are watched at once and whichever becomes ready first is the one that runs.
4.1 Select Syntax and Semantics
The select statement looks syntactically similar to
switch, but operates fundamentally differently. Where
switch chooses based on values,
select chooses based on
which channel operation can proceed.
Basic Syntax
// Illustrative snippet — not a complete program
select {
case v := <-ch1:
// Executes if receive from ch1 succeeds
fmt.Println("received from ch1:", v)
case ch2 <- value:
// Executes if send to ch2 succeeds
fmt.Println("sent to ch2")
case v, ok := <-ch3:
// Receive with comma-ok (detects closure)
if !ok {
fmt.Println("ch3 closed")
}
}
Structure:
// Illustrative snippet — not a complete program
select { // select keyword (no condition)
case <-ch1: // Receive, discard value
case v := <-ch2: // Receive into new variable
case v, ok := <-ch3: // Receive with closure detection
case ch4 <- value: // Send
default: // No case ready? Run this (Sec 4.3)
}
Each case must be a channel operation—send or receive. Nothing else is permitted:
// Illustrative snippet — not a complete program
// ✓ VALID: Channel operations
case v := <-ch: // Receive
case ch <- value: // Send
case v, ok := <-ch: // Receive with comma-ok
case <-ch: // Receive, discard value
// ✗ INVALID: Non-channel operations
case x > 5: // Compile error: not a channel op
case doWork(): // Compile error: missing <- operator
case v := compute(): // Compile error: not a channel receive
How Select Executes
When execution reaches a select statement, this sequence
occurs:
The three phases of executing a select. First every channel expression and every value being sent is evaluated exactly once, in source order. Then the statement waits for at least one case to become ready. Finally one ready case is chosen, its body runs, and the select exits.
Phase 1: Evaluate All Case Expressions
Before waiting, select evaluates all channel expressions
and send values once, in source order. This is
critical to understand:
all expressions evaluate, even for cases that won’t be
selected.
package main
import "fmt"
var count int
func incrementAndReturn(n int) int {
count++
fmt.Printf("Call %d: count now %d\n", n, count)
return count * 10
}
func main() {
ch1 := make(chan int, 1)
ch2 := make(chan int, 1)
select {
case ch1 <- incrementAndReturn(1):
fmt.Println("Sent to ch1")
case ch2 <- incrementAndReturn(2):
fmt.Println("Sent to ch2")
}
fmt.Printf("Final count: %d\n", count)
}
Both incrementAndReturn(1) and
incrementAndReturn(2) execute before
select chooses which case to run. The “Sent to…”
line varies between runs (random selection), but the final count is
always 2—both expressions always evaluate.
All case expressions evaluate at select entry, even if that case isn’t selected. This creates two distinct concerns:
- Correctness: expressions with observable side effects (database writes, network calls, counter increments) execute unconditionally
- Performance: expensive expressions (>1ms) delay all cases, since select can’t check readiness until evaluation completes
// Illustrative snippet — not a complete program
// ✗ BAD: fetchResult executes during Phase 1—
// delays readiness check AND runs even if done fires first
// (Pseudocode—real db.Query returns (*Rows, error))
select {
case resultCh <- fetchResult("SELECT..."):
// fetchResult runs BEFORE select checks readiness
case <-done:
return // Can’t fire until fetchResult finishes
}
Mitigation—compute before select:
// Illustrative snippet — not a complete program
// ✓ BETTER: Narrow the cancellation window
select {
case <-done:
return
default:
}
// Skipped if done was already closed; still a small race window
result := fetchResult("SELECT...")
select {
case resultCh <- result:
case <-done:
return
}
This narrows but doesn’t eliminate the window—for truly cancellation-aware operations, pass a context (Chapter 13). Most code falls into “doesn’t matter”—when computation is cheap (<100µs) and has no side effects. Don’t complicate unless profiling shows otherwise.
Phase 2: Wait for a Ready Case
After evaluation, select checks which cases can proceed:
- Receive case is ready when: sender waiting, buffered value available, or channel closed
- Send case is ready when: receiver waiting (unbuffered) or buffer has space (buffered). Note: sending to a closed channel panics—never include a closed channel in a send case
If no cases are ready: select blocks
(goroutine yields CPU) until at least one becomes ready
If one case is ready: That case executes
If multiple cases are ready: One is chosen
uniformly at random
Phase 3: Execute and Exit
Only the selected case’s statements run. Goroutines blocked
waiting to communicate on non-selected channels
remain blocked—select does not
unblock them. After the selected case completes,
select exits—it does not loop.
Example: Waiting on Two Channels
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch1 <- "from ch1"
}()
go func() {
time.Sleep(50 * time.Millisecond)
ch2 <- "from ch2"
}()
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case msg := <-ch2:
fmt.Println("ch2:", msg)
}
fmt.Println("Select completed")
}
Timeline:
A timeline of two channels becoming ready at different moments. ch2's sender arrives at 50 milliseconds and ch1's at 100. The select is waiting from the start, so it wakes at 50 milliseconds with ch2 — the earlier of the two, not the one written first.
The ch2 case executes first (becomes ready at 50ms).
Select completes immediately—it doesn’t wait for
ch1.
The ch1 sender goroutine (arriving at 100ms) remains
blocked forever after select exits—this is a goroutine leak. This example uses the leak deliberately to keep focus on select
semantics. Production code prevents this with:
- Buffered channels (Chapter 5)—sender completes without blocking
- Done channels for cancellation—sender can exit on shutdown signal
- Context with timeout (Chapter 13)—automatic cancellation propagation
To continuously handle multiple channels, wrap
select in a for loop—the standard
concurrent event loop in Go. Section 4.2 covers this in depth:
// Illustrative snippet — not a complete program
for {
select {
case task := <-tasks:
process(task)
case <-done:
return // Exit loop and function
}
}
Random Selection When Multiple Ready
If multiple cases can proceed, select chooses one
uniformly at random:
package main
import "fmt"
func main() {
ch1 := make(chan int, 1) // Buffered: sends don’t block
ch2 := make(chan int, 1)
ch1 <- 1 // Both channels have values ready
ch2 <- 2
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
}
}
Run it repeatedly and you get roughly half ch1 and half
ch2. Over 400 runs on this machine the split was
211 / 189 — the two cases really are equally likely,
which is the point of the next paragraph.
This example uses buffered channels (capacity 1) to make both
values immediately available. Buffered channels are covered in
Chapter 5, but the key point is simple: both receives can proceed
immediately, so select chooses randomly between them.
Why Not Just Pick the First Ready Case?
Random selection prevents starvation. If
select always favored the first syntactic case, later
cases might never execute when earlier ones are constantly ready:
// Illustrative snippet — not a complete program
for {
select {
case <-frequent: // If deterministic: always wins
case <-rare: // This would starve
}
}
Random selection makes starvation extremely unlikely—each ready case has an equal probability of being chosen on any given iteration. Section 4.5 covers techniques for when you need priority.
Blocking Until Ready
Without a default case (Section 4.3),
select blocks until at least one case can proceed:
package main
import "fmt"
func main() {
ch := make(chan int)
// No sender exists—blocks forever, runtime detects deadlock
select {
case v := <-ch:
fmt.Println("Received:", v)
}
}
When blocked, Go parks the goroutine entirely—no CPU is consumed. This is fundamentally different from the busy-loop a closed channel creates (covered below).
Mixing Send and Receive Cases
A single select can include both send and receive
operations:
package main
import (
"fmt"
"time"
)
func main() {
in := make(chan int)
out := make(chan int)
go func() { in <- 42 }()
go func() { <-out }()
// Sleep for demo only—not reliable synchronization
time.Sleep(10 * time.Millisecond)
select {
case v := <-in:
fmt.Println("Received:", v)
case out <- 99:
fmt.Println("Sent: 99")
}
}
Both cases are ready—random selection chooses which executes. As
with the earlier two-channel example, the non-selected goroutine
leaks. Mixed send+receive selects like this are less common—most
selects are receive-only, with control channels (like
done) providing shutdown signals.
Closed and Nil Channels in Select
Two channel states require special attention:
Closed channels are always ready. A receive case for
a closed channel never blocks—it returns immediately. For
unbuffered channels, this means the zero value with
ok == false. (Buffered channels drain remaining values
first—Chapter 5.)
// Illustrative snippet — not a complete program
ch := make(chan int)
close(ch)
select {
case v := <-ch:
fmt.Println(v) // Prints 0 (ok would be false, but we never check)
}
Use comma-ok to detect closure:
// Illustrative snippet — not a complete program
select {
case v, ok := <-ch:
if !ok {
fmt.Println("channel closed")
return
}
process(v)
}
A closed channel case in
for { select { ... } } executes
every iteration, returning zero values
infinitely. This consumes 100% of a CPU core while accomplishing
nothing useful.
// Illustrative snippet — not a complete program
ch := make(chan int)
close(ch)
for {
select {
case v := <-ch:
fmt.Println(v) // Prints 0 forever!
}
}
How bad is this? Let’s measure:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
close(ch)
count := 0
start := time.Now()
for time.Since(start) < time.Second {
select {
case <-ch:
count++
}
}
fmt.Printf("Received %d times in 1 second\n", count)
}
Tens of millions of wasted iterations per second—consuming 100% of a CPU core while accomplishing nothing useful.
Solution: Use comma-ok to detect closure and exit or set the channel to nil:
// Illustrative snippet — not a complete program
for {
select {
case v, ok := <-ch:
if !ok {
return // Exit on closure
// Or: ch = nil (disables case—Sec 4.6)
}
process(v)
}
}
Nil channels are never ready. A case with a nil channel is ignored—never selected:
// Illustrative snippet — not a complete program
var ch chan int // nil
select {
case v := <-ch:
fmt.Println(v) // Never executes
case <-time.After(time.Second):
fmt.Println("timeout") // This executes
}
This enables dynamic case control—Section 4.6 covers strategic use of nil channels.
Empty Select
A select with no cases blocks indefinitely:
// Illustrative snippet — not a complete program
select {} // Blocks forever
Use case: Keep main alive for
long-running goroutines:
// Illustrative snippet — not a complete program
func main() {
go runServer()
select {} // Block until killed externally
}
Caveat: if runServer() returns or panics and no other
goroutines remain, the runtime detects a deadlock and crashes.
Production code typically blocks on a signal channel for graceful
shutdown (Chapter 15), but empty select serves when you simply need
“run forever until killed.”
Select Is Not Switch
Despite similar syntax, select and switch
differ fundamentally:
switch
select
switch
select
switch
select
switch
select
switch
select
switch
select
switch
select
Common Mistakes
Selection is random when multiple cases are ready—source order has no effect.
Don’t rely on syntactic order. Use priority patterns (Section 4.5) when order matters.
select Doesn’t Loop
Only one case executes per select—then it
exits completely.
Wrap in for for continuous operation:
for { select { ... } }
A closed channel case is always ready—in a
for-select loop, this creates a busy loop
consuming 100% CPU with zero values.
Use comma-ok (v, ok := <-ch) to detect
closure, then return or set channel to
nil.
All case expressions evaluate at select entry—even for unselected cases. This wastes work and causes unintended side effects.
Compute values and perform side-effect operations before
select. Check cancellation first if needed.
select Without default
Unnecessary complexity—a single-case select without default is just a channel operation.
Use a direct channel operation:
v := <-ch instead of wrapping in
select.
Section Summary
default exists)
select {} blocks indefinitely
Key Takeaways
- Select multiplexes channels—wait on multiple, proceed with whichever is ready first
- Only channel operations—send or receive, nothing else
- Expressions evaluated once at entry—side effects run for all cases, even unselected ones
- Random selection when tied—no case has priority
-
Executes once—wrap in
forfor continuous operation - Closed channels always ready—use comma-ok to detect; beware busy loops (millions of wasted iterations/sec)
- Nil channels ignored—set a channel to nil to disable that case dynamically
-
Not like
switch—different evaluation, different purpose
Next: Section 4.2 explores multiplexing patterns—using select in loops to continuously handle multiple channels, coordinating with done channels, and managing channel closure.
select evaluates every case expression once, in source
order, then waits for one to become ready—and when several are
ready it picks uniformly at random, not first-come.
That randomness is deliberate: it is what stops a busy channel from
starving the case below it.
4.2 Multiplexing Multiple Channels
Section 4.1 showed that select executes exactly
once—it waits for one case to become ready, executes it, and
exits. But real concurrent programs need to handle channel events
continuously: processing tasks until shutdown,
receiving from multiple producers, or coordinating long-running work.
The solution is wrapping select in a
for loop—the for-select pattern.
This section covers the essential multiplexing patterns you’ll
use throughout your Go career.
Why For-Select?
From Section 4.1: A single select handles one event
and exits. To continuously handle events, wrap it in a
loop—the for-select pattern. This is
fundamental to Go concurrency.
Consider this single-use worker:
// Illustrative snippet — not a complete program
func workerOnce(tasks <-chan Task, done <-chan struct{}) {
select {
case task := <-tasks:
process(task) // Processes ONE task, then returns
case <-done:
return
}
}
To handle tasks continuously, wrap select in a
for loop:
// Illustrative snippet — not a complete program
func worker(tasks <-chan Task, done <-chan struct{}) {
for {
select {
case task := <-tasks:
process(task) // Processes task, loops back
case <-done:
return // Exit when signaled
}
}
}
Now the worker processes tasks continuously until shutdown.
A loop diagram. A for statement feeds into a select, the select hands off to a handler, and the handler returns to the top of the for. The cycle repeats until a case explicitly returns or sets an exit condition, which is why a plain break is not enough to leave it.
This pattern appears everywhere in Go: servers, workers, stream processors, coordinators.
Examples in this section use placeholder types like
Task and Result. In your code, replace
with your actual types.
Done Channels for Cancellation
A done channel signals goroutines to stop. The
pattern: include a case <-done that exits when
signaled.
// Illustrative snippet — not a complete program
// Simplified: doesn’t handle tasks closure—see below
func worker(id int, tasks <-chan Task, done <-chan struct{}) {
for {
select {
case task := <-tasks:
fmt.Printf("Worker %d: processing task\n", id)
process(task)
case <-done:
fmt.Printf("Worker %d: shutdown\n", id)
return
}
}
}
Why chan struct{}?
The done channel carries no data—only the signal matters:
struct{}has zero size- Intent is clear: “signaling, not data”
- Close (don’t send) to broadcast
Why Close Instead of Send?
Closing broadcasts to all receivers. Sending reaches only one:
// Illustrative snippet — not a complete program
// ✗ WRONG: Only one worker receives signal
done <- struct{}{} // One worker stops, others continue
// ✓ CORRECT: All workers receive signal
close(done) // All workers stop
Two signalling strategies compared. Sending one value on a done channel reaches exactly one worker; the other two keep running, never having seen the signal. Closing the channel instead makes every receive succeed at once, so all three workers stop together.
-
Use
chan struct{}(zero size, signal-only intent) -
Name clearly:
done,quit,stop,shutdown - One closer, many listeners (only one goroutine should close)
- Never send, only close (sending reaches only one goroutine)
-
Pass as receive-only:
done <-chan struct{} - Include in every select that might need cancellation
Chapter 13 introduces context.Context, which provides
done channels with additional features (deadlines, timeouts, and
cancellation propagation). The patterns here remain fundamental.
Handling Channel Closure in Loops
Recall from Section 4.1 that receiving from a closed channel always succeeds with the zero value. In a for-select loop, this creates a busy loop—the closed case fires every iteration:
// Illustrative snippet — not a complete program
// ✗ CRITICAL BUG: Busy loop when channel closes
func consume(ch <-chan int) {
for {
select {
case v := <-ch:
fmt.Println(v) // After close: 0 forever!
}
}
}
The closed channel returns 0 every iteration—a
critical production bug with 100% CPU usage and no
useful progress.
Solution: Detect Closure and Exit
Use comma-ok to detect closure:
// Illustrative snippet — not a complete program
func consume(ch <-chan int, done <-chan struct{}) {
for {
select {
case v, ok := <-ch:
if !ok {
fmt.Println("Channel closed, exiting")
return
}
fmt.Println("Received:", v)
case <-done:
return
}
}
}
When ok is false, the channel has closed—exit the
loop.
When you need to merge multiple channels and continue processing until all have closed, you need a more sophisticated pattern: setting closed channels to nil to disable their select cases. This nil channel pattern is covered in detail in Section 4.6.
Cancellable Sends with Nested Select
When a worker sends results but must also respect cancellation, use a select inside the for-select case body:
// Illustrative snippet — not a complete program
// Inside a for-select case handler:
result := process(task)
select {
case results <- result:
// Sent successfully
case <-done:
// Cancelled—abandon result
return
}
Why this matters: Without the nested select, the send blocks forever if the receiver has shut down:
// Illustrative snippet — not a complete program
// ✗ PROBLEM: Goroutine leak
result := process(task)
results <- result // Blocks forever if no receiver
Two versions of a worker sending its result. Without a nested select, the worker blocks forever on a send that no receiver will ever take, leaking the goroutine. With the send wrapped in a select that also watches done, the worker abandons the send and exits when shutdown is signalled.
This pattern prevents one of the most common goroutine leaks in production Go code.
Complete Example: Worker with Graceful Shutdown
Combining patterns—for-select, done channel, closure handling, and nested select:
// Illustrative snippet — not a complete program
func worker(id int, tasks <-chan Task,
results chan<- Result,
done <-chan struct{}) {
for {
select {
case task, ok := <-tasks:
if !ok {
fmt.Printf("Worker %d: tasks closed\n", id)
return
}
result := process(task)
// Nested select: respect shutdown during send
select {
case results <- result:
// Sent successfully
case <-done:
fmt.Printf("Worker %d: cancelled\n", id)
return
}
case <-done:
fmt.Printf("Worker %d: shutdown\n", id)
return
}
}
}
Key points:
- Outer select waits for task or shutdown
- Comma-ok detects task channel closure
- Inner select allows abandoning result send if shutdown occurs
- Multiple exit paths all lead to clean return
-
Limitation: once
process(task)starts, cancellation takes effect only after it returns. For interruptible processing, seecontext.Contextin Chapter 13
done signal or tasks close
tasks, sends via results,
signals via done
Coordinating Shutdown with WaitGroup
Production code combines done channels with WaitGroups for clean termination. Here’s a simplified worker (without result sending) to focus on shutdown coordination:
package main
import (
"fmt"
"sync"
)
func main() {
tasks := make(chan int, 10) // Buffered: holds queued work
done := make(chan struct{})
var wg sync.WaitGroup
// Start workers
for i := 1; i <= 3; i++ {
wg.Go(func() {
simpleWorker(i, tasks, done)
})
}
// Queue work
for i := 1; i <= 10; i++ {
tasks <- i
}
// Shutdown strategy (choose one):
close(tasks) // Graceful: workers drain buffer, then exit
// close(done) // Workers exit, remaining tasks may be lost
wg.Wait()
fmt.Println("All workers finished")
}
func simpleWorker(id int, tasks <-chan int, done <-chan struct{}) {
for {
select {
case task, ok := <-tasks:
if !ok {
fmt.Printf("Worker %d: tasks closed\n", id)
return
}
fmt.Printf("Worker %d: task %d\n", id, task)
case <-done:
fmt.Printf("Worker %d: immediate shutdown\n", id)
return
}
}
}
Shutdown Strategies Comparison
close(tasks)
close(done)
Notice that the work is not shared evenly: one worker took eight of the ten tasks. Three goroutines receiving from one buffered channel do not take turns—whichever is scheduled when a value is available gets it, and a worker already running is the cheapest one to hand the next task to. Over 150 runs the busiest worker took between 2 and 9 of the 10 tasks, and a clean round-robin never occurred once. If you need even distribution, you have to build it; the channel will not give it to you.
Choose based on requirements: graceful completion vs. immediate termination.
Exiting For-Select Loops
The break statement inside select breaks
only from the select, not the enclosing loop:
// Illustrative snippet — not a complete program
// ✗ WRONG: break exits select, loop continues
for {
select {
case task := <-tasks:
process(task)
case <-done:
break // Only exits select!
}
}
// After done closes, loop continues forever
Demonstrating the Break Bug
This bug is subtle but severe. Let’s see what actually happens:
package main
import "fmt"
func main() {
done := make(chan struct{})
close(done) // Signal “stop” immediately
count := 0
for {
select {
case <-done:
fmt.Println("Received done signal")
break // Only breaks select, not for!
}
count++
if count > 5 {
fmt.Println("Loop ran 5+ times after 'break'!")
return
}
}
}
The done case executes
every iteration because break only exits
the select, not the for loop. The loop continues indefinitely,
creating a busy loop at 100% CPU.
Three Correct Approaches
Approach 1: Return (preferred when exiting function)
// Illustrative snippet — not a complete program
func worker(tasks <-chan Task, done <-chan struct{}) {
for {
select {
case task := <-tasks:
process(task)
case <-done:
return // Exits function entirely
}
}
}
Approach 2: Labeled Break (when cleanup needed after loop)
// Illustrative snippet — not a complete program
func worker(tasks <-chan Task, done <-chan struct{}) {
loop:
for {
select {
case task := <-tasks:
process(task)
case <-done:
break loop // Exits the labeled for loop
}
}
cleanup() // Runs after loop exits
}
Approach 3: Boolean Flag (for multiple exit conditions)
// Illustrative snippet — not a complete program
func worker(ch <-chan int, done <-chan struct{}) {
count := 0
running := true
for running {
select {
case v, ok := <-ch:
if !ok {
running = false
break // leaves the select, not the loop —
// the flag above is what ends it
}
count++
process(v)
if count >= 100 {
running = false
}
case <-done:
running = false
}
}
fmt.Printf("Processed %d items\n", count)
}
Recommendation: Use return when
possible—it’s the clearest. Use labeled break when cleanup
code must run after the loop. Avoid boolean flags unless you have
genuinely complex exit conditions—they add state that can
obscure the actual exit logic.
When to Use Each Pattern
for v := range ch
When for range is sufficient:
// Illustrative snippet — not a complete program
// ✓ Good: No cancellation needed
func processAll(jobs <-chan int) {
for job := range jobs { // Exits when jobs closes
process(job)
}
}
When for-select is required:
// Illustrative snippet — not a complete program
// ✓ Required: Need cancellation
func processUntilDone(jobs <-chan int, done <-chan struct{}) {
for {
select {
case job, ok := <-jobs:
if !ok {
return
}
process(job)
case <-done:
return // Exit before jobs closes
}
}
}
Common Mistakes
Infinite busy loop when channel closes—100% CPU, no useful progress.
Use comma-ok: v, ok := <-ch; if !ok { return }
break in For-Select
break exits the select statement, not the for
loop—loop continues forever.
Use return or labeled break loop to
exit the enclosing loop.
Ignores data from other channels that may still be open and sending.
Set closed channel to nil, continue processing
others (Section 4.6).
Blocks forever if receiver has shut down—goroutine leak.
Use nested select with done case to allow
abandoning the send.
Worker cannot be stopped—runs until program exits or deadlocks.
Always include case <-done: return in every
select that needs cancellation.
Only one goroutine receives the signal; others keep running.
close(done) broadcasts to all goroutines
simultaneously.
Panic on second close—“close of closed channel.”
Designate a single owner to close the channel. If multiple
goroutines may trigger shutdown, use sync.Once to
ensure only the first close executes.
Section Summary
for { select {...} }
close(done)
v, ok := <-ch; if !ok { return }
select {case out<-v: case <-done: ...}
Key Takeaways
- For-select is the fundamental pattern—loop provides repetition, select provides multiplexing
-
Done channels enable cancellation—
close(done)broadcasts to all goroutines; designate a single owner to close - Always use comma-ok in loops—detect closure to prevent CPU spinning
- Nested select for cancellable sends—prevent blocked senders on shutdown
-
Return or labeled break to exit—unlabeled
breakonly exits select - WaitGroup for coordinated shutdown—ensure all goroutines complete before proceeding
- Choose shutdown strategy—drain buffered work vs. stop immediately
Next: Section 4.3 covers non-blocking
operations—using the default case to poll channels
without blocking, when this is useful, and the critical warning about
CPU-spinning loops.
Wrapping a select in a for is how one
goroutine serves many channels for its whole life. Two things bite:
a bare break leaves the select and not the
loop, and a send that is not itself wrapped in a
select with done will strand the goroutine
at shutdown.
4.3 Non-Blocking Operations: The Default Case
Sections 4.1 and 4.2 showed select blocking until at
least one case is ready. But sometimes you need to
check a channel without committing to wait—poll
once and move on if nothing is available.
The default case enables this: it executes when no other
case is ready, making select non-blocking.
// Illustrative snippet — not a complete program
select {
case v := <-ch:
fmt.Println("received:", v)
default:
fmt.Println("no value ready")
}
If ch has a value, the receive executes. If not,
default executes immediately—no blocking.
The default case is one of the most misused features
in Go concurrency. Used correctly, it enables essential patterns
like try-send and try-receive. Used incorrectly—especially
in loops—it creates CPU-spinning bugs that consume 100% of a
CPU core doing nothing useful.
Most select statements should NOT have a
default case.
If you’re considering adding one, this section will help you
decide if you genuinely need it.
Execution Rules
How select evaluates when default is present:
- Evaluate all channel expressions (same as Section 4.1)
- Check which cases are ready
- If any case is ready: Select one randomly, execute it (default ignored)
- If NO case is ready: Execute default immediately
The difference default makes. Without default, a select with no ready case blocks and the goroutine yields the CPU. With default, the same select takes the default branch immediately and returns, which in a loop means it never yields at all.
Non-Blocking Receive (Try-Receive)
Check if a channel has a value without waiting:
// Illustrative snippet — not a complete program
func tryReceive(ch <-chan int) (int, bool) {
select {
case v := <-ch:
return v, true // Got value
default:
return 0, false // No value ready
}
}
// Usage
if value, ok := tryReceive(ch); ok {
process(value)
} else {
// No value right now—do something else
}
Note: If the channel is closed,
case v := <-ch fires immediately with the zero
value—so tryReceive returns (0, true),
indistinguishable from receiving a real zero. To detect closure, use
v, ok := <-ch inside the case and check
ok.
Use cases:
- Check for cancellation before starting expensive work
- Drain a channel of pending values
- Poll for optional updates
Complete example: periodic cancellation check
// Illustrative snippet — not a complete program
func processLargeDataset(data []Item, done <-chan struct{}) error {
for i, item := range data {
// Check cancellation every 100 items
if i%100 == 0 {
select {
case <-done:
return errors.New("cancelled")
default:
// Not cancelled, continue
}
}
process(item)
}
return nil
}
This checks once per batch—not in a tight loop—then
proceeds based on the result. Note that this is a point-in-time
snapshot: cancellation that arrives
during process(item) won’t be caught until
the next check. For truly cancellation-aware operations, pass a
context.Context
(Chapter 13).
Non-Blocking Send (Try-Send)
Attempt to send without blocking if no receiver is ready:
// Illustrative snippet — not a complete program
func trySend(ch chan<- int, value int) bool {
select {
case ch <- value:
return true // Sent successfully
default:
return false // Would block
}
}
// Usage
if trySend(results, result) {
// Delivered
} else {
// Receiver not ready
}
Try-send with default
silently discards data when the channel
isn’t ready. Use this pattern only when:
- Data loss is acceptable (metrics, debug logs, best-effort notifications)
- Blocking would be worse than losing data
- You monitor/count dropped data
Example of acceptable dropping:
// Illustrative snippet — not a complete program
// Debug/metrics—dropping is fine under load
func recordMetric(metrics chan<- Metric, m Metric) {
select {
case metrics <- m:
default:
// Collector backed up—drop this point
}
}
Example where dropping is WRONG:
// Illustrative snippet — not a complete program
// ✗ NEVER do this with critical data
func saveOrder(orders chan<- Order, o Order) {
select {
case orders <- o:
default:
// Customer’s order just disappeared!
}
}
For critical data, use buffered channels (Chapter 5) or blocking sends with timeouts (Section 4.4) instead.
The CPU-Spinning Trap
This is where default becomes dangerous. Consider this
seemingly reasonable code:
// Illustrative snippet — not a complete program
// ✗ CATASTROPHIC BUG: 100% CPU doing nothing
func worker(tasks <-chan Task, done <-chan struct{}) {
for {
select {
case task := <-tasks:
process(task)
case <-done:
return
default:
// “Keep checking for work”
}
}
}
What actually happens: Each iteration, neither
tasks nor done is ready, so
default runs—and the loop immediately retries.
Millions of times per second, at maximum CPU speed, accomplishing
nothing.
This is a busy loop—the goroutine consumes 100% of a CPU core checking channels that are empty, accomplishing no useful work.
A CPU usage chart pinned at one hundred percent across its whole width, labeled as doing nothing useful. This is what a select with a default case inside a tight for loop produces: the loop spins as fast as the processor allows while no work is available.
Demonstrating the Problem
Here’s code that shows how fast the spinning occurs:
package main
import (
"fmt"
"time"
)
// ⚠️ DEMONSTRATION ONLY—DO NOT USE IN PRODUCTION
// This intentionally creates a CPU-spinning loop
func main() {
ch := make(chan int)
count := 0
start := time.Now()
// Spin for 1 second
for time.Since(start) < time.Second {
select {
case <-ch:
// Never executes
default:
count++
}
}
fmt.Printf("Spun %d times in 1 second\n", count)
}
Tens of millions of empty checks every second—all wasted CPU cycles. The exact count varies by hardware, but the result is always the same: an entire core burning for nothing.
The Fix: Remove Default
The correct pattern is to let select block:
// Illustrative snippet — not a complete program
// ✓ CORRECT: Blocks until work arrives
func worker(tasks <-chan Task, done <-chan struct{}) {
for {
select {
case task := <-tasks:
process(task)
case <-done:
return
}
// No default—blocks until ready
}
}
Many developers think “blocking = bad” and add
default to “keep the goroutine active.”
This is backwards:
Blocking is the correct, efficient behavior.
When Default IS Appropriate
Despite the danger, default has legitimate uses. The key
distinction: single check vs. continuous loop.
Pattern 1: One-Time Non-Blocking Check
Check channel state once, then proceed:
// Illustrative snippet — not a complete program
func checkForShutdown(done <-chan struct{}) bool {
select {
case <-done:
return true
default:
return false
}
}
// Usage: check before expensive work
func process(done <-chan struct{}) error {
if checkForShutdown(done) {
return errors.New("already cancelled")
}
return expensiveComputation()
}
This catches cancellation that has already happened. If you
need to detect cancellation during the computation, use the
periodic check pattern above or pass a
context.Context (Chapter 13).
Pattern 2: Draining a Channel
Remove all pending values without blocking:
// Illustrative snippet — not a complete program
func drain(ch <-chan int) []int {
var values []int
for {
select {
case v, ok := <-ch:
if !ok {
return values // Channel closed
}
values = append(values, v)
default:
return values // Nothing buffered—stop
}
}
}
This loops with default, but
exits immediately via return when the buffer is empty
or the channel is closed. The comma-ok check is critical—without
it, a closed channel returns zero values instantly on every iteration,
causing an infinite loop (closed channels are always
ready—Section 4.1).
The drain loop uses default in a loop but is
safe because it’s bounded.
Each iteration either makes progress (receives a value), detects
closure (returns), or finds nothing buffered (returns via
default). Maximum iterations = buffer size + 1.
Works correctly for:
- Buffered channels: drains buffer until empty
- Closed channels: drains remaining buffered values, then detects closure via comma-ok
- Channels where senders have finished (but channel is still open)
Does NOT work for:
- Unbuffered channels with active senders (no buffer to drain—values arrive one at a time)
- Channels that continue receiving values indefinitely
Pattern 3: Try-Send for Optional Notifications
Send if someone is listening, skip if not:
// Illustrative snippet — not a complete program
func notifyProgress(progress chan<- int, percent int) {
select {
case progress <- percent:
// Listener received update
default:
// Channel not ready—skip
}
}
Progress updates are optional—if no one is listening, we don’t want to block.
Dropping a notification when nobody is listening is the mildest form of backpressure: the producer refuses to slow down and sheds the excess instead. That is the right call for progress updates and metrics, and the wrong one for work you must not lose. Chapter 17 takes the trade-off seriously—rate limiting, load shedding, and how to choose between dropping, blocking, and buffering.
When NOT to Use Default
Antipattern: Polling with Sleep
// Illustrative snippet — not a complete program
// ✗ WASTEFUL: Polling every 100ms
for {
select {
case v := <-ch:
process(v)
case <-done:
return
default:
time.Sleep(100 * time.Millisecond)
}
}
Why this is wasteful:
- Value arrives at time T
- Default executes at time T, sleeps until T+100ms
- Value sits in channel unprocessed for up to 100ms
- Adds unnecessary latency
Better: use ticker for periodic work:
// Illustrative snippet — not a complete program
// ✓ CORRECT: Blocking select with ticker
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case v := <-ch:
process(v) // Instant response
case <-ticker.C:
doPeriodicWork()
case <-done:
return
}
}
If you just need to wait for channel values (no periodic work), the
fix is even simpler: remove default entirely and let
select block. The ticker is only necessary when you
genuinely need periodic actions alongside channel operations.
Antipattern: Using Default to “Avoid Deadlock”
// Illustrative snippet — not a complete program
// ✗ WRONG: Silently drops data
select {
case ch <- value:
// sent
default:
// “Prevents deadlock”—but loses data!
}
If you’re adding default to prevent deadlock, you
have a design problem. The deadlock was a
symptom—default masks it without
solving the underlying issue. Fix the design—ensure receivers
exist, use buffering, or apply backpressure—don’t silently
discard data.
Default with Closed and Nil Channels
Closed channels are always ready (Section 4.1), so
the receive case executes, not default:
// Illustrative snippet — not a complete program
ch := make(chan int)
close(ch)
select {
case v := <-ch:
fmt.Println("Received:", v) // Runs (v = 0)
default:
fmt.Println("Not ready") // Never runs
}
Use comma-ok to distinguish closed from data:
// Illustrative snippet — not a complete program
select {
case v, ok := <-ch:
if !ok {
fmt.Println("Channel closed")
} else {
fmt.Println("Received:", v)
}
default:
fmt.Println("Channel not ready")
}
Nil channels are ignored (Section 4.1). If all
channel cases involve nil channels, default executes:
// Illustrative snippet — not a complete program
var ch chan int // nil
select {
case v := <-ch:
fmt.Println(v) // Never—nil ignored
default:
fmt.Println("default") // Always executes
}
Decision Guide
A decision tree for whether to use a default case. The first question is whether the select sits inside a loop. If it does, default is usually wrong because it turns the loop into a spin; if it does not, a one-shot non-blocking check is legitimate.
Practical Example: Async Logger
A legitimate use combining patterns—non-blocking send with bounded buffer:
// Illustrative snippet — not a complete program
import (
"fmt"
"io"
"sync"
)
type AsyncLogger struct {
out io.Writer
logs chan string
done chan struct{}
wg sync.WaitGroup
}
func NewAsyncLogger(out io.Writer, bufSize int) *AsyncLogger {
l := &AsyncLogger{
out: out,
logs: make(chan string, bufSize),
done: make(chan struct{}),
}
l.wg.Add(1)
go l.run()
return l
}
func (l *AsyncLogger) Log(msg string) {
select {
case l.logs <- msg:
// Queued for async writing
default:
// Buffer full—drop, don’t block caller
}
}
func (l *AsyncLogger) run() {
defer l.wg.Done()
for {
select {
case msg := <-l.logs:
fmt.Fprintln(l.out, msg)
case <-l.done:
l.drainAndExit()
return
}
}
}
func (l *AsyncLogger) drainAndExit() {
for {
select {
case msg := <-l.logs:
fmt.Fprintln(l.out, msg)
default:
return // Buffer empty, exit
}
}
}
// Close signals shutdown and waits for all
// buffered messages to be written.
func (l *AsyncLogger) Close() {
close(l.done)
l.wg.Wait() // Block until drain completes
}
Design points:
-
Log()uses non-blocking send—callers never block on slow I/O - Main loop has no default—blocks efficiently waiting for logs
-
Shutdown drain uses
defaultto detect empty buffer and exit (bounded operation) -
Close()waits for the goroutine viasync.WaitGroup—no buffered messages are lost -
Caveat: Calling
Log()afterClose()silently drops messages. A production version would guard with async.Mutexoratomic.Boolto reject late writes
Common Mistakes
CPU spinning at 100%—whether the default body is empty,
calls runtime.Gosched(), or does nothing useful.
All variants burn a full core.
Remove default; let select block.
Only add default when you have a specific,
intentional use.
Adds latency—values sit unprocessed during sleep. Not a fix for spinning.
Remove default. For periodic work, add a
time.NewTicker case instead.
Silently drops data, masks a design bug rather than solving it.
Fix the design—ensure receivers exist, use buffering, or apply backpressure.
Misunderstanding: default means “no case ready right now,” not “otherwise.”
default = “no channel operation can proceed
at this instant.”
Infinite loop instead of bounded drain—becomes CPU spinning.
Always return or break in
default for drain patterns. Use comma-ok for
closed channels.
Section Summary
default exits immediately
(e.g., drain pattern)
default executes
default—blocking is correct
Key Takeaways
- Default makes select non-blocking—executes immediately if no case is ready
- NEVER use default in loops—causes catastrophic CPU spinning. Blocking is efficient: zero CPU while waiting, instant wake when ready
- Legitimate uses are rare—one-time checks, try-send/receive, bounded drains
- Try-send can drop data—only acceptable for non-critical notifications (metrics, progress updates)
- Closed channels are always ready—receive case executes, not default. Use comma-ok in drains
- If adding default to “fix” blocking—you have a design problem. Use tickers for periodic work, timeouts for deadlines
- When in doubt, omit default—let select block
Next: Section 4.4 covers timeout patterns—using
time.After() and time.NewTimer() to
implement deadlines correctly, avoiding timer leaks, and choosing the
right timeout mechanism.
default turns select from
“wait” into “check and move on.” That is
exactly right for a one-shot probe and exactly wrong inside a loop,
where it stops the goroutine ever yielding and burns a full core
doing nothing. When in doubt, leave it out.
4.4 Timeouts: time.After() and
time.NewTimer()
This section makes more version-conditional statements than the
rest of the book combined, because
Go 1.23 rewrote how timers work. The book
targets Go 1.25+—the same version the
exercises declare in their go.mod—so wherever
you see “on Go ≤ 1.22,” that is history,
not advice. Two things changed: unreferenced timers became
eligible for garbage collection immediately, and timer channels
became unbuffered. Nearly everything else in this section follows
from those two facts.
Sections 4.1–4.3 showed how to multiplex channel operations—waiting on multiple channels, handling shutdown, and performing non-blocking checks. But one critical question remains: how long should you wait?
Two versions of the same call. Without a timeout, a receive from a slow operation blocks forever if that operation hangs, leaking the goroutine and holding its resources. With a timeout case in a select, the caller gives up after a bounded wait and continues.
Unbounded waits create unresponsive systems. A slow database query shouldn’t block your service indefinitely. A disappeared client shouldn’t leak a goroutine forever. Timeouts are essential for:
- API calls: Don’t wait forever for a slow backend
- User requests: Respond within acceptable latency
- Resource acquisition: Don’t hold connections indefinitely
- Graceful shutdown: Give workers time to finish, then force stop
Go’s time package provides four mechanisms for
timeouts and periodic events in select:
-
time.After(duration)—Simple one-shot timeout; leaks memory in loops on Go ≤ 1.22 -
time.NewTimer(duration)—Stoppable, resettable; preferred for loops -
time.NewTicker(duration)—Automatic repeated firing at regular intervals -
time.AfterFunc(duration, f)—Callback-based; runsfin a new goroutine after delay
This section covers all four, with special emphasis on the timer leak trap that catches most Go developers at least once.
time.After(): Simple Timeouts
time.After(d) returns a channel that receives the current
time after duration d elapses:
// Illustrative snippet — not a complete program
func After(d time.Duration) <-chan time.Time
Minimal example:
// Illustrative snippet — not a complete program
// computeResult() returns a channel immediately;
// the actual work runs in a separate goroutine.
select {
case result := <-computeResult():
fmt.Println("Got result:", result)
case <-time.After(3 * time.Second):
fmt.Println("Computation took too long")
}
If computeResult() completes within 3 seconds, we get the
result. Otherwise, we time out.
A timeline showing both outcomes for a three-second timeout. In the first, the result arrives before three seconds and the timeout case never fires. In the second, three seconds elapse first and the timeout case wins while the work continues in the background.
Complete Example
// Illustrative snippet — not a complete program
func fetchWithTimeout(url string) (string, error) {
result := make(chan string, 1) // Buffered
go func() {
data := fetch(url)
result <- data
}()
select {
case data := <-result:
return data, nil
case <-time.After(5 * time.Second):
return "", errors.New("request timeout")
}
}
The buffered channel (make(chan string, 1)) is
critical. If the timeout fires first, the sender goroutine will
still try to send. With an unbuffered channel, that send would
block forever—a goroutine leak. The buffer allows the sender
to complete and exit even when no one receives.
Note that the goroutine continues running even after timeout—we discuss this in “Timeout Doesn’t Cancel Work” below.
The Trap: time.After() in Loops Leaked Memory (Go ≤
1.22)
time.After() in Loops Leaks
Memory
On Go ≤ 1.22, every call to
time.After() allocated a timer that was not garbage
collected until it fired. In loops where events arrive faster than
the timeout, those timers accumulated indefinitely—a memory
leak. On Go 1.23+ this no longer happens;
what remains is one allocation per iteration, which is a
throughput cost rather than a leak. The callout below has the
details.
Starting with Go 1.23, unreferenced timers are garbage collected
immediately—even if they haven’t fired and
Stop() was never called. This means
time.After() in loops
no longer leaks memory on Go 1.23+ (when
go.mod declares go 1.23 or later).
However, time.NewTimer() with
Reset() remains preferable in loops: it avoids
per-iteration allocation overhead, gives you explicit control via
Stop(), and keeps your code compatible with older Go
versions. The patterns in this section are still the correct
approach—but the consequences of getting it wrong are less
severe on Go 1.23+.
Consider this seemingly reasonable code:
// Illustrative snippet — not a complete program
// ✗ Go ≤ 1.22: memory leak. 1.23+: churn, not a leak
for {
select {
case msg := <-messages:
process(msg)
case <-time.After(1 * time.Minute):
fmt.Println("No message for 1 minute")
}
}
What happens (on Go ≤ 1.22):
- Iteration 1: Creates Timer1 (expires in 60s). Message arrives at 1s → select returns, but Timer1 is still waiting.
- Iteration 2: Creates Timer2 (expires in 60s). Message arrives at 2s → Timer1 and Timer2 both still waiting.
- After 1,000 iterations: 1,000 abandoned timers sitting in the runtime’s timer heap, consuming memory until they eventually fire.
Each abandoned timer remains in the runtime’s timer heap, consuming memory until it eventually fires.
A memory graph climbing steadily upward as thousands of timers accumulate. This was the Go 1.22 and earlier behavior: a timer created by time.After in a loop stayed alive until it fired, so timers created faster than they expire pile up. On Go 1.23 and later the line stays flat.
When Do Timers Get Garbage Collected?
On Go ≤ 1.22, a timer is garbage collected when:
- It fires (sends to its channel), AND
- No references to the Timer object remain
In the leak scenario, timers are created but select returns before they fire. They’re still in the runtime timer heap. They fire eventually (after 60s in the example), then get garbage collected. But meanwhile, thousands accumulate.
On Go 1.23+, unreferenced timers are collected
immediately—so this accumulation no longer occurs. However, the
per-iteration allocation overhead remains, and
time.NewTimer() with Reset() is still the
more efficient pattern.
Production Impact
Service: API gateway handling 1,000 requests/second
Timeout: 30 seconds per request
Typical request duration: 50ms
The bug in production:
// Illustrative snippet — not a complete program
// ✗ THE BUG (Go ≤ 1.22)
for {
select {
case req := <-requests:
handleRequest(req)
case <-time.After(30 * time.Second):
checkIdleTimeout()
}
}
Impact calculation (Go ≤ 1.22):
- 1,000 timers created per second
- Each lives for 30 seconds (even though requests complete in 50ms)
- Steady state: ~30,000 concurrent timers in memory
- Each timer: ~250 bytes overhead (approximate; varies by platform)
- Memory: ~7.5 MB of steady-state overhead from abandoned timers
- GC pressure from scanning a timer heap with 30,000 entries
- 86,400,000 total timer allocations per day—significant GC churn
Typical symptoms: elevated GC pause times, increased memory footprint, and degraded tail latency under load. With longer timeouts (e.g., 5 minutes at 10K req/s), the steady-state count reaches millions of concurrent timers.
Always use time.NewTimer() in loops—even on Go
1.23+, it avoids per-iteration allocation overhead.
time.NewTimer(): The Correct Solution for Loops
time.NewTimer() creates a timer you can stop and reset:
// Illustrative snippet — not a complete program
timer := time.NewTimer(5 * time.Second)
// Later:
timer.Stop() // Cancel the timer
timer.Reset(10 * time.Second) // Restart with new duration
The Timer type:
// Illustrative snippet — not a complete program
type Timer struct {
C <-chan time.Time // Receives when timer fires
}
func (t *Timer) Stop() bool
func (t *Timer) Reset(d Duration) bool
Understanding Stop() Return Values
On Go 1.23+, Stop() returns
false in exactly two cases, and in neither of them is
there anything to drain:
-
The value was already received from
timer.C - The timer was already stopped
A timer that has fired but whose value nobody took still returns
true—it is stoppable, and the value is gone. That
is the case people most often expect to be false, and it
is the one Go 1.23 changed.
On Go ≤ 1.22 the first case was
different: a fired-but-unreceived timer returned false
and left its value parked in the buffered channel, where it
had to be drained before Reset(). A
timer that was merely already stopped returned false
with an empty channel—so draining unconditionally would
block forever. Telling those two falses
apart is the entire reason the drain was written as a non-blocking
select with a default.
Correct Loop Pattern
// Illustrative snippet — not a complete program
// ✓ CORRECT (Go 1.23+): one timer, reused each iteration
func processMessages(messages <-chan Message, done <-chan struct{}) {
timer := time.NewTimer(1 * time.Minute)
defer timer.Stop()
for {
select {
case msg := <-messages:
process(msg)
// Go 1.23+: stop, then reset. No drain needed —
// see “The Drain, and Why Go 1.23 Retired It”.
timer.Stop()
timer.Reset(1 * time.Minute)
case <-timer.C:
fmt.Println("No message for 1 minute")
// already fired and received — just reset
timer.Reset(1 * time.Minute)
case <-done:
return
}
}
}
How this works:
- Create timer once before loop
- When a message arrives: stop the timer, then reset it for the next iteration—no drain on Go 1.23+
- When timer fires: handle timeout, reset
- Only one timer object exists throughout
The Drain, and Why Go 1.23 Retired It
On the toolchain this book targets, the stop-drain-reset dance is no
longer required: timer.Stop() followed by
timer.Reset(d) is correct and complete. The pattern is
worth understanding anyway, because you will meet it in every codebase
written before Go 1.23 and in any module that still has to build
on one.
What it defended against was a real bug—spurious timeouts. Here is the code that produced it:
// Illustrative snippet — not a complete program
// ✗ WITHOUT drain: spurious timeout — on Go ≤ 1.22 only.
// On Go 1.23+ this function waits the full 100ms.
func demonstrateSpuriousTimeout() {
timer := time.NewTimer(100 * time.Millisecond)
time.Sleep(200 * time.Millisecond)
timer.Stop() // ≤1.22: false. 1.23+: true
timer.Reset(100 * time.Millisecond)
// ≤1.22 only: old value still in timer.C
select {
case <-timer.C:
// ≤1.22: fires at once on the stale value
fmt.Println("Spurious timeout!")
}
}
What happened, on Go ≤ 1.22:
- Timer created for 100ms
-
We sleep for 200ms—timer fires at 100ms, and because timer
channels were buffered, the value sits in
timer.C Stop()returnsfalse: already firedReset()reschedules the timer for 100ms from now-
But the old value is still in
timer.C - Select immediately receives the stale value—spurious timeout
What happens on Go 1.23+:
- Steps 1–2 are the same, except the channel is now unbuffered, so the fired value was never parked anywhere—nothing received it
-
Stop()returnstrue. This is the step most often misremembered: a timer whose value nobody took is still stoppable.Stopreturnsfalseonly when the value was actually received, or when the timer was already stopped Reset()reschedules as before- There is no stale value to receive, so the select waits the full 100ms—the correct behavior, with no drain
Both behaviors are still reachable from a current toolchain, so
you do not have to take this on faith. Run the program above with
GODEBUG=asynctimerchan=1 and the pre-1.23
implementation comes back: Stop() returns
false and the receive fires instantly. Without the
flag, it waits the full 100ms.
The fix—drain before reset:
// Illustrative snippet — not a complete program
// ✓ WITH drain: Clean reset
if !timer.Stop() {
select {
case <-timer.C: // Remove stale value
default: // Don’t block if drained
}
}
timer.Reset(100 * time.Millisecond)
When reusing a timer, always follow the stop-drain-reset sequence.
Why the non-blocking drain? If
Stop() returns false, either:
-
The timer fired and
timer.Chas a value → drain it -
The timer was already stopped →
timer.Cis empty → don’t block
The select with default handles both
cases safely.
Go 1.23+: Reset() now guarantees the
channel is drained after returning, so a plain
timer.Reset(d) is sufficient. The drain pattern
remains necessary for code that must support Go ≤ 1.22.
Simpler Alternative (Lower Throughput)
For simpler code when allocation overhead isn’t a concern.
Prefer the Reset() pattern above for production loops.
// Illustrative snippet — not a complete program
for {
timer := time.NewTimer(timeout)
select {
case msg := <-messages:
timer.Stop() // Prevent leak
process(msg)
case <-timer.C:
handleTimeout()
case <-done:
timer.Stop()
return
}
}
Simpler than Reset() but allocates a new timer each
iteration. Use Reset() for high-throughput loops.
When time.After() IS Acceptable
Even on Go ≤ 1.22 where the leak was real,
time.After() is appropriate in several cases:
Case 1: Single Timeout (Not in Loop)
// Illustrative snippet — not a complete program
// ✓ GOOD: One-time timeout
select {
case result := <-doWork():
return result
case <-time.After(5 * time.Second):
return errors.New("timeout")
}
This executes once. The timer fires or the work completes—either way, no leak.
Case 2: The Timeout Fires Most of the Time
// Illustrative snippet — not a complete program
// ✓ ACCEPTABLE: Timeout fires regularly
for {
select {
case msg := <-infrequentMessages:
handle(msg) // Rare—once per minute
case <-time.After(10 * time.Second):
checkSystemHealth()
}
}
If events arrive less frequently than the timeout, most iterations execute the timeout case. Timers fire and are cleaned up—minimal accumulation.
Bounded-overhead rule: time.After() has
bounded overhead when the
timeout fires more often than events arrive. The
maximum concurrent timer count is approximately
event_rate × timeout_duration.
- Events every 30s, timeout 10s → Bounded (at most 1–2 abandoned timers)
- Events every 1s, timeout 10s → Leak (on Go ≤ 1.22: ~10 concurrent timers per period)
time.NewTicker()
Case 2 fits “do X every N seconds unless
event Y happens”—an idle-detection pattern. For
unconditionally periodic work (heartbeats, polls) where events
don’t reset the interval, use
time.NewTicker() instead.
Simple rule: Unless you can clearly articulate why
time.After() is safe in your specific case (one-shot
usage, or timeout fires more often than events), use
time.NewTimer() with defer timer.Stop().
Short durations reduce the accumulation window but don’t
eliminate it—safety depends on the event-to-timeout ratio, not
the absolute duration.
time.NewTicker(): For Periodic Events
Timers fire once. For periodic events, use
time.NewTicker():
// Illustrative snippet — not a complete program
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
doPeriodicWork()
case <-done:
return
}
}
Ticker vs Timer:
time.NewTimer
time.NewTicker
time.NewTimer
time.NewTicker
time.NewTimer
time.NewTicker
time.NewTimer
time.NewTicker
time.NewTimer
time.NewTicker
Common mistake: Using Timer with manual reset for periodic work:
// Illustrative snippet — not a complete program
// ✗ COMPLEX: Manual reset for periodic work
// (simplified—real code needs done channel and defer Stop())
timer := time.NewTimer(interval)
for {
select {
case <-timer.C:
doWork()
timer.Reset(interval)
}
}
// ✓ SIMPLER: Use Ticker
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
doWork() // Automatic periodic firing
}
}
Timer Mechanism Decision Table
time.After()
time.NewTimer() + Reset
time.NewTicker()
time.AfterFunc()
time.NewTimer() + Reset
Timeout Doesn’t Cancel Work
Timing out in a select doesn’t stop the
goroutine doing the work—it only unblocks the
waiting goroutine.
// Illustrative snippet — not a complete program
select {
case result := <-doWork():
return result
case <-time.After(5 * time.Second):
return errors.New("timeout")
// doWork() goroutine is STILL RUNNING
}
What a timeout does and does not do. The intuitive expectation is that timing out tells the worker to stop. What actually happens is that only the caller moves on; the worker keeps running to completion, unaware that nobody is waiting for its result any more.
A timeline in which the caller waits two seconds, times out at five, and moves on, while the worker carries on until ten seconds. The gap between the caller leaving and the worker finishing is the goroutine and the resources a timeout alone does not reclaim.
For actual cancellation, you need cooperative cancellation with done
channels or context.Context (Chapter 13).
While these patterns teach timeout fundamentals, production Go
code typically uses context.WithTimeout for cleaner
timeout and cancellation handling. Context provides automatic
timeout propagation across function calls and cooperative
cancellation. Chapter 13 covers this comprehensively.
// Illustrative snippet — not a complete program
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := doWork(ctx) // Checks ctx.Done()
Pattern: First Response Wins with Timeout
Query multiple sources, use first response or timeout:
// Illustrative snippet — not a complete program
func queryFastest(
endpoints []string,
timeout time.Duration,
) (Response, error) {
responses := make(chan Response, len(endpoints))
for _, endpoint := range endpoints {
go func() {
resp := query(endpoint)
responses <- resp
}()
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case resp := <-responses:
return resp, nil
case <-timer.C:
return Response{}, errors.New("all timed out")
}
}
The buffer size matches the number of goroutines. When the first response wins, the other goroutines don’t stop—they continue their work. Without buffering, the slower goroutines would block forever on send—a goroutine leak.
Three endpoints queried at once, taking 150, 80 and 200 milliseconds. The select returns at 80 milliseconds with B's response. A and C finish later and send into a buffered channel nobody reads, which is exactly why that channel needs a buffer rather than being unbuffered.
The slower goroutines don’t stop—they continue making HTTP requests, database queries, and consuming backend resources. The buffered channel prevents goroutine leaks, but the underlying work still completes. For expensive operations, use context cancellation to actually stop the work.
Pattern: Timeout with Cancellation
Combine timeout with done channel for full control:
// Illustrative snippet — not a complete program
func fetchWithTimeoutAndCancel(
url string,
timeout time.Duration,
done <-chan struct{},
) ([]byte, error) {
result := make(chan []byte, 1)
errCh := make(chan error, 1)
go func() {
data, err := fetch(url)
if err != nil {
errCh <- err
return
}
result <- data
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case data := <-result:
return data, nil
case err := <-errCh:
return nil, err
case <-timer.C:
return nil, fmt.Errorf("timeout after %v", timeout)
case <-done:
return nil, errors.New("cancelled")
}
}
Four exit conditions: success, error, timeout, or explicit cancellation.
Pattern: Inactivity Timeout
Reset the timer on each event—timeout only after period of inactivity:
// Illustrative snippet — not a complete program
func monitorActivity(events <-chan Event, done <-chan struct{}) {
inactivity := 30 * time.Second
timer := time.NewTimer(inactivity)
defer timer.Stop()
for {
select {
case event := <-events:
handleEvent(event)
// Reset inactivity timer
// Go 1.23+: stop, then reset. No drain needed —
// see “The Drain, and Why Go 1.23 Retired It”.
timer.Stop()
timer.Reset(inactivity)
case <-timer.C:
fmt.Println("No activity for 30s")
return
case <-done:
return
}
}
}
Use cases:
- Connection keepalive—close if no data for N seconds
- User session timeout—logout after inactivity
- Watchdog—restart if no heartbeat received
Pattern: Overall Operation Deadline
Set a deadline for entire operation, not per step:
// Illustrative snippet — not a complete program
func processAll(
items <-chan Item,
maxDuration time.Duration,
) error {
deadline := time.NewTimer(maxDuration)
defer deadline.Stop()
for {
select {
case item, ok := <-items:
if !ok {
return nil // Channel closed
}
process(item)
case <-deadline.C:
return fmt.Errorf("exceeded %v deadline", maxDuration)
}
}
}
The timer runs continuously—no reset needed. If processing takes too long overall, the deadline fires.
Pattern: Heartbeat with Health Monitoring
Workers send periodic “I’m alive” signals. Supervisors monitor them and take action if workers become unresponsive:
// Illustrative snippet — not a complete program
// Worker side: sends periodic heartbeats
func workerWithHeartbeat(
tasks <-chan Task,
heartbeat chan<- time.Time,
done <-chan struct{},
) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case task := <-tasks:
process(task)
case t := <-ticker.C:
// Non-blocking heartbeat send
select {
case heartbeat <- t:
default:
}
case <-done:
return
}
}
}
// Supervisor side: monitors worker health
func supervisor(
heartbeat <-chan time.Time,
shutdown chan<- struct{}, // close to signal worker to stop
) {
timeout := 2 * time.Second
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case <-heartbeat:
// Worker alive—reset watchdog
// Go 1.23+: stop, then reset. No drain needed —
// see “The Drain, and Why Go 1.23 Retired It”.
timer.Stop()
timer.Reset(timeout)
case <-timer.C:
// No heartbeat—worker unresponsive
log.Println("Worker unresponsive")
close(shutdown) // close is valid on send-only channels
return
}
}
}
Why this pattern matters:
- Detect hung workers (stuck in infinite loop, deadlocked, waiting on I/O forever)
- Used in production for watchdog timers, health checks, distributed systems
Heartbeats are sent from the select loop, so they
cannot fire while process(task) blocks. If a task takes longer than the supervisor timeout, the
supervisor falsely declares the worker unresponsive. Mitigations:
set the supervisor timeout well above the maximum expected task
duration, or send heartbeats from a separate goroutine.
When to use:
- Long-running workers with many short tasks (heartbeats fire between tasks)
- Distributed systems health monitoring
- Any scenario where you need to detect and recover from worker failure
When NOT needed:
- Simple request-response patterns (timeout on the request itself is sufficient)
- Short-lived goroutines that complete quickly
time.AfterFunc(): Callback-Based Timeouts
For triggering an action after a delay without blocking:
// Illustrative snippet — not a complete program
func startOperationWithDeadline(op Operation, deadline time.Duration) {
done := make(chan struct{})
// After deadline, signal cancellation
timer := time.AfterFunc(deadline, func() {
fmt.Println("Deadline exceeded")
close(done)
})
defer timer.Stop()
result := op.Run(done)
handleResult(result)
}
AfterFunc runs the callback in its own goroutine when the
timer fires. Unlike After(), you can stop it before it
fires with timer.Stop().
The callback function runs in a new goroutine, not the calling goroutine. If your callback accesses shared state, ensure proper synchronization (mutexes, atomic operations, or channel communication).
// Illustrative snippet — not a complete program
var once sync.Once
timer := time.AfterFunc(deadline, func() {
// ⚠️ Runs in a NEW goroutine
// Use sync.Once to prevent double-close panic
once.Do(func() { close(done) })
})
When to use AfterFunc vs select with timer:
AfterFuncselect with time.After/NewTimer
time.After/NewTimer
Comparing time.After() vs time.NewTimer()
time.After()
time.NewTimer()
time.After()
<-chan Time
time.NewTimer()
*Timer (with .C channel)
time.After()
time.NewTimer()
timer.Stop()
time.After()
time.NewTimer()
timer.Reset(d)
time.After()
time.NewTimer()
time.After()
time.NewTimer()
defer Stop()
time.After()
time.NewTimer()
Common Mistakes
time.After() in loopOn Go ≤ 1.22: timer leak as abandoned timers accumulate. On Go 1.23+: no leak, but unnecessary per-iteration allocation overhead.
Use time.NewTimer() with Reset().
timer.Stop()
Timer keeps running after select, consuming resources.
Always defer timer.Stop() after creation.
Stale value in timer.C triggers spurious timeout.
A blocking <-timer.C can hang forever if the
timer was already stopped.
Non-blocking drain:
if !timer.Stop() { select { case <-timer.C: default: }
}
then Reset(). On Go 1.23+,
Reset() handles this automatically.
Worker goroutine keeps running after the caller times out.
Use done channel or context.Context for actual
cancellation.
Goroutine leak when timeout wins—sender blocks forever on unbuffered channel.
Buffer size ≥ number of senders.
Manual reset complexity—error-prone and unnecessary boilerplate.
Use time.NewTicker() for automatic repeated
firing.
AfterFunc without syncData race in callback—runs in a separate goroutine. Double-close panic if callback can fire more than once.
Use sync.Once for one-shot close; use mutex or
channels for other shared state.
Section Summary
time.After()
time.NewTimer()
Stop()
time.NewTicker()
Stop()
time.AfterFunc()
Stop()
Key Takeaways
-
time.After()leaks in loops on Go ≤ 1.22—usetime.NewTimer()withReset()for one timer reused across iterations -
Use
time.NewTicker()for periodic events—automatic repeated firing without manual reset -
Always
defer timer.Stop()—clean up timers on all exit paths -
Drain before
Reset()(Go ≤ 1.22)—select { case <-timer.C: default: }prevents spurious timeouts from stale values -
Timeout ≠ cancellation—the worker
goroutine keeps running and consuming resources. Use
context.Contextfor actual cancellation - Buffer channels used with timeouts—prevents goroutine leaks when the timeout wins and the sender has nowhere to send
-
When in doubt, use
NewTimer()—explicit control is always safer than implicit convenience
Next: Section 4.5 covers random selection behavior—what happens when multiple cases are ready simultaneously, why Go chooses randomly, and patterns for when you need priority or fairness.
time.After is fine for a single timeout and
time.NewTimer with Reset is better in a
loop—on Go 1.23+ for allocation reasons, not leak
reasons. And a timeout only releases the caller: the work
carries on until you cancel it.
4.5 Random Selection and Priority Patterns
Section 4.1 mentioned that when multiple select cases are
ready simultaneously, Go chooses one
uniformly at random. This isn’t an
implementation detail—it’s a deliberate design decision
with important implications.
This section explores why Go uses random selection, when it causes problems, and patterns for implementing priority when you need it.
Random Selection in Action
When multiple cases can proceed, Go selects uniformly at random:
// Illustrative snippet — not a complete program
func demonstrateRandomness() {
ch1 := make(chan int, 100)
ch2 := make(chan int, 100)
// Fill both channels—both always ready
// 100 iterations so count equals percentage
for i := 0; i < 100; i++ {
ch1 <- i
ch2 <- i
}
ch1Count := 0
ch2Count := 0
for i := 0; i < 100; i++ {
select {
case <-ch1:
ch1Count++
case <-ch2:
ch2Count++
}
}
fmt.Printf("ch1: %d selections (%d%%)\n",
ch1Count, ch1Count)
fmt.Printf("ch2: %d selections (%d%%)\n",
ch2Count, ch2Count)
}
Both cases are ready every iteration. Over 100 iterations, each gets roughly 50%—the distribution is uniform. Case order in source code does not affect selection.
A select with three ready cases. All three arrows converge on a single decision point labeled random pick: when more than one case is ready, the runtime chooses among them uniformly at random rather than preferring the one written first.
Why Random Selection?
Go’s random selection prevents starvation—a situation where one channel never gets serviced because another is always chosen first.
Why the random choice matters. In a hypothetical select that always preferred the first case written, a high-volume channel that always has data would be chosen every time and the done case below it would never be checked — the shutdown signal would never be seen.
Fairness Across Producers
Consider aggregating data from multiple sources:
// Illustrative snippet — not a complete program
func aggregate(source1, source2, source3 <-chan Data) <-chan Data {
out := make(chan Data)
go func() {
defer close(out)
for {
select {
case d := <-source1:
out <- d
case d := <-source2:
out <- d
case d := <-source3:
out <- d
}
}
}()
return out
}
If all three sources produce data continuously, random selection ensures each gets approximately equal representation in the output—no source starves.
This simplified example doesn’t handle channel closure. If any source closes, the select receives zero values from it endlessly, flooding the output and spinning the CPU. A production merge function uses the nil channel pattern (Section 4.6) to disable closed sources and exit when all are exhausted.
Real-World Example: Load Balancing
Random selection is desirable for distributing work fairly:
// Illustrative snippet — not a complete program
// Load balancing across backend servers
func handleRequests(
requests <-chan Request,
server1, server2, server3 chan<- Request,
) {
for req := range requests {
// All servers ready?
// Random selection distributes evenly
select {
case server1 <- req:
case server2 <- req:
case server3 <- req:
}
}
}
// Over 10,000 requests with all servers ready:
// Server 1: ~3,333 requests (33%)
// Server 2: ~3,334 requests (33%)
// Server 3: ~3,333 requests (33%)
//
// If select favored first case,
// server1 would get ALL 10,000!
This shows why random selection is often exactly what you want—fair distribution without explicit load balancing logic.
Go’s random selection reflects a key principle: the runtime shouldn’t make assumptions about programmer intent. If you need priority, you must express it explicitly. The default is fair treatment of all cases.
Most Code Doesn’t Need Priority
Before exploring priority patterns, understand: random selection is correct for 90% of select statements.
Priority patterns add complexity and potential bugs. Add them only when you have concrete requirements that random selection violates:
- Measured SLO violations (shutdown takes >1s when it should take <100ms)
- Proven starvation (queue depths growing unbounded)
- Business requirement (critical updates must apply within N milliseconds)
Don’t add priority “just in case” or “for performance.” Random selection is fast, fair, and simple. Start with plain select, measure, then optimize if needed.
The rest of this section covers priority patterns for when you genuinely need them.
When Random Selection Causes Problems
Random selection provides fairness, but sometimes you need priority:
Problem 1: Shutdown Signals Get Delayed
// Illustrative snippet — not a complete program
// ✗ PROBLEM: done might not be selected promptly
for {
select {
case task := <-tasks: // High volume
process(task)
case <-done:
return // May take many
// iterations to select
}
}
If tasks always has work and done is closed,
each iteration has only a 50% chance of selecting done.
On average the worker processes about one extra task before noticing
shutdown, but unlucky runs could see several.
An analysis of shutdown latency. With 100 buffered tasks and a closed done channel, each iteration is a coin flip between taking a task and seeing the shutdown, and each task takes ten milliseconds. The expected delay before shutdown is noticed grows with the queue.
Problem 2: High-Priority Work Delayed
// Illustrative snippet — not a complete program
// ✗ PROBLEM: Critical config might wait
select {
case data := <-dataStream: // Continuous
process(data)
case cfg := <-configUpdates: // Rare but
applyConfig(cfg) // time-critical
}
A time-critical configuration change (security patch, rate-limit
adjustment) might sit in configUpdates while data items
are processed first—violating an SLO that requires config
applied within N milliseconds.
Problem 3: Error Handling Priority
// Illustrative snippet — not a complete program
// ✗ PROBLEM: Might return result
// when error exists
select {
case err := <-errors:
return nil, err // Should check first
case result := <-results:
return result, nil
}
If both channels have values, you might return a result when an error should take precedence.
Pattern: Priority with Nested Select
The standard pattern for priority: check the high-priority channel first with a non-blocking select, then fall back to blocking select.
The Problem (Without Priority)
// Illustrative snippet — not a complete program
// ✗ Random selection delays shutdown
func workerNoPriority(tasks <-chan Task, done <-chan struct{}) int {
processed := 0
for {
select {
case task := <-tasks:
process(task) // Takes 10ms
processed++
case <-done:
return processed
}
}
}
// Scenario: Tasks arriving constantly,
// done closes
//
// Iteration N: Both ready
// → 50% chance done, 50% chance task
// Average: ~1 extra task before exiting
// Unlucky runs: several extra tasks
The Solution (With Priority)
// Illustrative snippet — not a complete program
// ✓ Check done first every iteration
func workerWithPriority(tasks <-chan Task, done <-chan struct{}) int {
processed := 0
for {
// Priority check (non-blocking)
select {
case <-done:
return processed
default:
// Not done, continue
}
// Main select: handle work or done
select {
case task := <-tasks:
process(task)
processed++
case <-done:
return processed
}
}
}
// Now: done is checked at START of every
// iteration
// Result: Typically 0-1 tasks after done
// closes (0-10ms delay)
The key insight: default only executes when
no other case is ready—it doesn’t
compete with <-done.
When done is already closed: the
first select’s done case is ready (closed
channels are always ready), so default is ignored.
Result: immediate return. Main select never reached.
When done is NOT closed yet: no case
is ready, so default executes. Fall through to main
select, which blocks waiting for tasks OR done.
The pattern ensures done is
checked first every iteration, providing true
priority when it’s already closed.
The done case appears in both selects for a reason:
-
First select: Catches
donethat’s already closed (priority check) -
Second select: Catches
doneclosing while waiting for tasks
Without done in the second select, the worker becomes
unkillable while waiting for tasks. If tasks
is empty, the second select blocks forever, even if
done closes.
// Illustrative snippet — not a complete program
// ✗ BUG: Can't exit while waiting
for {
select {
case <-done:
return
default:
}
select {
case task := <-tasks: // Blocks here
process(task) // done not checked!
// ← done case missing!
}
}
Visual Timeline
Two scenarios for a nested-select priority check. When done is already closed, the first select takes it immediately and returns, so the main select is never reached and no extra tasks are drained. When done is not ready, the first select falls through and the main select runs normally.
Demonstrating Priority Impact
package main
import "fmt"
func noPriority(tasks <-chan int, done <-chan struct{}) int {
count := 0
for {
select {
case <-tasks:
count++
case <-done:
return count
}
}
}
func withPriority(tasks <-chan int, done <-chan struct{}) int {
count := 0
for {
select {
case <-done:
return count
default:
}
select {
case <-tasks:
count++
case <-done:
return count
}
}
}
func main() {
done := make(chan struct{})
close(done) // Closed before workers start
// Separate channels for each test
// One trial is a coin flip: noPriority often returns after zero
// tasks, which looks identical to the priority version. Average
// over many trials so the difference is actually visible.
const trials = 1000
totalWithout, totalWith := 0, 0
for t := 0; t < trials; t++ {
ch1 := make(chan int, 100)
ch2 := make(chan int, 100)
for i := 0; i < 100; i++ {
ch1 <- i
ch2 <- i
}
totalWithout += noPriority(ch1, done)
totalWith += withPriority(ch2, done)
}
fmt.Printf("Without priority: %.2f tasks per trial\n",
float64(totalWithout)/float64(trials))
fmt.Printf("With priority: %.2f tasks per trial\n",
float64(totalWith)/float64(trials))
}
The priority pattern provides immediate shutdown response.
Pattern: Drain High-Priority First
To completely drain a high-priority channel before checking others:
// Illustrative snippet — not a complete program
func processWithStrictPriority(
urgent <-chan Task,
normal <-chan Task,
done <-chan struct{},
) {
for {
// Drain ALL urgent tasks first
draining := true
for draining {
select {
case task := <-urgent:
handleUrgent(task)
case <-done:
return
default:
draining = false
}
}
// Now check all channels
select {
case task := <-urgent:
handleUrgent(task)
case task := <-normal:
handleNormal(task)
case <-done:
return
}
}
}
Behavior:
- Inner loop drains
urgentuntil empty defaulttriggers exit when urgent is empty- Outer select checks all channels (urgent still included—new urgent work gets priority)
- Cycle repeats
This pattern can completely starve the
normal channel. If urgent receives 100
tasks/second continuously, normal never gets
processed and its queue grows unbounded—eventual OOM.
Only use drain pattern when:
- Urgent volume is bounded/bursty (not continuous)
- You actively monitor
normalqueue depth - Starvation of normal is explicitly acceptable
For most cases, nested select (statistical priority) is safer.
Pattern: Separate Workers for Priority Levels
Instead of complex select logic, use dedicated goroutines:
// Illustrative snippet — not a complete program
func main() {
critical := make(chan Task)
normal := make(chan Task)
done := make(chan struct{})
incoming := make(chan Task)
// Router: distribute by priority.
// Lifecycle: the inner selects exit on done, but a router
// parked on "range incoming" is NOT woken by close(done) —
// the caller must close(incoming) to retire it. Until then
// close(critical/normal) is unreachable.
go func() {
for task := range incoming {
if task.Priority == HighPriority {
select {
case critical <- task:
case <-done:
return
}
} else {
select {
case normal <- task:
case <-done:
return
}
}
}
close(critical)
close(normal)
}()
// More workers for critical (5:2 ratio)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Go(func() {
worker(critical, done)
})
}
for i := 0; i < 2; i++ {
wg.Go(func() {
worker(normal, done)
})
}
// ... send tasks to incoming ...
close(done)
wg.Wait()
}
func worker(tasks <-chan Task, done <-chan struct{}) {
for {
select {
case task, ok := <-tasks:
if !ok {
return
}
process(task)
case <-done:
return
}
}
}
Advantages:
- Simple, straightforward code
- Priority via resource allocation (more workers = higher throughput)
- No complex select patterns
- Easier to reason about and tune
Disadvantages:
- More goroutines
- Doesn’t guarantee strict ordering
- Resource allocation is static (though worker pools can help)
This is often the cleanest solution when priority is about throughput rather than strict ordering.
When NOT to Fight Random Selection
Sometimes developers add complex priority logic when it’s not needed:
Case 1: Low-Volume, Long-Running Tasks
// Illustrative snippet — not a complete program
// Usually GOOD ENOUGH for this workload
for {
select {
case task := <-tasks:
process(task) // Takes 100ms
case <-done:
return
}
}
Why random selection is fine here: Processing takes
100ms per task. Shutdown arrives, both cases ready. 50% chance
done executes immediately, 50% chance one more task
(100ms), then done executes.
Worst case delay: ~100ms (one extra task). For
graceful shutdown, 100ms extra is usually acceptable.
Case 2: Acceptable Delay or Approximate Fairness
// Illustrative snippet — not a complete program
// Config updates can wait a few iterations
select {
case data := <-stream:
process(data)
case cfg := <-config:
apply(cfg)
}
If applying a non-critical config update a few iterations late (maybe 50ms) is acceptable, random selection works. Similarly, if you need approximate fairness across multiple sources rather than strict ordering, plain select gives roughly equal distribution over thousands of iterations—no complexity needed.
Rule of thumb: Add priority patterns only when you have a concrete requirement that random selection violates. Don’t add complexity speculatively.
Comparing Priority Approaches
Common Mistakes
Placing done first in select and assuming it has
priority. Case order has no effect on
selection—it’s always random among ready cases.
Use nested select with default for true priority.
Don’t rely on source code ordering.
Writing a priority select with only
case <-done and no default.
Without default, the select blocks waiting for
done and the second select is never reached.
Always use default in priority checks to make
them non-blocking, allowing fall-through to the main select.
Including done only in the priority select but
not in the main select. If tasks is empty, the
main select blocks forever—even if done
closes. The worker becomes unkillable.
Include done in both
selects—the priority check catches already-closed done,
the main select catches done closing while waiting.
Stacking multiple priority checks (three nested selects all
checking done) before processing tasks. Adds
overhead without meaningful benefit.
A single nested select is sufficient. The first non-blocking check already guarantees detection on the next iteration.
Decision Guide
A decision tree for priority patterns. The first question is whether random selection is actually causing a measurable problem. If not, the advice is to leave it alone; priority machinery adds complexity that most code does not need.
Key Takeaways
- Random selection is by design—prevents starvation, ensures fairness
- Case order doesn’t matter—selection is uniformly random among ready cases
- Most code doesn’t need priority—add only with proven, measured requirements
-
Nested select with
defaultprovides priority—the non-blocking check ensures the high-priority channel is tested every iteration -
Include
donein both selects—priority check AND main select, or the worker becomes unkillable - Drain pattern risks starvation—use only for bounded bursts, monitor queue depths
- Separate workers often simpler—resource allocation instead of complex select logic
- Measure first, then optimize—start with plain select and add priority only when you can demonstrate random selection is causing concrete problems
These priority patterns work identically with
context.Context. Since
ctx.Done() returns a
<-chan struct{} that closes on cancellation, treat
it exactly like a done
channel in nested select patterns. Context is covered in depth in
later chapters.
Next: Section 4.6 covers nil channels in select—using nil to dynamically enable and disable select cases, implementing state machines, and coordinating complex channel lifecycles.
Random selection is a feature, and most code should not fight it.
When you genuinely must, a second select with a
default checked before the main one gives you
priority without competing against it—because
default only fires when nothing else is ready.
4.6 Nil Channels: Dynamic Case Control
Section 3.4 introduced nil channels—channels with zero value
that block forever on send or receive. In isolation, this seems like a
bug to avoid. But inside select, nil channels become a
powerful feature:
a nil channel case is completely ignored.
This enables dynamic control over which select cases are active. You can “turn off” a case by setting its channel to nil, and “turn on” a case by assigning a real channel. This section covers the mechanics and patterns that make this useful.
Nil Channel Behavior in Select
A case with a nil channel is never selected—it’s as if that case doesn’t exist:
// Illustrative snippet — not a complete program
var ch chan int // nil
select {
case v := <-ch:
fmt.Println("received:", v) // Never executes
case <-time.After(time.Second):
fmt.Println("timeout") // Always executes
}
The receive case is ignored because ch is nil. Only the
timeout case is considered.
Demonstrating the Difference
package main
import "fmt"
func main() {
// Closed channel: always ready, returns zero
closed := make(chan int)
close(closed)
// Nil channel: ignored in select
var nilCh chan int
// Test closed channel
select {
case v := <-closed:
fmt.Println("closed returned:", v)
default:
fmt.Println("default")
}
// Test nil channel
select {
case v := <-nilCh:
fmt.Println("nil returned:", v)
default:
fmt.Println("default")
}
}
The closed channel case executes (returning zero). The nil channel
case is ignored, so default executes.
Critical Distinction: Closed vs Nil in Loops
This distinction is crucial for correctness in for-select loops:
The critical difference between a closed and a nil channel inside a for-select loop. A closed channel is always ready, so its case fires on every iteration and returns the zero value forever — a busy loop at full CPU. A nil channel is never ready, so its case is skipped entirely.
This is why nil channels matter: When a channel closes in a loop, you need to disable that case. Setting it to nil is the solution.
Pattern: Merge Multiple Channels Until All Close
The canonical use of nil channels: receive from multiple channels until all have closed.
The Problem
Without nil channels, a closed channel creates a busy loop:
// Illustrative snippet — not a complete program
// ✗ BUG: Busy loop when channel closes
func mergeBroken(ch1, ch2 <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
select {
case v := <-ch1:
out <- v // After ch1 closes:
// sends 0 forever!
case v := <-ch2:
out <- v // After ch2 closes:
// sends 0 forever!
}
}
}()
return out
}
When ch1 closes, <-ch1 returns
0 immediately every iteration—infinite zeros flood
the output.
The Solution
Detect closure, set channel to nil, exit when all are nil:
// Illustrative snippet — not a complete program
// ✓ CORRECT: Nil channels disable closed cases
func merge(ch1, ch2 <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for ch1 != nil || ch2 != nil {
select {
case v, ok := <-ch1:
if !ok {
ch1 = nil // Disable this case
continue // Don't send zero!
}
out <- v
case v, ok := <-ch2:
if !ok {
ch2 = nil // Disable this case
continue // Don't send zero!
}
out <- v
}
}
}()
return out
}
Key elements:
-
Comma-ok detects closure (
ok == false) - Set to nil disables the case in future iterations
- Continue skips sending the zero value
- Loop condition exits when all channels are nil
Production note: If the consumer stops reading from
out, the goroutine blocks on
out <- v forever. In production, add a
done channel or use context to cancel the
merge.
Execution Trace
Let’s trace through concrete execution:
// Illustrative snippet — not a complete program
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
ch1 <- 1
ch1 <- 2
close(ch1)
}()
go func() {
ch2 <- 10
ch2 <- 20
ch2 <- 30
close(ch2)
}()
merged := merge(ch1, ch2)
for v := range merged {
fmt.Println(v)
}
A step-by-step trace of a two-channel merge. Both cases are considered each iteration; select picks one at random; the received value is forwarded. When a channel closes, its variable is set to nil so its case drops out, and the loop ends once both are nil.
The key insight: once a channel is set to nil, that case disappears from consideration. The loop continues processing the remaining channel until it too closes.
Critical Mistake: Forgetting Continue After Nil
When detecting closure, you must skip processing the zero value.
Without continue, the zero value falls through and
gets sent to the output channel.
// Illustrative snippet — not a complete program
// ✗ SEVERE BUG: Sends spurious zero value
select {
case v, ok := <-ch:
if !ok {
ch = nil
// Falls through to send!
}
out <- v // ✗ Sends 0 when closed!
}
What goes wrong:
- Channel closes
- Receive gets
v=0, ok=false - Set
ch = nil(correct) -
Then send
0to output (WRONG!) - Downstream receives spurious zero
Concrete Impact Example
// Illustrative snippet — not a complete program
// ✗ WITHOUT continue: False reading
// Merging temperature sensor readings
func mergeBuggy(a, b <-chan float64) <-chan float64 {
out := make(chan float64)
go func() {
defer close(out)
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil
}
out <- v // ← Sends 0.0!
case v, ok := <-b:
if !ok {
b = nil
}
out <- v
}
}
}()
return out
}
// Downstream consumer sees:
// 20.5, 21.0, 0.0, 18.0, 18.5, 0.0
// ^^^ ^^^
// FALSE READINGS! Not real temps
//
// In production:
// - Triggers low-temperature alarms
// - Corrupts analytics/averages
// - Misleads dashboard users
The fix—always continue:
// Illustrative snippet — not a complete program
// ✓ CORRECT: Skip send on closure
// (inside a for-select loop)
select {
case v, ok := <-ch:
if !ok {
ch = nil
continue // ← Essential! Skip send
}
out <- v // Only reached for real data
}
This is one of the most common nil channel bugs. Make it a habit:
nil and continue go together.
Understanding Nil Assignment Scope
Setting ch = nil modifies only the
local parameter variable, not the original
channel. This is standard Go value semantics—a channel value
is a handle to a runtime structure, and the variable
holding that handle is a copy.
Why this matters: You’re not “breaking” or “closing” the original channel—you’re just telling your local select loop to ignore it.
// Illustrative snippet — not a complete program
func merge(ch1, ch2 <-chan int) <-chan int {
// ch1 and ch2 are COPIES of channel
// values (handles)
go func() {
// ...
ch1 = nil // Modifies local copy only
// Caller's channel is unchanged
// ...
}()
return out
}
// Usage
myCh := make(chan int)
merge(myCh, otherCh)
// myCh is still a valid channel—not nil
Pattern: N-Way Merge
The nil channel approach works well for a fixed number of channels (2–3). For merging an arbitrary number, use one goroutine per input channel:
// Illustrative snippet — not a complete program
import "sync"
func mergeN(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Go(func() {
for v := range ch {
out <- v
}
})
}
go func() {
wg.Wait()
close(out)
}()
return out
}
You can’t write N dynamic select cases in
Go—the number of cases must be known at compile time. The
goroutine-per-channel approach above avoids this limitation
entirely. Each goroutine uses range, which handles
closure automatically—no nil channels needed.
For cases where you truly need a single goroutine to select across
a dynamic channel set (e.g., channels added at runtime), Go
provides reflect.Select:
// Illustrative snippet — not a complete program
cases := make([]reflect.SelectCase, len(channels))
for i, ch := range channels {
cases[i] = reflect.SelectCase{
Dir: reflect.SelectRecv,
Chan: reflect.ValueOf(ch),
}
}
chosen, value, ok := reflect.Select(cases)
Pattern: State Machine with Nil Channels
Use nil channels to encode state—which operations are valid in each state:
// Illustrative snippet — not a complete program
type Worker struct {
tasks chan Task
pause chan struct{}
resume chan struct{}
done chan struct{}
}
func (w *Worker) Run() {
// Active when running
var tasksCh <-chan Task = w.tasks
for {
select {
case task := <-tasksCh:
// Only when tasksCh != nil
process(task)
case <-w.pause:
fmt.Println("Pausing...")
tasksCh = nil // Disable tasks
case <-w.resume:
fmt.Println("Resuming...")
tasksCh = w.tasks // Re-enable
case <-w.done:
return
}
}
}
A state machine drawn as two boxes. In the Running state the task channel variable holds a real channel; receiving on the pause channel moves it to Paused, where that same variable is set to nil so its select case is disabled. Receiving on resume restores the channel and returns to Running.
The pattern above handles repeated signals gracefully. Setting
tasksCh = w.tasks when it’s already set to
w.tasks has no effect—the assignment is
idempotent. This prevents bugs from duplicate signals without
explicit state checking.
Similarly, receiving pause when already paused just
sets tasksCh = nil again—harmless.
Advantages of channel-based state:
- No explicit state variable or mutex
- State is implicit in channel values
- Select automatically handles valid transitions
- Invalid operations (task while paused) simply don’t execute
Pattern: Conditional Send with Nil Channel
Send only when a condition is true:
// Illustrative snippet — not a complete program
func processWithOptionalOutput(
input <-chan Data,
output chan<- Result,
enableOutput bool,
done <-chan struct{},
) {
var outCh chan<- Result
if enableOutput {
outCh = output
}
// If false, outCh remains nil
for {
select {
case data := <-input:
result := process(data)
// Ignored if outCh is nil
select {
case outCh <- result:
// Sent result
case <-done:
return
default:
// nil: skip by design
// full/no receiver: dropped!
}
case <-done:
return
}
}
}
When enableOutput is false, outCh is nil,
and the send case is always ignored. No explicit
if enableOutput check needed inside the loop.
Pattern: Coordinated Forwarder
Forward values from input to output with proper closure handling:
// Illustrative snippet — not a complete program
func forwarder(
in <-chan int,
out chan<- int,
done <-chan struct{},
) {
var pending int
var hasPending bool
inCh := in // Active initially
var outCh chan<- int // nil until value
for {
select {
case v, ok := <-inCh:
if !ok {
// Input closed
if !hasPending {
return // Nothing left
}
inCh = nil // Disable input
continue
}
pending = v
hasPending = true
outCh = out // Enable output
inCh = nil // Disable input
case outCh <- pending:
hasPending = false
outCh = nil // Disable output
inCh = in // OK if closed; next iter exits
case <-done:
return
}
}
}
How it works:
-
Initially:
inChactive (waiting for input),outChnil (nothing to send) -
After receive:
inChnil (can’t receive while sending),outChactive (has value) -
After send:
outChnil (nothing to send),inChactive (ready for next) - On input close: If pending value exists, send it first; otherwise exit immediately
This prevents both receiving a new value while one is pending (which would overwrite) and blocking on send when nothing is pending.
Common Mistakes
Using for { ... } without an exit condition. When
all channel cases become nil and there’s no
default, select has no valid cases and blocks
forever. Go’s deadlock detector won’t fire if
other goroutines exist—silent goroutine leak.
Always include an exit condition:
for ch1 != nil || ch2 != nil. Track active
channel count if using a slice.
Setting ch = nil but not using
continue. The zero value falls through to
process(v) or out <- v,
propagating false data downstream.
Always pair nil assignment with continue to skip
the zero value.
Trying to close(ch) to disable a case. You
can’t close a receive-only channel, and even if you
could, a closed channel is
still ready—it doesn’t disable
the case.
Set the channel to nil instead. Nil = ignored;
closed = always ready.
Worrying that ch = nil inside a function will
“break” the caller’s channel. Developers
avoid nil assignment thinking it has broader impact.
ch = nil only affects the local variable. The
caller’s channel is unchanged. This is standard Go value
semantics.
Section Summary
Key Takeaways
- Nil channels are ignored in select—case never executes
- Closed channels are always ready—case executes with zero value (dangerous in loops!)
- Set closed channels to nil—disables the case cleanly
- Always continue after setting nil—skip the zero value (temperature sensor bug!)
-
Exit condition prevents silent leaks—
for ch1 != nil || ch2 != nil; without it, Go’s deadlock detector won’t help if other goroutines exist - Nil assignment is local—doesn’t affect caller’s channel
- State machines with nil channels—channel value encodes valid operations
-
For N-way merge, use goroutine-per-channel —or
reflect.Selectfor truly dynamic channel sets
Assigning nil to a channel variable removes its case
from the select entirely, which is how you drain N
channels until every one has closed. The bug to know is the missing
continue: a closed channel left non-nil is
always ready, and the loop spins on it forever.
Chapter 4 Self-Check
Test your understanding of the concepts covered in this chapter. Click each question to reveal the answer.
One is chosen uniformly at random. Case order in source code has no effect.
break inside a select case to exit a
for-select loop. The loop continues running. What’s
wrong?
break only exits the select, not the for loop. Use
return or labeled break loop.
time.After(30 * time.Second) inside a for-select loop
handling 1,000 messages per second?
On Go ≤ 1.22, a timer leak: 1,000
timers/sec × 30 sec lifetime = 30,000 concurrent timers,
and 86 million created over 24 hours.
On Go 1.23+ it is not a leak—those
timers are collected as soon as they go unreferenced—but
it is still 86 million allocations a day for no reason. Either
way the fix is the same: time.NewTimer() with
Reset(), one timer for the life of the loop.
default to a for-select loop to
“keep checking for work.” What’s the
consequence?
CPU spinning. When no case is ready,
default executes immediately, loop repeats tens of
millions of times per second consuming 100% CPU.
Busy loop. The closed channel case executes every iteration, returning zero values tens of millions of times per second.
continue. What
bug does this create?
The zero value is sent to output. Temperature sensor example: downstream sees false 0.0°C readings, triggering alarms and corrupting analytics.
Closed: Always ready, returns zero value immediately (causes busy loops). Nil: Completely ignored (cleanly disables that case).
done in BOTH selects?
First select (with default) catches done already
closed—immediate priority response. Second select catches
done closing while blocked waiting for tasks.
Without both, worker becomes unkillable during waits.
With channels (Chapter 3) and select (Chapter 4), you can build sophisticated concurrent programs. Next: Chapter 5 covers buffered channels—when to use them, how to size them, and the patterns they enable.
Exercise 4.1 — Take the Case Out of the Running
Merge two channels without spinning on a closed one
§4.6 made the point that a closed channel is not
“finished” as far as select is
concerned—it is permanently ready, handing back the
zero value on every single iteration. Here is that fact as running
code, in the shape you will actually meet it: a two-channel merge.
package ch04
// Merge forwards every value from a and b onto a single channel and
// closes it once both inputs are done.
//
// TODO(reader): this is broken in the way §4.6 warns about. A closed
// channel is not "finished" as far as select is concerned — it is
// PERMANENTLY READY, and yields the zero value on every iteration. So
// the moment either input closes, this loop spins at full CPU pushing
// zeros into out, and never reaches close(out).
//
// Fix it with §4.6's technique:
// - when a receive reports ok == false, take that case out of the
// running so it stops being selected,
// - skip the send for that iteration,
// - and return once both are done, so the defer can close out.
//
// Do not change the signature, and do not count values — Merge has
// no idea how many are coming, which is the whole point.
func Merge(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
select {
case v, ok := <-a:
_ = ok // <- your move
out <- v
case v, ok := <-b:
_ = ok // <- your move
out <- v
}
}
}()
return out
}
package ch04
import (
"slices"
"testing"
"time"
)
// collect drains out until it closes. A correct Merge closes; one that
// leaves a closed channel enabled spins on it, so we stop early and say
// so rather than printing a thousand zero values.
func collect(t *testing.T, out <-chan int) []int {
t.Helper()
type result struct {
got []int
runaway bool
}
done := make(chan result, 1)
go func() {
var got []int
for v := range out {
got = append(got, v)
if len(got) > 200 {
done <- result{got[:8], true}
return
}
}
done <- result{got, false}
}()
select {
case r := <-done:
if r.runaway {
t.Fatalf("Merge produced 200+ values and kept going; the "+
"first few were %v.\nA closed channel is permanently "+
"ready and yields the zero value every time, so the "+
"select is spinning on it. See §4.6.", r.got)
}
return r.got
case <-time.After(2 * time.Second):
t.Fatal("Merge never closed out. See §4.6.")
return nil
}
}
func TestMergeForwardsEverythingThenCloses(t *testing.T) {
a, b := make(chan int), make(chan int)
go func() {
for _, v := range []int{1, 2, 3} {
a <- v
}
close(a)
}()
go func() {
for _, v := range []int{10, 20} {
b <- v
}
close(b)
}()
got := collect(t, Merge(a, b))
slices.Sort(got)
want := []int{1, 2, 3, 10, 20}
if !slices.Equal(got, want) {
t.Fatalf("Merge gave %v, want %v", got, want)
}
}
// The case that most cleanly separates a real fix from a lucky one: one
// input is already closed before Merge ever looks at it.
func TestMergeHandlesAnAlreadyClosedInput(t *testing.T) {
a := make(chan int)
b := make(chan int)
close(a)
go func() {
b <- 7
close(b)
}()
got := collect(t, Merge(a, b))
if !slices.Equal(got, []int{7}) {
t.Fatalf("Merge gave %v, want [7]", got)
}
}
Run it and the failure is loud and instant:
timer.C. You
then call timer.Stop(). What does it return, and is
there anything to drain?
On Go 1.23+ it returns
true and there is nothing to
drain—the value was never parked anywhere, because timer
channels are unbuffered. Stop() returns
false only if the value was actually received, or
the timer was already stopped. On Go ≤ 1.22 the
same call returned false and left a value sitting
in the buffered channel. You can see both from one toolchain:
run it with GODEBUG=asynctimerchan=1 to get the old
behavior back.
select with a default rather than a
plain <-timer.C?
Because on Go ≤ 1.22 Stop() returned
false for two different reasons, and only
one of them left a value behind. If the timer had fired
unreceived there was something to drain; if it had merely been
stopped already, the channel was empty and a plain receive would
block forever. The default is what made the drain
safe in both cases. That ambiguity is gone on 1.23+, which is
why the whole dance retired.
time.After in a loop the wrong tool even on
Go 1.25?
Two reasons. First, time.After is one-shot: a loop
around it allocates a fresh timer every iteration, which on
1.23+ is no longer a leak but is still pointless churn. Second,
it measures from the top of each iteration, so your period
silently becomes
one second plus however long the body took and drifts.
time.NewTicker keeps a fixed cadence and allocates
once—just remember defer ticker.Stop().
Look at the values it captured before the guard tripped:
10 1 2 20 3 — the five real ones, in the
interleaved order you would expect — and then zeros without end.
The merge worked perfectly right up to the moment the first input
closed.
The second test is the one that separates a real fix from a lucky one.
It hands Merge a channel that is
already closed before the first select ever
runs, so there is no window in which the naive version looks correct.
go test -race ./... in
code/ch04/ reports ok for both tests, with
every value forwarded exactly once and out closed
afterwards. No counting values, no changed signature.
select ignore a case entirely, and “Nil
Assignment Only Affects Local Variable” explains why it is safe
to do it to a parameter. You will also need
continue—the chapter calls forgetting it a Critical
Mistake for good reason.
labs/go-concurrency/code/ch04/. A worked answer sits in
solution/merge.go.txt—worth sitting with the wall
of zeros for a minute first, because recognizing that shape in a real
log is the transferable skill.
Further reading
- The Go Programming Language Specification — Select statements — the normative text, including the sentence that makes §4.1 true: if more than one communication can proceed, the runtime makes a uniform pseudo-random choice.
-
Go 1.23 release notes — timer changes
— the two changes §4.4 is built on: unreferenced timers
become collectable immediately, and timer channels are now
unbuffered. Worth reading once, because almost every older article
about
time.Afterpredates it. -
time.Timer.Stop— the current contract, which says plainly that afterStopreturns, no value prepared earlier will be received. That one sentence is why the drain pattern retired. -
Go Concurrency Patterns: Pipelines and cancellation
— where the done-channel pattern from §4.2 is taken to a
full pipeline. Chapter 13 replaces the hand-rolled version with
context. -
Go Concurrency Patterns: Context
— the answer to the gap §4.4 names but does not close: a
timeout releases the caller, and
contextis how you also stop the work.
You can now wait on several channels at once, take a non-blocking path
with default, put a deadline on any operation, reason
about why the choice is random, and switch a case off by assigning
nil. Every channel in this chapter has been
unbuffered—a send waits for a receiver, every
time. Chapter 5 adds the buffer: what capacity
actually changes, why len and cap finally
start returning something interesting, and how a buffer that is too
large hides backpressure instead of relieving it.