Module async/index

async/index
Stability: unstable — may still change; see below.

Async task utilities — cooperative yield, JoinHandle.join and the JoinHandle combinators (plans/archive/STD_API_AUDIT.md §7 P0 item 6, reshaped as futures by plans/ASYNC_IO_API_AUDIT.md A1).

Every combinator (join_all, race, race_first, any, any_first, timeout, select) is a FUTURE: it suspends the awaiting task until its operands are done, so it works the same in main and inside an io.async body. The list combinators take spawned handles — spawn at the call site. A JoinHandle is move-only (plans/VALUES_BY_DEFAULT.md §3.13 A3), so each takes its handles by value: join, join_all, race_first and any_first consume them, and race/any hand the list back with the index, so the losers can still be aborted or joined:

{ join_all, race } :: import("std/async");

handles := ArrayList(JoinHandle(String)).new();
handles.push(io.spawn(fetch_a(io), io));
handles.push(io.spawn(fetch_b(io), io));
results := io.await(join_all(handles, io), io); // ArrayList(Option(String))
h := io.spawn(fetch_a(io), io);
one := io.await(h.join(io), io); // Option(String), from a task too

timeout and the two-way select take FUTURES (§3.13 A4), so they can wrap a future that borrows (timeout(rx.recv(io), d, io), select(s.next(io), t.next(io), io)). Each runs its operands as scoped child tasks and aborts and joins them before it resolves, so no borrow outlives the call; a borrowing operand makes the combinator's own future a borrowing one, awaited where it is made.

For the async-safe Channel and Mutex (usable INSIDE tasks without parking the event loop the way std/sync's blocking primitives do), see std/async/channel and std/async/mutex.

Stability

unstable — the names (yield, join, join_all, race, race_first, any, any_first, timeout, TimeoutError) and the future-returning shape are settled as of 2026-10-03; the list combinators' JoinHandle argument type is a decision (plans/ASYNC_IO_API_AUDIT.md Q3), which decision 14 of plans/VALUES_BY_DEFAULT.md reverses for timeout and select (they take futures, 2026-10-16). What may still change is the wait primitive under them (__yo_join_wait_*): today it wakes on ANY added task, which any works around by re-waiting over the handles still running.

Types

TimeoutError

Why timeout(...) produced no value (D18).

The old Option(T) return collapsed these two outcomes into each other — and, when T was itself an Option, into a task that legitimately returned .None. A caller that wants to retry on a slow task but give up on a cancelled one could not tell them apart.

Variants

VariantFieldsDescription
Elapsed

The deadline fired first; timeout aborted the operand.

Aborted

The operand was aborted (an effect handler unwound it) while the deadline was still unexpired, so it never produced a value.

Trait Implementations

Methods
clone : (TimeoutError) fn(self : &TimeoutError) -> TimeoutError

Parameters

NameTypeNotes
selfTimeoutError

Returns: TimeoutError

to_string : (TimeoutError) fn(self : &TimeoutError) -> String

Parameters

NameTypeNotes
selfTimeoutError

Returns: String

source : (TimeoutError) fn(self : &TimeoutError) -> Option(dyn(ToString + ))

The error that caused this one, or .None at the root of the chain.

Rust's Error::source. Defaulted to .None, so an error with nothing underneath it implements the trait by saying only what it is; a wrapper overrides it to hand back what it wrapped. Walking the chain to the root cause works as of 2026-09-14 — the returned Dyn used to lose the Error trait on an erased receiver, so a caller could print one link but not follow it (#521, issues/fixed/self-trait-in-a-return-type-loses-the-trait-on-an-erased-receiver.md).

Parameters

NameTypeNotes
selfTimeoutError

Returns: Option(dyn(ToString + ))

Selected type-function
fn(A : Type, B : Type) -> Type

Which operand of select finished first, with its result.

Type Parameters

NameTypeNotes
ATypecomptime
BTypecomptime

Functions

yield function
fn(io : Io) -> Impl(Future(unit))

Suspend the current async task until the next event-loop turn: drain the ready queue, then poll (and, when idle with pending I/O, block in) I/O.

There is NO TIMER under this. The future is created pending and completed at the top of the next ready-task drain, after that drain has measured its budget, so the resumed continuation lands beyond the budget and runs in the following step — with exactly one __yo_io_poll() in between. That is the same mechanism std/async/waker's yield_now documents, and the two are now the same thing.

It was a 1 ms timer until v0.2.32, for a bootstrap reason and not a design one: yield is on the compiler's OWN import path (through std/fs/watch), and a seed compiler emits the async runtime it was built with — so pointing this at __yo_async_yield_start could not link until a published seed carried that symbol. v0.2.31 is the first that does.

Two earlier shapes are ruled out and neither should come back. A body of return(()) completes the future SYNCHRONOUSLY, so io.await(yield(io), io) never reaches __yo_async_poll_step at all: a poll-until-finished loop built on it (is_finished() + await yield()) spins without ever polling I/O and the polled task never progresses (the build smoke hang's spin component, issues/fixed/build-smoke-hangs-registry-perturbation.md). An ALREADY-COMPLETE future is the same failure by a different route — the await point takes an inline fast path for one of those and the task never leaves the C stack.

Parameters

NameTypeNotes
ioIo

Returns: Impl(Future(unit))

join_all function
fn(generic(T : Type), handles : ArrayList(JoinHandle(T)), io : Io) -> Impl(Future(ArrayList(Option(T)), Io))

Await every handle, in order, resolving to each task's result (.None for a task that was aborted). All tasks run concurrently on the loop — the wait costs the SLOWEST task's wall time, not the sum. It drains the list: each handle is consumed by its join.

results := io.await(join_all(handles, io), io); // ArrayList(Option(T))

Type Parameters

NameTypeNotes
TTypecomptime

Parameters

NameTypeNotes
handlesArrayList(JoinHandle(T))
ioIo

Returns: Impl(Future(ArrayList(Option(T)), Io))

race function
fn(generic(T : Type), handles : ArrayList(JoinHandle(T)), io : Io) -> Impl(Future(Tuple(usize, ArrayList(JoinHandle(T))), Io))

Resolve once at least one handle is terminal (completed OR aborted), to its index, and hand the list back: (index, handles). The losers keep running: a JoinHandle dropped without an await DETACHES its task, so abort() the losers if their work is unwanted (race_first does that for you). Panics on an empty list.

(w, hs) := io.await(race(handles, io), io);

Type Parameters

NameTypeNotes
TTypecomptime

Parameters

NameTypeNotes
handlesArrayList(JoinHandle(T))
ioIo

Returns: Impl(Future(Tuple(usize, ArrayList(JoinHandle(T))), Io))

race_first function
fn(generic(T : Type), handles : ArrayList(JoinHandle(T)), io : Io) -> Impl(Future(Option(T), Io))

Resolve to the FIRST handle's result and abort the rest — race with the cleanup done for you (.None if the winner was aborted rather than completed). It drains the list. Panics on an empty list.

This exists because race/any hand back an index and leave the losers running: dropping a JoinHandle DETACHES its task (Tokio's semantics, which is what makes a fire-and-forget io.spawn statement correct), so a caller who wants the losers' work stopped has to abort() each one. The handle's Drop releases its reference to the task; abort-on-drop is deliberately not its semantics.

Every loser is aborted AND joined: the join lets a loser suspended in a cancellable operation be released here rather than when that operation would have completed.

Type Parameters

NameTypeNotes
TTypecomptime

Parameters

NameTypeNotes
handlesArrayList(JoinHandle(T))
ioIo

Returns: Impl(Future(Option(T), Io))

any function
fn(generic(T : Type), handles : ArrayList(JoinHandle(T)), io : Io) -> Impl(Future(Tuple(Option(usize), ArrayList(JoinHandle(T))), Io))

Resolve once some handle COMPLETES (not merely aborts), to its index, or to .None once every handle is terminal without a completion, and hand the list back: (index, handles). Like race, the losers keep running unless aborted (any_first aborts them). Panics on an empty list.

Type Parameters

NameTypeNotes
TTypecomptime

Parameters

NameTypeNotes
handlesArrayList(JoinHandle(T))
ioIo

Returns: Impl(Future(Tuple(Option(usize), ArrayList(JoinHandle(T))), Io))

any_first function
fn(generic(T : Type), handles : ArrayList(JoinHandle(T)), io : Io) -> Impl(Future(Option(T), Io))

Resolve to the first handle to COMPLETE and abort the rest — any with the cleanup done for you. .None when every handle ended without completing. It drains the list. Panics on an empty list. See race_first for why this exists.

Type Parameters

NameTypeNotes
TTypecomptime

Parameters

NameTypeNotes
handlesArrayList(JoinHandle(T))
ioIo

Returns: Impl(Future(Option(T), Io))

timeout function
fn(generic(T : Type), fut : Impl(Future(T, Io)), limit : Duration, io : Io) -> Impl(Future(Result(T, TimeoutError), Io))

Run fut with a deadline: .Ok(v) if it finishes within limit, .Err(TimeoutError.Elapsed) if the deadline fires first (the operand is aborted), .Err(TimeoutError.Aborted) if the operand was aborted on its own (an effect handler unwound it). Millisecond granularity (the timer contract of std/time/sleep). Tokio's time::timeout(d, fut).

r := io.await(timeout(rx.recv(io), Duration.from_millis(i64(200)), io), io);

The operand is a FUTURE, so it may borrow (plans/VALUES_BY_DEFAULT.md §3.13 A4): timeout(s.next(io), d, io) over a &mut stream works, and the future timeout returns is then a borrowing future too, awaited where it is made. The operand runs as a scoped child task, and is aborted and joined before timeout resolves, so no borrow outlives the call. The operand's effect type is Io, as timeout's own is; an operand with a richer bundle is not accepted yet (issues/2026-10-20-an-effect-binder-bound-only-through-a-future-parameter-resolves-to-io.md).

To have the deadline abort the work, pass the work's future itself: it runs as timeout's scoped child and is aborted when the deadline fires. A task that is already spawned is timed out through its join future, timeout(h.join(io), d, io), which resolves to .Ok(Option(T)). On the deadline that drops the join future and with it the handle, which DETACHES the task: it keeps running (Tokio's semantics for a dropped JoinHandle). Before §3.13 A4, timeout took the JoinHandle and aborted the task on the deadline.

An operand that finishes before the deadline leaves nothing behind: aborting the deadline task deregisters the armed timer in the I/O backend and releases the deadline task there and then. Until that cancel path existed the timer kept counting and held ~256 bytes per call for the whole remaining deadline (issues/fixed/timeout-deadline-timer-future-leak.md).

Type Parameters

NameTypeNotes
TTypecomptime

Parameters

NameTypeNotes
futImpl(Future(T, Io))
limitDuration
ioIo

Returns: Impl(Future(Result(T, TimeoutError), Io))

select function
fn(generic(A : Type, B : Type), first : Impl(Future(A, Io)), second : Impl(Future(B, Io)), io : Io) -> Impl(Future(Option(Selected(A, B)), Io))

Run two futures and resolve to the first one to COMPLETE, aborting and joining the other: .Some(.First(a)) or .Some(.Second(b)), or .None when both were aborted (an effect handler unwound each) without a completion. An operand that aborts does not win; the other keeps running. When both have completed by the time the wait resumes, the first wins (a biased select, like Tokio's select! with biased;).

match(
  io.await(select(rx.recv(io), sleep(u64(50)), io), io),
  .Some(.First(msg)) => handle(msg),
  .Some(.Second(_)) => println("idle"),
  .None => ()
);

The operands are FUTURES, so they may borrow (plans/VALUES_BY_DEFAULT.md §3.13 A4), as timeout's does: each runs as a scoped child task with the Io bundle, the loser is aborted and joined before select resolves, and the future select returns borrows what the operands borrow. The two may not lend overlapping places when either side is &mut (decision 38 B): select(s.next(io), s.next(io), io) is E0910.

Type Parameters

NameTypeNotes
ATypecomptime
BTypecomptime

Parameters

NameTypeNotes
firstImpl(Future(A, Io))
secondImpl(Future(B, Io))
ioIo

Returns: Impl(Future(Option(Selected(A, B)), Io))

_scope function
fn(generic(U : Type), h : JoinHandle(U)) -> _Scoped(U)

Take ownership of a spawned operand as a scoped child.

Type Parameters

NameTypeNotes
UTypecomptime

Parameters

NameTypeNotes
hJoinHandle(U)

Returns: _Scoped(U)

_scoped_state function
fn(generic(U : Type), s : &_Scoped(U)) -> FutureState

The scoped task's state (Aborted once it was joined).

Type Parameters

NameTypeNotes
UTypecomptime

Parameters

NameTypeNotes
s&_Scoped(U)

Returns: FutureState

_scoped_abort function
fn(generic(U : Type), s : &_Scoped(U)) -> unit

Abort the scoped task (a terminal one is left alone).

Type Parameters

NameTypeNotes
UTypecomptime

Parameters

NameTypeNotes
s&_Scoped(U)

Returns: unit

_scoped_wait_on function
fn(generic(U : Type), s : &_Scoped(U), w : &IoFuture) -> unit

Add the scoped task to the join wait w.

Type Parameters

NameTypeNotes
UTypecomptime

Parameters

NameTypeNotes
s&_Scoped(U)
w&IoFuture

Returns: unit

_scoped_join function
fn(generic(U : Type), s : &mut _Scoped(U), io : Io) -> Impl(Future(Option(U), Io))

Join the scoped task: its result, or .None if it was aborted. Called once, after which the task is no longer scoped (the join owns it).

Type Parameters

NameTypeNotes
UTypecomptime

Parameters

NameTypeNotes
s&mut _Scoped(U)
ioIo

Returns: Impl(Future(Option(U), Io))