Skip to content

Select

Wait across tasks and streams, run exactly one arm, and account for every source you did not pick.

Updated View as Markdown

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 => body

The 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:

  1. bind each source to a named local exactly once;
  2. know which sources are single-use (Task) and which are reusable (Stream);
  3. decide how end-of-stream or shutdown is represented;
  4. decide what happens to every loser — cancel, keep, or hand off;
  5. do not assume fair arm rotation;
  6. keep any default body bounded;
  7. 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close