Lesson 04 / 14
select Statement
The select statement is the language's answer to waiting on more than one channel at the same time: when only one branch is ready, that branch is chosen deterministically; when none are ready and there is a default branch, that branch is chosen. Source order carries no priority. Bounding measurement: when two branches are ready at the same time, selection is random, and this case is not run, only modeled — randomness is the language's own decision.
Published
In this lesson
- Shows that select deterministically chooses the one branch that is ready, and that the branches' source order carries no priority
- Proves, by running it, that the default branch is chosen when no branch is ready, and how select is used as a non-blocking control tool
- Shows that selection is random when two branches are ready at the same time, and that this case is modeled, not run
Contents
The previous lesson measured where a channel’s capacity starts blocking, and used select
with default only to poll a single channel. But a goroutine often waits for news from
more than one source at the same time: one of several result channels might be ready, a
cancellation signal might arrive, a timeout might run out. The select statement exists for
exactly this — the one structure that waits on more than one channel at the same time and
handles whichever becomes ready first.
This need shows up everywhere that waiting on a single channel is not enough. When a
goroutine is only waiting on one channel, writing <-channel is enough; but a goroutine that
wants to handle whichever of two separate sources arrives first cannot write this with a
single receive expression. select fills exactly this gap.
A select block contains more than one case, each starting with a channel operation. The
statement picks one of the ready operations and runs that branch; if none is ready and there
is a default branch, that runs immediately, otherwise select waits until at least one
branch is ready. This lesson measures how this choice gets made, where it is deterministic,
and where it is random.
Every branch of select can be a send or a receive; within the same select, some branches
can be sends and others receives, what matters is only which one can complete at that
moment without blocking. This is the first difference that sets select apart from a
switch: a switch compares a value against its branches in sequence, while select tries
every branch’s own channel operation at the same time and decides based on which one is
ready. Writing a case that is not a channel operation does not compile; select’s branches
always start with a send or a receive.
The language sets no upper limit on how many branches a select can have; a select block
can wait on two channels or on ten. This lesson only uses two-channel examples, because the
rules being measured — deterministic selection, order independence, randomness — do not
depend on the number of branches; two branches are enough to show these rules and are the
easiest case to read. As the number of branches grows, the rules being measured do not
change, only the number of branches that can be ready at the same time grows; the third
section’s rule about randomness also stays valid in exactly the same way when more than two
branches are ready.
Selection Is Deterministic When Only One Branch Is Ready
If only one of the branches inside a select is ready, select runs that branch without
considering any other option. If none are ready and there is a default branch, select
falls to it without waiting at all — this is exactly the behavior used to poll capacity in
the previous lesson.
// singleanddefault.go — selection is deterministic when only one branch is ready, default is chosen when none are
package main
import "fmt"
func choose(a, b chan string) string {
select {
case v := <-a:
return "a: " + v
case v := <-b:
return "b: " + v
default:
return "default: neither is ready"
}
}
func main() {
a := make(chan string, 1)
b := make(chan string, 1)
fmt.Println("when neither is ready :", choose(a, b))
a <- "first value"
fmt.Println("when only a is ready :", choose(a, b))
b <- "second value"
fmt.Println("when only b is ready :", choose(a, b))
}
when neither is ready : default: neither is ready when only a is ready : a: first value when only b is ready : b: second value
GC14. — All three calls call the same choose function, with the same source code; the
only thing that changes is the fullness of the channels. In the first call, both are empty,
and default is chosen. Once a value is placed on a, the second call chooses the a
branch and, in doing so, consumes the value on a — a is now empty again. In the
third call, with only b full, the b branch is chosen. In no call were more than one
branch ready at the same time; this is why all three results are strictly repeatable.
It matters that this measurement can be done inside a single goroutine, requiring no
concurrency at all: values are placed on the a and b channels directly from the main
goroutine, no goroutine is started. This shows that select itself is not a construct
specific to concurrency — select is only a language feature that chooses among more than
one channel operation; whether or not there is a goroutine on the other side of the channels
does not change select’s own behavior. In later lessons select will always be used
between goroutines, but this lesson shows its core behavior in its plainest form, inside a
single goroutine.
This distinction looks small but matters: select’s deterministic behavior comes not from
timing, only from which channels are full at that moment. Had the same code filled the
channels in a different order, or run on a different machine, the result would still be the
same — because what decides it is not timing, but the fullness state itself.
Source Order Is Not a Priority
select’s branches carry no top-to-bottom priority order — this is a fundamental
difference from an ordinary if/else if chain or a switch. Whichever branch is ready
gets chosen, no matter what order it was written in; source order exists only for
readability, it does not affect runtime behavior.
// orderdoesnotmatter.go — the source order of select branches does not decide which branch is chosen
package main
import "fmt"
func choose(a, b chan string) string {
select {
case v := <-b:
return "b: " + v
case v := <-a:
return "a: " + v
}
}
func main() {
a := make(chan string, 1)
b := make(chan string, 1)
a <- "only a filled"
fmt.Println(choose(a, b))
}
a: only a filled
GC15. — In this function, the b branch is written first in the source, the a
branch second. When run with only a full, the result is still a: only a filled —
showing that the order in the source does not change the outcome. Every case in a select
is weighed equally; whichever branch is ready is the one that runs. This raises the next
section’s question: what would happen if two branches were ready at the same time, given
that order carries no priority?
This behavior comes from how select evaluates its branches. The order written in the
source is only the order the compiler reads the cases in; at runtime, select evaluates
all of the branches at once, at the moment it runs, finds the ones that are ready, and
then chooses among those. In an ordinary if a != nil { ... } else if b != nil { ... }
chain, the first branch whose condition holds would win — there, source order really is a
priority. select does not follow that model; this difference is one of the points most
often confused when learning select, because its syntax looks a lot like a switch, but
its evaluation rule is different.
Bounding Measurement: When Two Branches Are Ready at Once, Selection Is Random
Every measurement so far has set up situations where at most one branch is ready at any
given moment. What rule does the language apply when two branches are ready at the same
time? Go’s own specification gives a clear answer to this question: when more than one
branch is ready at the same time, select chooses uniformly at random among them. This
is a deliberate exception to the determinism rule every measurement in this course has
rested on up to this lesson — and this is why this lesson does not run this case, only
models it.
// modeledonly.go — when two branches are ready at once selection is random, this lesson does not run this, only shows it
package main
func chooseWhenBothReady(a, b chan string) string {
select {
case v := <-a:
return "a: " + v
case v := <-b:
return "b: " + v
}
}
GC16. — This function shows exactly the situation select faces when called with both
a and b full: the compiler makes no fixed preference between the two branches here, the
runtime chooses randomly again on every call. chooseWhenBothReady is never called anywhere
in this lesson — had it been called, it could have produced different results between two
runs, and this would deliberately break the “same output on every run” condition this
course’s verification looks for. The function compiles only because it exists; the
source itself shows where the randomness shows up, not a run of it.
This randomness is not a shortcoming, it is a deliberate design decision. If select always
chose the first branch in the source between two ready branches, programmers would, over
time, start using this order like a priority rule, and this could lead to one of the channels
being systematically starved — if the same two branches stayed ready one after another, the
second branch might never run at all. Random selection removes this risk at the language’s
own level: no branch can systematically get ahead just because it was written first in the
source.
This unmeasurability is the actual reason behind the previous section’s finding that “source order is not a priority.” Had order carried a priority, which branch gets chosen when two are ready at the same time would still be deterministic — the one written first would win. Because order carries no weight at all, there is no real rule left to decide between two branches that are both ready at once; the language fills this gap with randomness, a different path from the “defined” class seen in the Go Fundamentals course: there, a fixed value was placed in for something left unwritten, here, a value drawn again every time is placed in for something left unwritten. This lesson does not carry this distinction forward as an axis, it only notes it once.
Because this lesson never calls chooseWhenBothReady, the only way to show the difference
between two runs is to read the source. This is the one place, unlike every other lesson in
this course up through this one, where the claim “the same source can give a different result
on different runs” is proven without running it: Go’s own specification guarantees that
the randomness exists, this lesson only shows, on the source itself, where that guarantee
takes effect.
select Is a Non-Blocking Control Tool
The default branch turns select into more than a polling tool, into a control tool that
keeps the flow moving without ever stopping it. This can be used to drain every ready value
in a channel until none are left — the moment the channel empties, the loop stops itself
without waiting.
This use separates two different jobs select does. In the first section’s use, select
was making a choice among more than one channel — deciding which source was ready. Here,
select is used on a single channel, to ask whether that channel is ready; the presence
of the default branch is the language’s way of saying “do not wait if it is not ready.”
Both uses share the same syntax, but their purposes differ: one decides among more than one
source, the other reads a single source’s instantaneous state.
// nonblockingcontrol.go — select is used to keep the flow moving without waiting on a channel that is not ready
package main
import "fmt"
func tryToReceive(channel chan int) (int, bool) {
select {
case v := <-channel:
return v, true
default:
return 0, false
}
}
func main() {
results := make(chan int, 3)
results <- 10
results <- 20
total, count := 0, 0
for {
v, ok := tryToReceive(results)
if !ok {
break
}
total += v
count++
}
fmt.Println("values received without blocking from the channel:", count, "total:", total)
_, ok := tryToReceive(results)
fmt.Println("attempt once the channel is empty :", ok)
}
values received without blocking from the channel: 2 total: 30 attempt once the channel is empty : false
GC17. — Only two values were placed on the results channel; the loop drains these two
with tryToReceive, and because ok returns false on the third attempt, the loop stops on
its own — no goroutine was waited for, no order was left to the scheduler. This setup lets a
channel be asked “what do you have for me right now”; if the channel itself is empty, the
asking side can move on without waiting. This is the general form of the technique used to
poll buffer capacity in the previous lesson: there, whether a single channel was full was
being asked, here the same technique is used to drain a channel.
The loop’s stopping condition also stands out: the for loop does not stop based on a fixed
number, it stops based on the second value tryToReceive returns. How many times it will
loop cannot be read from the source code — no matter how many values were placed on the
results channel, the same loop runs and stops at exactly that many steps. This differs from
walking a slice with range: how many times range will loop can be read in advance
from the slice’s length, while how many times this loop will run is only learned by running
it. This difference becomes even clearer when compared against the range over channel
behavior the closing lesson measures: range counts a channel’s closing as its stopping
condition, while the loop here counts the channel’s current fullness as its stopping
condition — both produce a finite loop, but based on a different signal.
This way of using it has a cost: tryToReceive does not wait for a goroutine to produce,
it only takes whatever is ready at that moment. If nothing has been written to the channel
yet, tryToReceive always returns false — even if the producing goroutine is about to send
a value a microsecond later. A non-blocking control tool loses waiting in return; which one
is needed, when select should be allowed to wait and when it should move on immediately, is
a design decision.
This danger becomes even clearer with the wrong use of putting a default branch inside a
loop: if the goal is to wait for a value to arrive, and the loop retries immediately every
time it falls to default, the program waits without ever blocking, by keeping the processor
continuously busy — this is select‘s counterpart of the busy waiting pattern set up in
the context of locks and critical sections in the Operating System Concepts course. This
lesson does not set up that pattern: tryToReceive is called once, not waited on in a loop.
When waiting is actually needed, the right tool is a select with no default — then
select itself blocks until a branch is ready and does not keep the processor busy; this
would be what the first section’s choose function looks like written without a default
branch.
Summary
selectdeterministically chooses the one branch that is ready, among more than one channel operation, only when exactly one branch is ready.- The branches’ order in the source carries no priority; whichever branch is ready gets chosen no matter what order it was written in.
- When no branch is ready and there is a
defaultbranch, it runs immediately;selectturns this way into a polling and control tool that never waits. - When two branches are ready at the same time, selection is uniformly random; this is a deliberate exception to the determinism rule this course measures, and it is therefore not run, only modeled.
select+defaultcan be used to drain a channel of whatever values it holds, with no goroutine waited for, until none are left.- A
selectwith nodefaultblocks when no branch is ready and does not keep the processor busy; this is the right tool when waiting is actually needed, not a loop withdefault.
Next Step
This lesson never touched on how a closed channel behaves inside select. A receive from a
closed channel makes a select branch ready right away, exactly like a receive from a full
channel — but the answer that comes back is not the measured value itself, it is the news
that the channel will now stay empty. The next lesson measures this last behavior:
closing a channel is a termination signal, and it measures how that signal shows itself in
both range and a direct receive.
To keep your progress and take notes, Log in
My notes
Log in to take notes.