select waits across several completion sources and runs the body of the first
one that becomes ready.
fn fetch_left(): int { println("left") return 1 }
fn fetch_right(): int { println("right") return 2 }
fn first_answer(): int {
left := spawn { fetch_left() }
right := spawn { fetch_right() }
mut chosen := 0
select {
value := left => chosen = value
value := right => chosen = value
}
return chosen
}Exactly one arm body runs. The other sources keep running and stay yours.
select is a statement
It evaluates to void. Results leave through enclosing mutable state, or by
returning from an arm body — never as the value of the select itself.
fn work(): int { return 1 }
fn pick(): int {
task := spawn { work() }
chosen := select {
value := task => value
}
return chosen
}That reports ATOLL2002: expected 'int', found 'void'. The two working shapes
are a mutable local, as above, or a direct return:
fn fetch_left(): int { return 1 }
fn fetch_right(): int { return 2 }
fn first_answer(): int {
left := spawn { fetch_left() }
right := spawn { fetch_right() }
select {
value := left => return value
value := right => return value
}
return 0
}Arm bodies are ordinary statements: they can return, break, continue,
raise an error, or mutate enclosing state — wherever those are legal in the
surrounding context.
Arm structure
pattern := source => bodyThe source must satisfy the compiler’s structural Selectable[T] protocol —
raw_handle(): usize plus await(): T. Built-in Task[T] and Stream[T] both
do. Source expressions are evaluated once, before the wait begins, so bind
anything non-trivial to a named local first.
The binding pattern is checked against the source’s produced type. Use _ when
the arm cares that the source fired, not what it carried:
fn work(): int { return 1 }
fn drain_signal(): int {
events := Stream.new[int](8)
shutdown := spawn { work() }
mut total := 0
mut running := true
for running {
select {
v := events => total = total + v
_ := shutdown => running = false
}
}
return total
}For a task, selection consumes and reaps the winning task. For a stream, it receives the next value and leaves the stream usable for the next iteration. Do not place the same single-consumer task in two arms — both refer to one result and one reaping obligation.
Selecting inside a loop
The loop-plus-select shape is how a coordinator multiplexes long-lived sources.
break from an arm body exits the loop, not just the selection.
fn work(n: int): int { return n }
fn wait_for_one(): int {
task := spawn { work(9) }
mut got := 0
for {
select {
value := task => {
got = value
break
}
}
}
return got
}default arms
Adding default makes the selection non-blocking. The runtime scans the sources
once; if none is ready, it runs default instead of registering waiters and
parking.
fn handle(message: int): void { println("message ${message}") }
fn bounded_work(): void { println("tick") }
fn poll_loop(rounds: int): void {
inbox := Stream.new[int](8)
mut i := 0
for i < rounds {
select {
message := inbox => handle(message)
default => bounded_work()
}
i = i + 1
}
}This is the poll-and-work pattern: drain what is ready, otherwise do a slice of
background work. Keep the default body bounded. A tight loop whose default
does nothing and reaches no other safepoint starves everything else on the
scheduler.
default is also how you write a non-blocking “is anything ready?” check without
committing to a wait.
Losing sources are still yours
Selection removes the temporary wait registrations. It does not cancel the
sources it did not pick — that is the whole difference from
race, which owns its arm tasks and cancels the
losers.
With two alternatives, a boolean ledger is enough:
fn fetch_left(): int { return 1 }
fn fetch_right(): int { return 2 }
fn use_value(v: int): void { println("got ${v}") }
fn pick_one(): void {
left := spawn { fetch_left() }
right := spawn { fetch_right() }
mut left_won := false
select {
value := left => {
left_won = true
use_value(value)
}
value := right => use_value(value)
}
if left_won {
right.cancel()
} else {
left.cancel()
}
}The winner has already been consumed — never await or cancel it again.
Beyond two arms, use an explicit enum ledger. A boolean stops scaling the moment a third source appears:
enum Winner {
Cache
Replica
Origin
}
fn from_cache(): int { return 1 }
fn from_replica(): int { return 2 }
fn from_origin(): int { return 3 }
fn lookup(): int {
cache := spawn { from_cache() }
replica := spawn { from_replica() }
origin := spawn { from_origin() }
mut winner := Winner.Cache
mut value := 0
select {
v := cache => {
winner = Winner.Cache
value = v
}
v := replica => {
winner = Winner.Replica
value = v
}
v := origin => {
winner = Winner.Origin
value = v
}
}
match winner {
Winner.Cache => {
replica.cancel()
origin.cancel()
}
Winner.Replica => {
cache.cancel()
origin.cancel()
}
Winner.Origin => {
cache.cancel()
replica.cancel()
}
}
return value
}The ledger is application state. select returns void deliberately: it makes
the “who won, and what do I owe them” question explicit rather than hiding it in
a result value.
Failures in arm bodies
An arm body can propagate an error out of the enclosing function like any other statement.
error ArmError { Bad }
fn risky(): int ! ArmError {
error Bad
}
fn run(): int ! ArmError {
task := spawn { risky() }
mut got := 0
select {
result := task => got = result?
}
return got
}Note that result here is the Result[int, ArmError] the child produced; the
? inside the arm body applies the enclosing function’s error policy.
Fairness
Selection is scheduling-dependent. When several sources are already ready, the runtime’s scan order may consistently favour an earlier arm. Do not write correctness logic that assumes statistically fair rotation — if you need round-robin, keep a cursor in application state.
Empty selections
select {} is accepted as a void no-op. It does not suspend and introduces no
effects.
fn nothing(): void {
select {}
}It is rarely what you want: an empty selection usually means the arm set is being constructed dynamically, which belongs in a higher-level abstraction.
Effects
At runtime a selection waits and consumes one winner. It spawns nothing and
cancels nothing. The current checker is more conservative and records Spawn,
Suspend, and Cancel for any non-empty selection — including one with a
non-blocking default arm. Treat that row as an analysis over-approximation, not
as a description of behaviour, and declare it when you annotate a function that
contains a select.
Checklist
Before entering a selection:
- bind each source to a named local exactly once;
- know which sources are single-use (
Task) and which are reusable (Stream); - decide how end-of-stream or shutdown is represented;
- decide what happens to every loser — cancel, keep, or hand off;
- do not assume fair arm rotation;
- keep any
defaultbody bounded; - make sure arm-body errors fit the enclosing function’s error contract.
Reach for race instead when the construct itself
should own the competitor tasks and cancel the losers for you.