Skip to content
intermediate Phase 9 · Channels, Select, and Channel Patterns

select and Non-Blocking Communication

Wait on multiple channel operations, add defaults, and build timeouts.

45m
0 problems
Topic Progress 0%

One Goroutine, Two Things to Wait For

A worker collects results from a channel, but the caller may cancel at any moment. Before select, this forces an ugly choice: block on <-results and the worker cannot notice cancellation; poll the cancel flag on a timer and you trade latency for wakeups; spawn one extra goroutine just to forward signals and now you manage its lifetime too. select resolves the deadlock directly: it waits on several channel operations at once and runs the first one that can proceed.

func work(jobs <-chan Job, results chan<- Result, done <-chan struct{}) {
	for {
		select {
		case j, ok := <-jobs:
			if !ok {
				return // jobs channel closed: stream over
			}
			results <- run(j)
		case <-done:
			return // caller cancelled: stop now
		}
	}
}

Read the mechanics precisely, because every select rule hangs off this shape:

  • Uniform readiness. The runtime evaluates all channel cases. If exactly one can proceed, it runs. If several can proceed, one is chosen uniformly at random — not first-in-source-order. Randomness prevents starvation when a fast channel would otherwise always win over a slow one in a long-running loop.
  • Blocking when nothing is ready. With no default branch, select parks until any case becomes ready. The goroutine consumes no CPU while waiting — same as a plain receive.
  • The send cases are symmetric. case results <- r: participates like a receive; it is ready when a receiver is waiting or the buffer has room. This lets a goroutine wait until either the consumer takes its result or the caller cancels.
  • No case, no default: hang. If the select's cases can never become ready — a closed-nil combination, a channel nobody will touch — the goroutine blocks forever, the silent bug class covered in Goroutine Leaks.

A select with a single case behaves exactly like the bare channel operation; the construct earns its keep at two or more cases. And an important composition detail: the results <- run(j) send in the body, outside the select, still blocks without watching done. Production versions pull that send into its own select — one select waiting to send results or to observe cancellation — because a worker blocked pushing into a full result buffer would otherwise ignore the shutdown signal it just received.

default: Asking Without Waiting

Sometimes you do not want to block at all. Is the queue empty right now? Try to push this without stalling the caller? Add a default branch and select stops being a waiting construct and becomes a poll:

func trySend(ch chan<- Job, j Job) bool {
	select {
	case ch <- j:
		return true
	default:
		return false // nobody was ready; caller decides what to do
	}
}

func tryRecv(ch <-chan Job) (Job, bool) {
	select {
	case j := <-ch:
		return j, true
	default:
		return Job{}, false
	}
}

The rule: default runs only when no case is ready at the instant select is evaluated. It is not a timeout and not a fallback chosen when the wait is long — it is the immediate answer to "can any of these proceed right now?" This gives Go its non-blocking channel idiom without timing tricks, and it is the mechanism behind checking len(ch)-style conditions without the racy alternative of reading len and then sending (between the check and the send, another goroutine may have filled the buffer).

The other classic use makes select do double duty as timeout:

select {
case ev := <-events:
	handle(ev)
case <-time.After(5 * time.Second):
	return errors.New("no event within 5s")
}

time.After returns a channel that delivers once after the duration; select then waits on either an event or the tick. It reads beautifully in one-shot request handlers. In a loop it is a trap: each iteration allocates a new timer that lives until it fires — a slow producer leaks timer entries proportional to iterations (Go 1.23's garbage-collectible timers soften this, but a time.NewTimer you Stop() explicitly remains the pattern that works on every version, and Goroutine Leaks revisits the failure mode). For a loop, reset one timer:

t := time.NewTimer(idleLimit)
defer t.Stop()

for {
	select {
	case j := <-jobs:
		if !t.Stop() { <-t.C } // drain if it already fired
		t.Reset(idleLimit)
		process(j)
	case <-t.C:
		return nil // idle shutdown
	}
}

Note what default is not appropriate for: a consumer that must wait for work. select with default on a jobs channel returns immediately when empty, so the loop spins at 100% CPU unless the caller sleeps — replacing a parked goroutine with a busy loop, which is strictly worse. Blocking select costs nothing while idle; use default only when "not ready" is itself a meaningful answer.

The Closed-Channel Trick: Always-Ready Cases

A subtlety from Closing Channels turns select into a control-panel: a receive on a closed channel is always ready. Once someone closes done, case <-done: fires on the very next select evaluation — even if the select was already parked, even if it is evaluated a million more times. That permanence is what makes shutdown composable:

func aggregate(in1, in2 <-chan int, done <-chan struct{}) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for {
			select {
			case v := <-in1:
				out <- v
			case v := <-in2:
				out <- v
			case <-done:
				return
			}
		}
	}()
	return out
}

Three structural points separate good shutdown selects from broken ones.

Every blocking operation in the body needs its own escape hatch. The out <- v inside the loop blocks if the downstream consumer is slow — and while it blocks, this goroutine is not selecting on done. The complete version wraps that send too:

case v := <-in1:
	select {
	case out <- v:
	case <-done:
		return
	}

The rule generalizes: any channel operation a goroutine performs while cancellation should be possible must itself be inside a select that includes the done case. One unguarded blocking send is one goroutine that misses shutdown.

Random selection among ready cases prevents starvation but not fairness guarantees. If in1 is always ready and in2 rarely is, each select iteration has an equal chance of picking either among those ready at that moment — in2 does not get priority for having waited. Workloads that need fairness (round-robin over N producers) need explicit rotation logic; select guarantees only that no ready case is systematically starved by source order.

Interaction with nil channels, once directions enter the picture: assigning a case's channel variable to nil disables that case — select blocks on it forever, exactly as if the case were removed. People use this to turn off an input branch at runtime (a stage that stops reading from one source) without rewriting the select. A disabled case contributes nothing to readiness, so the select can still proceed on the others — a nil case alone with no default blocks, same as any permanently unready case.

The mental model: select is a wait on N conditions plus optional escape routes — default for immediate answers, a done channel for broadcast interruption, nil for a branch compiled out at runtime. context.Context later replaces hand-rolled done channels with a standard one, but every context-aware function you read is built on exactly this shape: select { case <-ctx.Done(): ...; case work <- job: ... }.

Control Flow Traps Inside select

select borrows switch's case syntax but not its fall-through rules, and two control-flow surprises account for most review comments.

break inside select does not break the loop. It breaks the select — which already ended when the case body finished. So this shutdown check does nothing:

for {
	select {
	case <-done:
		break // exits the SELECT, not the for — loop continues forever
	case j := <-jobs:
		process(j)
	}
}

The loop spins on, immediately selects done again (it is closed, hence always ready), and breaks the select again — a hot infinite loop instead of a shutdown. The fix is return, or if the select sits inside a labeled loop you need to leave:

workers:
	for {
		select {
		case <-done:
			break workers // labeled break leaves the for
		case j := <-jobs:
			process(j)
		}
	}

Most code sidesteps the issue by making the case body return outright; reach for a labeled break only when work must continue after the loop.

A nil channel in a case is disabled, not broken. This looks like a bug and behaves like a feature:

var pause <-chan int // nil: this case can never become ready

for {
	select {
	case v := <-active:
		process(v)
	case v := <-pause: // never selected
		process(v)
	case <-done:
		return
	}
}

Assigning pause = someChannel later enables the branch mid-flight; assigning it back to nil disables it again. Structs with directional fields (Directional Channels) use this to make optional inputs — a nil input channel means "this stage has no second source," and the compiler plus select semantics enforce it instead of an if src != nil wrapper around the whole receive.

The related trap is the reverse: a nil channel you meant to assign. var out chan<- Result passed without initialization makes every case out <- r: permanently unready — the select quietly waits on the other branches and your results vanish into a branch that never fires. Because nil cases block silently rather than panic, this fails as lost data or hung shutdown, never as a crash. The debugging habit worth building: when a select behaves as if a case "isn't wired up," print the channel variables — a nil pointer there explains more select mysteries than any other single check.

Quiz

1. A select has two cases and both channels are ready at the same time. Which one runs?

Question 1 options

2. A worker loop reads jobs with `select { case j := <-jobs: ...; default: idle() }` where jobs is an unbuffered channel with an idle producer. What is the outcome?

Question 2 options

3. Inside `for { select { case <-done: break; case j := <-jobs: process(j) } }`, done has been closed. What happens?

Question 3 options

4. A select's case reads `case v := <-secondary:` where secondary is a nil channel variable. What does select do with it?

Question 4 options

Flashcards

Question

When does select choose a case, and what happens if none is ready?

Answer

It evaluates all cases and runs one that is ready — chosen uniformly at random if several are. With no default branch it parks until any case becomes ready, consuming no CPU; with a default branch it runs default immediately when nothing is ready at that instant, making the select a non-blocking poll.

Question

Why is `case <-done:` effective for shutdown, and what is the one rule it imposes on surrounding code?

Answer

A receive on a closed channel is always ready, so once the caller closes done the case fires on the very next select — broadcasting shutdown to any waiter. The rule: every other blocking channel operation the goroutine performs must itself sit in a select watching done, or it will park without ever noticing the signal.

Question

How do you implement a non-blocking send in Go, and when should you avoid one?

Answer

Wrap the send with a default branch: `select { case ch <- v: ok = true; default: ok = false }` — it succeeds only if a receiver or buffer space is available right now. Avoid it for consumers that must wait for work, because an empty queue then falls through to default on every iteration and turns a parked goroutine into a busy loop.

Question

What is wrong with `time.After` inside a long-running select loop?

Answer

Each iteration creates a fresh timer that stays alive until it fires, so with a fast producer the loop accumulates pending timers for its whole lifetime — memory that scales with iterations rather than with configured timeouts. In a loop, create one time.NewTimer, Stop-and-Reset it each iteration, and defer the Stop; save time.After for one-shot request paths.

Question

What does assigning nil to a select case's channel accomplish at runtime?

Answer

It disables that case: a nil channel is never ready, so select behaves as though the branch were removed while the other cases keep working. This lets optional inputs be switched on and off by reassigning the variable — no restructuring of the select required — and it fails safe only if at least one other case or a default can still proceed.

Revision Notes

Key Takeaways

  • 1. select waits on many operations at once and executes one ready case, chosen randomly to avoid starvation
  • 2. default converts select from a waiting construct into an immediate poll — misuse it in idle loops and you get a busy spin
  • 3. A closed channel's receive case is always ready, which is why close composes with select as a broadcast shutdown
  • 4. Every blocking send or receive in a cancellable goroutine must sit in a select that also watches the done channel
  • 5. nil disables a select case and a stray `break` exits only the select — both fail silently rather than crashing

Interview Tips

  • • "Write a goroutine that reads from a channel or times out" — single select on the channel and `time.After`, then immediately discuss the loop variant and why you'd switch to a reusable timer
  • • "How does select prevent one channel from starving another?" — uniform random choice among ready cases; be ready to add that fairness over long windows still needs rotation when one source is almost always ready
  • • "Your worker doesn't shut down on cancel — where do you look?" — find the unguarded blocking operation outside a select (usually the results send); show the nested select fix
  • • "What does a nil channel do in a select?" — disables the case; follow by using it deliberately for optional pipeline inputs, which demonstrates intent rather than recollection

Cheat Sheet

select and Non-Blocking Communication

Readiness rules:

Form Behavior
select, 1+ ready cases picks one at random among ready
select, none ready, no default parks (no CPU used)
select, none ready, with default runs default immediately
case on nil channel case disabled forever
case on closed channel always ready — fires every evaluation
break inside select ends the select only; use return or a label to leave the loop

Non-blocking send: select { case ch <- v: /* sent */ default: /* full or no listener */ }

Loop-safe timeout: one time.NewTimer + Stop/Reset per iteration; reserve time.After for one-shot handlers.

Code template (work or cancel):

for {
	select {
	case j, ok := <-jobs:
		if !ok { return }
		select {
		case results <- run(j):
		case <-done:
			return
		}
	case <-done:
		return
	}
}

Continue Learning