race is a value expression. It starts every arm as a child task, waits for one
to complete, cancels the others, and evaluates to the winner’s value.
fn from_cache(key: int): int {
println("cache lookup ${key}")
return key
}
fn from_origin(key: int): int {
println("origin lookup ${key}")
return key + 1
}
fn lookup(key: int): int {
return race {
from_cache(key)
from_origin(key)
}
}Arms are expressions in a brace block with no separators — one per line reads best. Commas are accepted but add nothing:
fn a(): int { return 1 }
fn b(): int { return 2 }
fn either(): int {
return race { a(), b() }
}Every arm starts before the wait begins, so their side effects overlap and must be safe even for an arm that later loses.
Result types
Arms with the same type unify into that type. That is the case you want for public code.
struct Response {
status: int
body: string
}
fn fetch(host: string): Response {
println("fetching from ${host}")
return Response { status: 200, body: "ok" }
}
fn fastest(): Response {
winner: Response = race {
fetch("primary")
fetch("replica")
}
return winner
}Heterogeneous arms widen to an anonymous union under the same rules as any other branching expression:
fn as_number(): int { return 1 }
fn as_text(): string { return "one" }
fn describe(): string {
winner: int | string = race {
as_number()
as_text()
}
match winner {
n: int => return "number ${n}"
s: string => return s
}
}Prefer a single explicit arm type at an API boundary. A caller should not have to match on which implementation strategy happened to win.
Completion, not success
The current lowering is:
- spawn every arm;
- select the first task to complete;
- cancel every losing task;
- await and return the winner.
That is a completion race. If the first arm to finish fails, its failure wins — even when another arm would have succeeded a moment later.
error FetchError { Timeout, Refused }
fn primary(): int ! FetchError {
println("primary")
return 1
}
fn replica(): int ! FetchError {
error Timeout
}
fn fetch(): int ! FetchError {
outcome: Result[int, FetchError] = race {
primary()
replica()
}
return outcome?
}replica returns immediately, so it is very likely to win, and outcome?
propagates Timeout while primary is cancelled mid-flight.
“First success” is a different algorithm and is not what race does. Build
it explicitly: spawn the candidates yourself, select in a loop, keep the
failures, and stop when a success arrives or the candidates run out.
error FetchError { Timeout }
fn attempt(candidate: int): int ! FetchError {
if candidate % 2 == 0 { error Timeout }
return candidate * 10
}
fn first_success(candidates: []int): int {
mut running: []Task[Result[int, FetchError]] = []
for c in candidates {
running.add(spawn { attempt(c) })
}
mut answer := -1
mut failures := 0
for task in running {
match task.await() {
Ok(value) => {
if answer < 0 { answer = value }
}
Err(err) => failures = failures + 1
}
}
println("failures: ${failures}")
return answer
}That version costs full fan-out but never mistakes the fastest failure for the answer.
Identifying the winner
The result is a value, not a label. If the caller needs to know which arm won, put the identity in the value:
enum Source {
Cache
Origin
}
struct Answer {
source: Source
value: int
}
fn from_cache(): Answer {
return Answer { source: Source.Cache, value: 1 }
}
fn from_origin(): Answer {
return Answer { source: Source.Origin, value: 2 }
}
fn lookup(): int {
winner := race {
from_cache()
from_origin()
}
match winner.source {
Source.Cache => println("served from cache")
Source.Origin => println("served from origin")
}
return winner.value
}Cancellation of losers
Losers are cancelled for you — this is the reason to reach for race instead of
select, which leaves its sources alone.
Cancellation is cooperative, so a losing task stops at a scheduler or suspension
safepoint, not at an arbitrary instruction. Its owned locals still get cleanup
paths, and a Drop implementation still runs:
struct Lease {
id: int
}
impl Drop for Lease {
fn drop(self): void {
println("releasing lease ${self.id}")
}
}
fn slow_path(id: int): int {
lease := Lease { id: id }
println("slow path holding ${lease.id}")
return lease.id
}
fn fast_path(id: int): int {
return id
}
fn lookup(id: int): int {
return race {
fast_path(id)
slow_path(id)
}
}If fast_path wins, slow_path is cancelled and its Lease is still released.
Avoid irreversible external side effects before the point where an arm is safe to abandon, or make those operations idempotent. A cancelled arm may have already sent a request, written a row, or charged a card.
Empty races
race {} is accepted as a degenerate void expression with no runtime work.
fn nothing(): void {
race {}
}The checker records the race effect row before recognizing the empty case, so it
still carries Spawn, Suspend, and Cancel. Require at least one arm in real
code.
Effects
A non-empty race contributes all three concurrency effects:
Spawn, because each arm becomes a child task;Suspend, because the caller waits for a winner;Cancel, because the losers are stopped.
Ordering
When multiple arms are already complete at the readiness scan, an earlier handle
can win. race is not a randomness primitive and promises nothing about the
distribution between equally fast arms.
Choosing between the three constructs
| You want… | Use |
|---|---|
| Every result | spawn each, then await each |
| One interchangeable result, losers stopped | race |
| One of several different sources, losers kept | select |
race fits hedged requests, primary/replica lookups, and any “these are two ways
to compute the same thing” pair. Once the arms mean different things, or once you
need the losers to survive, you want select.