When you have a single channel, a simple send or receive is enough. But real concurrent programs rarely deal with just one channel — a goroutine might be waiting for data from multiple sources, or it might need to give up if nothing arrives within a deadline. Go's select statement exists precisely for this: it lets a goroutine wait on multiple channel operations at once and react to whichever one becomes ready first.
The select statement
At a glance, select looks like switch. Both use case clauses, both execute the matching branch, and both compile down to a decision tree. The syntax really is similar:
Each case must be a channel operation — either a send (ch <- value) or a receive (value := <-ch, <-ch). Unlike switch, you cannot put arbitrary boolean expressions in select cases.
Switch vs select
The resemblance to switch is mostly cosmetic. The two constructs behave very differently:
switch | select | |
|---|---|---|
| Cases are | Evaluated top-to-bottom | Evaluated simultaneously |
| Branches on | First true condition | First ready channel |
| Blocks | Never (unless channel op in case) | Until at least one case is ready |
| Default | Optional short-circuit | Optional non-blocking escape |
The critical difference is simultaneous evaluation. In a switch, Go checks each case in order and stops at the first match. In a select, Go checks all cases at the same time and picks among the ones that are ready.
Both channels already have a value, so both cases are ready immediately. A switch would always pick ch1 because it's listed first. A select makes no such guarantee.
Simultaneous evaluation and pseudo-random selection
When multiple cases are ready at the same time, Go picks one using pseudo-random uniform selection — every ready case has an equal probability of being chosen. This is a deliberate design decision, not an implementation detail. If select always favored the first ready case, programs that relied on it would work correctly in tests but starve lower-priority channels under load. Randomness prevents systematic bias.
Running this a few times will produce different orderings. Neither channel dominates.
The default case
If no channel is ready and the select has a default clause, Go executes it immediately instead of blocking. This turns select into a non-blocking operation:
This is useful for polling — checking whether data is available without committing to wait for it. Be careful, though: a select with a default inside a tight loop becomes a busy-wait that burns CPU. Use it only when you genuinely want to proceed without data, not as a substitute for proper synchronization.
Timeouts with time.After
Blocking forever is usually wrong. A goroutine waiting on a channel that never sends will leak. time.After returns a channel that receives a value after the specified duration, making it a natural fit for select timeouts:
If ch delivers a message within two seconds, the first case runs. If two seconds pass with nothing, time.After's channel fires and the second case runs. The goroutine never blocks indefinitely.
The for-select pattern
A single select decides once and exits. Most goroutines need to keep reacting — reading from a stream, processing work items, or watching for a shutdown signal. The idiomatic way to do this is for-select: an ordinary for loop whose body is a select.
The goroutine spins in the loop, blocking at select each iteration until either a job arrives or the done channel signals shutdown. This pattern appears throughout the Go standard library and is the foundation of most long-lived concurrent goroutines.
A few things to notice in the example:
- The
okidiom (j, ok := <-jobs) detects a closed channel. When a channel is closed and drained,okisfalse. Checking it prevents the goroutine from looping forever on zero values. - The
donechannel usesstruct{}— an empty struct has zero size and is the conventional type for signal-only channels that carry no data. - Both
returnstatements are explicit. Abreakinside aselectonly breaks out of theselect, not the enclosingfor. To exit the loop from within aselect, usereturn, orbreakwith a labeled outer loop.
Putting it together
Here is a small self-contained example that combines for-select, a timeout, and a done signal to show how the pieces interact:
generate sends incrementing integers until done is closed. main reads them in a for-select loop, and after 100 milliseconds the timeout case fires, closes done, and exits. The generator goroutine detects the closed done channel in its own for-select and exits cleanly.
The select statement — especially inside a for loop — is the primary tool for building responsive, cancellable goroutines. Every goroutine you launch should have a way to exit; the done channel pattern shown here is one of the most common ways to provide that exit path. Without it, goroutines that block indefinitely become leaks — a silent, growing drain on memory that is easy to introduce and surprisingly hard to notice. That problem, and how to solve it systematically, is the subject of the next article.