Skip to content

Race

Start every arm as a task, take the first to complete, and cancel the rest.

Updated View as Markdown

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:

  1. spawn every arm;
  2. select the first task to complete;
  3. cancel every losing task;
  4. 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close