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.

What you'll learn
  • Select syntax and execution semantics
  • Multiplexing multiple channel operations
  • Non-blocking operations with the default case
  • Timeout patterns with time.After() and time.NewTimer()
  • Random selection when multiple cases are ready
  • Using nil channels to dynamically enable/disable cases
  • Essential patterns: done channels, heartbeats, first-response-wins
Building toward

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).

Prerequisites

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:

worker_problem.go
// 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:

The select statement makes this possible. It multiplexes channel operations—waiting on multiple channels simultaneously and proceeding with whichever becomes ready first:

worker_select.go
// 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.

WITHOUT vs WITH SELECT

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

basic_select.go
// 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:

select_structure.go
// 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:

valid_invalid.go
// 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:

SELECT EXECUTION MODEL

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.

evaluation_order.go
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)
}
Output (one possible run)
Call 1: count now 1
Call 2: count now 2
Sent to ch1
Final count: 2

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.

Side Effects in Case Expressions

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
side_effects_bad.go
// 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:

side_effects_good.go
// 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:

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 blockedselect does not unblock them. After the selected case completes, select exits—it does not loop.


Example: Waiting on Two Channels

two_channels.go
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")
}
Output
ch2: from ch2
Select completed

Timeline:

TWO CHANNEL RACE

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.

Goroutine Leak in This Example

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:

for_select.go
// 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:

random_select.go
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)
    }
}
Output (either line, ~50/50)
ch1: 1

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.

Buffered Channels in This Example

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:

starvation.go
// 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:

blocking_select.go
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)
    }
}
Output
fatal error: all goroutines are asleep - deadlock!

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:

mix_send_receive.go
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")
    }
}
Output (either case may win)
Received: 42
# or, on another run:
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.)

closed_in_select.go
// 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:

comma_ok_select.go
// Illustrative snippet — not a complete program
select {
case v, ok := <-ch:
    if !ok {
        fmt.Println("channel closed")
        return
    }
    process(v)
}
Closed Channels in Select Loops Create Busy Loops

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.

busy_loop.go
// 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:

busy_loop_measure.go
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)
}
Output (varies by CPU)
Received 23419877 times in 1 second
Measured Measured on go1.26.1, darwin/amd64, Intel i7-10700K @ 3.80GHz. Your absolute number will differ; the order of magnitude will not.

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:

fix_closed.go
// 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:

nil_in_select.go
// 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:

empty_select.go
// Illustrative snippet — not a complete program
select {}  // Blocks forever

Use case: Keep main alive for long-running goroutines:

server_select.go
// 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 vs Select
Row
Purpose
Case expressions
Evaluation
Selection
Blocking
Fallthrough

Common Mistakes

Expecting Case Order to Matter
Problem

Selection is random when multiple cases are ready—source order has no effect.

Fix

Don’t rely on syntactic order. Use priority patterns (Section 4.5) when order matters.

Forgetting select Doesn’t Loop
Problem

Only one case executes per select—then it exits completely.

Fix

Wrap in for for continuous operation: for { select { ... } }

Ignoring Closed Channel Behavior
Problem

A closed channel case is always ready—in a for-select loop, this creates a busy loop consuming 100% CPU with zero values.

Fix

Use comma-ok (v, ok := <-ch) to detect closure, then return or set channel to nil.

Expensive or Side-Effecting Case Expressions
Problem

All case expressions evaluate at select entry—even for unselected cases. This wastes work and causes unintended side effects.

Fix

Compute values and perform side-effect operations before select. Check cancellation first if needed.

Single-Case select Without default
Problem

Unnecessary complexity—a single-case select without default is just a channel operation.

Fix

Use a direct channel operation: v := <-ch instead of wrapping in select.


Section Summary

Select Fundamentals
Purpose
Multiplex channel operations—wait on multiple simultaneously
Case requirement
Channel ops only (send or receive)
Evaluation
All channel exprs evaluated at entry
Blocking
Blocks until at least one case ready (unless default exists)
Selection
Random if multiple ready (no priority)
Execution
One case runs, then select exits
Non-selected cases
Remain pending (blocked)
Closed channels
Always ready (return zero value)
Nil channels
Cases ignored (disabled)
Empty select
select {} blocks indefinitely

Key Takeaways

  1. Select multiplexes channels—wait on multiple, proceed with whichever is ready first
  2. Only channel operations—send or receive, nothing else
  3. Expressions evaluated once at entry—side effects run for all cases, even unselected ones
  4. Random selection when tied—no case has priority
  5. Executes once—wrap in for for continuous operation
  6. Closed channels always ready—use comma-ok to detect; beware busy loops (millions of wasted iterations/sec)
  7. Nil channels ignored—set a channel to nil to disable that case dynamically
  8. 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.

Section 4.1 — in one line

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?

Remember: Select Executes Exactly Once

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:

single_select.go
// 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:

for_select_worker.go
// 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.

FOR-SELECT LOOP

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.

Placeholder Types

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.

done_worker.go
// 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:

Why Close Instead of Send?

Closing broadcasts to all receivers. Sending reaches only one:

close_vs_send.go
// 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
SEND vs CLOSE FOR SIGNALING

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.

Done Channel Conventions
  • 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:

busy_loop_closure.go
// 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!
        }
    }
}
After ch closes
0
0
0
0
... (infinite, 100% CPU)

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:

detect_closure.go
// 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.

Multiple Closing Channels

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:

cancellable_send.go
// 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:

goroutine_leak_send.go
// Illustrative snippet — not a complete program
// ✗ PROBLEM: Goroutine leak
result := process(task)
results <- result  // Blocks forever if no receiver
WITHOUT vs WITH NESTED SELECT

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:

graceful_worker.go
// 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:

Worker Pattern — Four Questions
Exit?
Returns on done signal or tasks close
Communicate?
Receives via tasks, sends via results, signals via done
Errors?
Not shown (simplified—see Ch. 14)
Data?
Only accesses function parameters; no shared mutable state

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:

shutdown_coordinator.go
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

Graceful Drain vs Immediate Stop
Row
Signal
What happens
Work lost
Shutdown time
Use when
Graceful Drain Output (one real run)
Worker 1: task 1
Worker 1: task 4
Worker 1: task 5
Worker 1: task 6
Worker 1: task 7
Worker 1: task 8
Worker 1: task 9
Worker 1: task 10
Worker 1: tasks closed
Worker 3: task 2
Worker 3: tasks closed
Worker 2: task 3
Worker 2: tasks closed
All workers finished

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.

Immediate Stop Output (one real run)
Worker 1: task 1
Worker 3: task 2
Worker 3: task 3
Worker 3: immediate shutdown
Worker 2: immediate shutdown
Worker 1: immediate shutdown
All workers finished

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:

break_wrong.go
// 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:

break_bug_demo.go
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
        }
    }
}
Output
Received done signal
Received done signal
Received done signal
Received done signal
Received done signal
Received done signal
Loop ran 5+ times after 'break'!

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)

exit_return.go
// 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)

exit_labeled.go
// 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)

exit_flag.go
// 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

When to Use Each Pattern
Row
Single channel, exit when closes
Single channel with cancellation
Multiple channels, same priority
Multiple channels, exit when all close
Cancellable send

When for range is sufficient:

for_range.go
// 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:

for_select_required.go
// 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

Not Checking for Channel Closure
Problem

Infinite busy loop when channel closes—100% CPU, no useful progress.

Fix

Use comma-ok: v, ok := <-ch; if !ok { return }

Unlabeled break in For-Select
Problem

break exits the select statement, not the for loop—loop continues forever.

Fix

Use return or labeled break loop to exit the enclosing loop.

Exiting on First Channel Close
Problem

Ignores data from other channels that may still be open and sending.

Fix

Set closed channel to nil, continue processing others (Section 4.6).

Send Without Cancellation Check
Problem

Blocks forever if receiver has shut down—goroutine leak.

Fix

Use nested select with done case to allow abandoning the send.

No Done Channel
Problem

Worker cannot be stopped—runs until program exits or deadlocks.

Fix

Always include case <-done: return in every select that needs cancellation.

Send Instead of Close on Done Channel
Problem

Only one goroutine receives the signal; others keep running.

Fix

close(done) broadcasts to all goroutines simultaneously.

Multiple Goroutines Closing Done Channel
Problem

Panic on second close—“close of closed channel.”

Fix

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

Row
For-select loop
Done channel
Closure detection
Nil disabling
Nested select
Coordinated shutdown

Key Takeaways

  1. For-select is the fundamental pattern—loop provides repetition, select provides multiplexing
  2. Done channels enable cancellationclose(done) broadcasts to all goroutines; designate a single owner to close
  3. Always use comma-ok in loops—detect closure to prevent CPU spinning
  4. Nested select for cancellable sends—prevent blocked senders on shutdown
  5. Return or labeled break to exit—unlabeled break only exits select
  6. WaitGroup for coordinated shutdown—ensure all goroutines complete before proceeding
  7. 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.

Section 4.2 — in one line

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.

default_intro.go
// 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.

Critical Warning: default Is Frequently Misused

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:

  1. Evaluate all channel expressions (same as Section 4.1)
  2. Check which cases are ready
  3. If any case is ready: Select one randomly, execute it (default ignored)
  4. If NO case is ready: Execute default immediately
SELECT WITH vs WITHOUT DEFAULT

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:

try_receive.go
// 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:

Complete example: periodic cancellation check

cancellation_check.go
// 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:

try_send.go
// 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
}
Critical Design Question: Is Dropping Data Acceptable?

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:

record_metric.go
// 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:

save_order.go
// 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:

cpu_spinning_bug.go
// 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.

CPU SPINNING

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:

demonstrate_spinning.go
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)
}
Output (varies by CPU)
Spun 30344271 times in 1 second
Measured Measured on go1.26.1, darwin/amd64, Intel i7-10700K @ 3.80GHz. Your absolute number will differ; the order of magnitude will not.

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:

worker_correct.go
// 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
    }
}
Blocking Is Not a Problem—It’s Efficient Waiting

Many developers think “blocking = bad” and add default to “keep the goroutine active.” This is backwards:

Blocking vs CPU-Spinning Goroutines
Row
CPU usage
Responsiveness
Scalability

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:

check_shutdown.go
// 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:

drain_channel.go
// 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).

Why Drain Doesn’t Spin

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:

Does NOT work for:

Pattern 3: Try-Send for Optional Notifications

Send if someone is listening, skip if not:

notify_progress.go
// 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

antipattern_polling.go
// 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:

Better: use ticker for periodic work:

blocking_ticker.go
// 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”

antipattern_deadlock.go
// 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 symptomdefault 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:

closed_default.go
// 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:

comma_ok_default.go
// 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:

nil_default.go
// 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

SHOULD I USE DEFAULT?

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:

async_logger.go
// 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:


Common Mistakes

Default in loops (any form)
Problem

CPU spinning at 100%—whether the default body is empty, calls runtime.Gosched(), or does nothing useful. All variants burn a full core.

Fix

Remove default; let select block. Only add default when you have a specific, intentional use.

Polling with sleep in default
Problem

Adds latency—values sit unprocessed during sleep. Not a fix for spinning.

Fix

Remove default. For periodic work, add a time.NewTicker case instead.

Default to “avoid deadlock”
Problem

Silently drops data, masks a design bug rather than solving it.

Fix

Fix the design—ensure receivers exist, use buffering, or apply backpressure.

Assuming default means “else”
Problem

Misunderstanding: default means “no case ready right now,” not “otherwise.”

Fix

default = “no channel operation can proceed at this instant.”

Drain without exit in default
Problem

Infinite loop instead of bounded drain—becomes CPU spinning.

Fix

Always return or break in default for drain patterns. Use comma-ok for closed channels.


Section Summary

Section 4.3 Summary
Purpose
Make select non-blocking
Executes when
No other case is ready
In loops
Almost always wrong unless default exits immediately (e.g., drain pattern)
Legitimate uses
One-time checks, try-send/receive, bounded drains
With closed channels
Receive case executes (closed = ready), not default
With nil channels
All nil → default executes
When in doubt
Don’t use default—blocking is correct

Key Takeaways

  1. Default makes select non-blocking—executes immediately if no case is ready
  2. NEVER use default in loops—causes catastrophic CPU spinning. Blocking is efficient: zero CPU while waiting, instant wake when ready
  3. Legitimate uses are rare—one-time checks, try-send/receive, bounded drains
  4. Try-send can drop data—only acceptable for non-critical notifications (metrics, progress updates)
  5. Closed channels are always ready—receive case executes, not default. Use comma-ok in drains
  6. If adding default to “fix” blocking—you have a design problem. Use tickers for periodic work, timeouts for deadlines
  7. 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.

Section 4.3 — in one line

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()

Which Go are we on?

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?

WHY TIMEOUTS MATTER

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:

Go’s time package provides four mechanisms for timeouts and periodic events in select:

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:

after_signature.go
// Illustrative snippet — not a complete program
func After(d time.Duration) <-chan time.Time

Minimal example:

after_minimal.go
// 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.

time.After() TIMELINE

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

fetch_with_timeout.go
// 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")
    }
}
Why Buffered?

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)

CRITICAL on 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.

Go 1.23+ Changed Timer GC

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:

after_in_loop_leak.go
// 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):

  1. Iteration 1: Creates Timer1 (expires in 60s). Message arrives at 1s → select returns, but Timer1 is still waiting.
  2. Iteration 2: Creates Timer2 (expires in 60s). Message arrives at 2s → Timer1 and Timer2 both still waiting.
  3. 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.

TIMER LEAK VISUALIZATION

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:

  1. It fires (sends to its channel), AND
  2. 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

Real-World Production Scenario

Service: API gateway handling 1,000 requests/second
Timeout: 30 seconds per request
Typical request duration: 50ms

The bug in production:

production_bug.go
// 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):

Derived Arithmetic from the stated scenario (1,000 req/s, 30s timeout), not a measurement. The ~250-byte figure is an approximation and varies by platform and Go version; re-derive it for your own workload before quoting it.

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:

timer_basic.go
// 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:

timer_type.go
// 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:

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

timer_loop_correct.go
// 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:

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:

spurious_timeout.go
// 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:

  1. Timer created for 100ms
  2. We sleep for 200ms—timer fires at 100ms, and because timer channels were buffered, the value sits in timer.C
  3. Stop() returns false: already fired
  4. Reset() reschedules the timer for 100ms from now
  5. But the old value is still in timer.C
  6. Select immediately receives the stale value—spurious timeout

What happens on Go 1.23+:

  1. Steps 1–2 are the same, except the channel is now unbuffered, so the fired value was never parked anywhere—nothing received it
  2. Stop() returns true. This is the step most often misremembered: a timer whose value nobody took is still stoppable. Stop returns false only when the value was actually received, or when the timer was already stopped
  3. Reset() reschedules as before
  4. There is no stale value to receive, so the select waits the full 100ms—the correct behavior, with no drain
Check it yourself in one line

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:

drain_before_reset.go
// 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)
The Stop-Drain-Reset Pattern

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.C has a value → drain it
  • The timer was already stopped → timer.C is 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.

timer_per_iteration.go
// 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)

after_one_time.go
// 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

after_fires_regularly.go
// 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.

Case 2 vs 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():

ticker_usage.go
// 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:

Ticker vs Timer
Row
Fires
Reset()
Use for
Channel behavior

Common mistake: Using Timer with manual reset for periodic work:

timer_vs_ticker.go
// 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

Timer Mechanism Decision Table
Row
One-time timeout (not in loop)
Loop with timeout (high frequency)
Periodic events (every N seconds)
Callback after delay
Inactivity detection

Timeout Doesn’t Cancel Work

Critical Point: Timeout ≠ Cancellation

Timing out in a select doesn’t stop the goroutine doing the work—it only unblocks the waiting goroutine.

timeout_no_cancel.go
// 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 TIMEOUT ACTUALLY DOES (AND DOESN’T DO)

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.

TIMEOUT TIMELINE

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).

Production Code: Use Context

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.

context_timeout.go
// 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:

query_fastest.go
// 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")
    }
}
Why Buffered Channels Matter

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.

FIRST-RESPONSE-WINS PATTERN

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.

Abandoned Queries Still Complete

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:

fetch_timeout_cancel.go
// 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:

monitor_activity.go
// 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:


Pattern: Overall Operation Deadline

Set a deadline for entire operation, not per step:

process_all_deadline.go
// 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:

heartbeat.go
// 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:

Heartbeat Limitation: Blocking Work

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:

When NOT needed:


time.AfterFunc(): Callback-Based Timeouts

For triggering an action after a delay without blocking:

after_func.go
// 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().

AfterFunc Callback Runs in a Separate Goroutine

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).

after_func_sync.go
// 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:

When to Use AfterFunc vs Select
Standalone action after delay
AfterFunc
Wait/block until timeout
select with time.After/NewTimer
Timeout among select cases
time.After/NewTimer

Comparing time.After() vs time.NewTimer()

Comparing time.After() vs time.NewTimer()
Row
Returns
Stoppable
Resettable
In loops
Cleanup
One-shot

Common Mistakes

time.After() in loop
Problem

On Go ≤ 1.22: timer leak as abandoned timers accumulate. On Go 1.23+: no leak, but unnecessary per-iteration allocation overhead.

Fix

Use time.NewTimer() with Reset().

Forgetting timer.Stop()
Problem

Timer keeps running after select, consuming resources.

Fix

Always defer timer.Stop() after creation.

Reset without drain (Go ≤ 1.22)
Problem

Stale value in timer.C triggers spurious timeout. A blocking <-timer.C can hang forever if the timer was already stopped.

Fix

Non-blocking drain: if !timer.Stop() { select { case <-timer.C: default: } } then Reset(). On Go 1.23+, Reset() handles this automatically.

Thinking timeout cancels work
Problem

Worker goroutine keeps running after the caller times out.

Fix

Use done channel or context.Context for actual cancellation.

Unbuffered channel with timeout
Problem

Goroutine leak when timeout wins—sender blocks forever on unbuffered channel.

Fix

Buffer size ≥ number of senders.

Timer for periodic events
Problem

Manual reset complexity—error-prone and unnecessary boilerplate.

Fix

Use time.NewTicker() for automatic repeated firing.

AfterFunc without sync
Problem

Data race in callback—runs in a separate goroutine. Double-close panic if callback can fire more than once.

Fix

Use sync.Once for one-shot close; use mutex or channels for other shared state.


Section Summary

Section 4.4 Summary
Row
time.After()
time.NewTimer()
time.NewTicker()
time.AfterFunc()

Key Takeaways

  1. time.After() leaks in loops on Go ≤ 1.22—use time.NewTimer() with Reset() for one timer reused across iterations
  2. Use time.NewTicker() for periodic events—automatic repeated firing without manual reset
  3. Always defer timer.Stop()—clean up timers on all exit paths
  4. Drain before Reset() (Go ≤ 1.22)—select { case <-timer.C: default: } prevents spurious timeouts from stale values
  5. Timeout ≠ cancellation—the worker goroutine keeps running and consuming resources. Use context.Context for actual cancellation
  6. Buffer channels used with timeouts—prevents goroutine leaks when the timeout wins and the sender has nowhere to send
  7. 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.

Section 4.4 — in one line

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:

random_demo.go
// 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)
}
Output (varies each run)
ch1: 48 selections (48%)
ch2: 52 selections (52%)

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.

RANDOM SELECTION WHEN MULTIPLE READY

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.

STARVATION PREVENTION

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:

aggregate.go
// 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.

Warning: Busy Loop on Closed Channel

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:

load_balance.go
// 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.

Design Philosophy

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:

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

shutdown_delay.go
// 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.

SHUTDOWN DELAY ANALYSIS

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

config_delay.go
// 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

error_priority.go
// 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)

no_priority.go
// 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)

with_priority.go
// 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)
Why Nested Select Provides Priority

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.

Critical: Include done in BOTH Selects

The done case appears in both selects for a reason:

  • First select: Catches done that’s already closed (priority check)
  • Second select: Catches done closing 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.

missing_done_bug.go
// 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

NESTED SELECT PRIORITY 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

priority_demo.go
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))
}
Output (mean of 1000 trials; run-to-run sd ≈ 0.05)
Without priority: 0.99 tasks per trial
With priority: 0.00 tasks per trial

The priority pattern provides immediate shutdown response.


Pattern: Drain High-Priority First

To completely drain a high-priority channel before checking others:

drain_priority.go
// 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:

  1. Inner loop drains urgent until empty
  2. default triggers exit when urgent is empty
  3. Outer select checks all channels (urgent still included—new urgent work gets priority)
  4. Cycle repeats
CRITICAL: Starvation Risk with Drain Pattern

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 normal queue 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:

separate_workers.go
// 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:

Disadvantages:

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

low_volume.go
// 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

good_enough.go
// 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

Comparing Priority Approaches
Plain select
No priority (fair). No risk. Best for equal priority cases
Nested select
Strong (≤1 task delay). Low risk. Best for shutdown signals
Drain pattern
Strict (100%). High starvation risk. Bounded bursts only
Separate workers
Resource-based. Medium risk. Best for throughput priority

Common Mistakes

Assuming Case Order Matters
Problem

Placing done first in select and assuming it has priority. Case order has no effect on selection—it’s always random among ready cases.

Fix

Use nested select with default for true priority. Don’t rely on source code ordering.

Priority Check Without Default
Problem

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.

Fix

Always use default in priority checks to make them non-blocking, allowing fall-through to the main select.

Forgetting Done in Main Select
Problem

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.

Fix

Include done in both selects—the priority check catches already-closed done, the main select catches done closing while waiting.

Over-Engineering Priority
Problem

Stacking multiple priority checks (three nested selects all checking done) before processing tasks. Adds overhead without meaningful benefit.

Fix

A single nested select is sufficient. The first non-blocking check already guarantees detection on the next iteration.


Decision Guide

DO YOU NEED PRIORITY PATTERNS?

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

  1. Random selection is by design—prevents starvation, ensures fairness
  2. Case order doesn’t matter—selection is uniformly random among ready cases
  3. Most code doesn’t need priority—add only with proven, measured requirements
  4. Nested select with default provides priority—the non-blocking check ensures the high-priority channel is tested every iteration
  5. Include done in both selects—priority check AND main select, or the worker becomes unkillable
  6. Drain pattern risks starvation—use only for bounded bursts, monitor queue depths
  7. Separate workers often simpler—resource allocation instead of complex select logic
  8. Measure first, then optimize—start with plain select and add priority only when you can demonstrate random selection is causing concrete problems
Production Note

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.

Section 4.5 — in one line

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:

nil_in_select.go
// 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.

Nil vs Closed vs Open Channels
Row
Open (empty)
Open (has data)
Closed
Nil

Demonstrating the Difference

nil_vs_closed.go
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")
    }
}
Output
closed returned: 0
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:

CLOSED vs NIL 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:

merge_broken.go
// 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:

merge_correct.go
// 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:

  1. Comma-ok detects closure (ok == false)
  2. Set to nil disables the case in future iterations
  3. Continue skips sending the zero value
  4. 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:

merge_usage.go
// 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)
}
MERGE EXECUTION TRACE

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

CRITICAL: Always continue After Setting 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.

missing_continue_bug.go
// 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:

  1. Channel closes
  2. Receive gets v=0, ok=false
  3. Set ch = nil (correct)
  4. Then send 0 to output (WRONG!)
  5. Downstream receives spurious zero

Concrete Impact Example

sensor_bug.go
// 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:

continue_fix.go
// 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

Nil Assignment Only Affects Local Variable

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.

nil_scope.go
// 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:

merge_n.go
// 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
}
Why Not Nil Channels for N-Way?

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:

reflect_select.go
// 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:

state_machine.go
// 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
        }
    }
}
STATE MACHINE VISUALIZATION

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.

Idempotent State Transitions

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:


Pattern: Conditional Send with Nil Channel

Send only when a condition is true:

conditional_send.go
// 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:

forwarder.go
// 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:

This prevents both receiving a new value while one is pending (which would overwrite) and blocking on send when nothing is pending.


Common Mistakes

Missing Loop Exit When All Channels Are Nil
Problem

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.

Fix

Always include an exit condition: for ch1 != nil || ch2 != nil. Track active channel count if using a slice.

Forgetting Continue After Nil
Problem

Setting ch = nil but not using continue. The zero value falls through to process(v) or out <- v, propagating false data downstream.

Fix

Always pair nil assignment with continue to skip the zero value.

Using Closed Instead of Nil
Problem

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.

Fix

Set the channel to nil instead. Nil = ignored; closed = always ready.

Thinking Nil Affects the Caller
Problem

Worrying that ch = nil inside a function will “break” the caller’s channel. Developers avoid nil assignment thinking it has broader impact.

Fix

ch = nil only affects the local variable. The caller’s channel is unchanged. This is standard Go value semantics.


Section Summary

Section 4.6 Summary
Nil channel case
Completely ignored in select
Closed channel case
Always ready, returns zero value
Set to nil
Disables that case for future iterations
Loop exit
When all tracked channels nil
Continue after nil
Essential—prevents processing zero value
Scope of nil assignment
Local variable only—doesn’t affect caller

Key Takeaways

  1. Nil channels are ignored in select—case never executes
  2. Closed channels are always ready—case executes with zero value (dangerous in loops!)
  3. Set closed channels to nil—disables the case cleanly
  4. Always continue after setting nil—skip the zero value (temperature sensor bug!)
  5. Exit condition prevents silent leaksfor ch1 != nil || ch2 != nil; without it, Go’s deadlock detector won’t help if other goroutines exist
  6. Nil assignment is local—doesn’t affect caller’s channel
  7. State machines with nil channels—channel value encodes valid operations
  8. For N-way merge, use goroutine-per-channel —or reflect.Select for truly dynamic channel sets
Section 4.6 — in one line

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.

1. A select has three cases and all three channels have values ready. Which case executes?

One is chosen uniformly at random. Case order in source code has no effect.

2. You use 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.

3. What happens if you put 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.

4. You add 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.

5. A channel closes in your for-select loop. You don’t use comma-ok. What happens?

Busy loop. The closed channel case executes every iteration, returning zero values tens of millions of times per second.

6. You’re merging two channels. After detecting closure, you set the channel to nil but forget 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.

7. What’s the difference between a closed channel and a nil channel in select?

Closed: Always ready, returns zero value immediately (causes busy loops). Nil: Completely ignored (cleanly disables that case).

8. Why does the nested select priority pattern need 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

Your move

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.

ch04/merge.go
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
}
ch04/merge_test.go
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:

go test ./...
--- FAIL: TestMergeForwardsEverythingThenCloses (0.00s)
  Merge produced 200+ values and kept going; the
  first few were [10 1 2 20 3 0 0 0].
  A closed channel is permanently ready and yields the
  zero value every time, so the select is spinning on it.
FAIL    corebackend.dev/go-concurrency/ch04
9. A timer fires. Nobody receives from 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.

10. If the drain is no longer needed, why was it written as a 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.

11. You need something to happen every second, indefinitely. Why is 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.

Done when: 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.
Hint, if you want one: §4.6 gives you a way to make 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.
Where the files are: 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

Next

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.