When a task must wait on several channels at once, what does a select (multi-way choice) construct do, and what happens if more than one channel is ready at the same moment?
answer
- guarded choice: wait on many, commit to one
- several ready -> random pick, no priority
- timer arm = timeout; cancel arm = shutdown
- default arm = non-blocking poll, spins in a loop
- closed channel is always ready -> hot loop
basics
~20 sSelect waits on several communication operations at once and proceeds with whichever becomes ready, running only that branch. If several are ready it picks one nondeterministically, usually at random for fairness. An optional default branch makes it a non-blocking poll.
solid answer
~60 sSelect is a guarded-choice construct: you list several communication operations — receives, and usually sends too — and the task blocks until at least one can proceed, then executes exactly that one branch. Without it you would have to poll channels in a loop, which either spins or blocks on the wrong channel while another has data. If several branches are ready the choice is nondeterministic. Implementations usually randomize rather than take the first listed, because a fixed priority order lets a busy channel starve a quiet one. Do not rely on which branch wins; if you need priority, write it explicitly, for example by first doing a non-blocking select on the high-priority channel and falling back to the full select. The pattern makes three things expressible that a single receive cannot: timeouts, by including a timer channel as one arm; cancellation, by including a done/cancel channel that every long-running task selects on; and merging, by selecting over several inputs. A default branch turns select into a poll — useful for try-send, dangerous inside a loop because it becomes a busy spin.
code
text · 11 linesloop:
# first give the urgent channel a chance, without waiting
select:
case v = receive(urgent): handle(v); continue
default: pass
# otherwise wait for anything
select:
case v = receive(urgent): handle(v)
case v = receive(normal): handle(v)
case receive(cancel): returngo deeper
Say it waits on multiple channels and runs the branch for whichever becomes ready first; mention that a timeout is just another channel.
Add that ties are broken nondeterministically for fairness, that only the chosen arm consumes a value, and what a default branch does.
Discuss cancellation discipline — every blocking point selects on a done channel — and the closed-channel hot loop, plus how to implement priority explicitly.
Frame select as the readiness primitive that makes channel topologies composable, and reason about starvation policy: default fairness versus explicit priority and the shedding it implies.
## What the construct is Select descends from Dijkstra's guarded commands: several alternatives, each guarded by a condition, with the runtime free to take any alternative whose guard holds. In channel languages the guard is can this communication proceed right now. The task offers a set of operations, blocks until at least one is possible, commits to exactly one, and runs its body. All other offers are withdrawn — nothing is consumed from the channels that were not chosen, which is what makes select safe rather than a lossy peek. select: case v = receive(work): handle(v) case receive(cancel): cleanup(); return case receive(timer(5s)): log("idle"); continue default: doSomethingElse() # optional: never blocks ## Nondeterminism and fairness When more than one arm is ready the model deliberately does not say which is taken; that is the same nondeterminism a scheduler has. Practical runtimes randomize the choice. The reason is starvation: if selection always preferred the first listed arm, a channel that is always ready would monopolize the loop and a low-traffic arm — often the cancel channel — might never be observed. So two rules follow. First, correctness must not depend on selection order. Second, if you genuinely need priority, express it: attempt a non-blocking select on the urgent channel, and only if it yields nothing perform the blocking select over all arms. Understand what you have bought — strict priority can starve the low-priority arm, which is why it is not the default. ## The three patterns that make it essential **Timeouts.** A plain receive waits forever. Adding a timer channel as another arm turns waiting into bounded waiting, with no special timeout parameter needed on the channel API. The timeout is just another event. **Cancellation.** Long-running tasks take a cancel or done channel that is closed when the work should stop. Every blocking point in the task becomes a select over the real operation and the cancel channel, so the task can unwind promptly instead of being stuck in a receive nobody will satisfy. This is how a channel program avoids leaking tasks on shutdown. **Merging and multiplexing.** A stage that consumes from several producers, or that must simultaneously accept new work and deliver results, needs to be ready for either direction at once. A select containing both a receive arm and a send arm expresses can either accept or emit, whichever the world allows first, which single-direction blocking cannot. ## Pitfalls The default arm makes select non-blocking. In a loop that is a busy-wait burning a core; use it only for genuine try-send/try-receive at a single point, or pair it with an explicit wait. A received value is consumed. If a branch takes an item and then discovers it cannot handle it, that item is gone unless the code re-sends it, which can reorder the stream. Re-creating a timer inside the loop restarts the timeout on every iteration, which is usually what you want for an idle timeout and wrong for a deadline; a deadline must be created once outside the loop. A closed channel is permanently ready to receive, yielding the closed indication immediately. A select whose arm reads a closed channel therefore spins at full speed unless the branch removes that arm from consideration, for example by setting its channel variable to a value the select treats as never-ready. This is the classic hot-loop bug in merge implementations. ## Mental model Select is to channels what waiting on several file descriptors is to I/O readiness: one blocking point, many possible wakeups, exactly one of them consumed. It converts a program that must guess which channel to wait on into one that reacts to whichever event happens first.
- A worker loop selects over an input channel and a cancel channel, and after the producer closes the input the loop starts burning 100% CPU. What happened?A closed channel is permanently ready to receive and immediately yields the closed indication, so that arm fires on every iteration with no work to do. The fix is to stop selecting on it once closed: either return from the loop when the receive reports closure, or replace the channel variable with one that can never become ready so the remaining arms still work.
saying these in an interview costs you the question
- Assuming branches are tried in the order they are written, so the first listed arm has priority.
- Adding a default branch inside a loop and creating a busy-wait.
- Thinking a non-chosen arm still consumes or peeks its value — only the chosen communication happens.
- Recreating a timeout timer each iteration and calling it a deadline.