Async
This chapter documents Zynx future-based async semantics, scheduling, structured concurrency, cancellation, and channel choice.
Status: current public API unless a rule is marked unsupported or unspecified.
Async
Zynx async is cold and future-based. A function declares asynchronous work by returning the canonical std.async.Future<T> type. Calling such a function evaluates argument expressions immediately, moves or copies those values into a suspended future frame, and returns the async. The future body does not run at call time, even when the body contains no await.
Plain Future<T> is statically neither Send nor Sync. A producer intended for one cross-thread move declares the distinct result ^Future<T> where Send. Future has no where Sync or where Send + Sync form, and qualification has no runtime flag or witness.
For example, write fn f() -> ^std.async.Future<usize throws(IOError)>. There is no top-level throws clause on a future-returning function; errors are delivered through the future payload.
Future<void> is an ordinary Future whose successful result is void(). Fallthrough, return;, and return void(); complete it identically, and await yields void(). Success remains distinct from checked failure and cancellation; zero payload bytes do not create a separate future kind or completion path.
std.async.Future, scoped std.async.Join, compiler-known std.async.Scope, and std.async.TimeoutError are intrinsic declarations. Only the canonical declarations in std.async are treated as async types; user declarations with the same names are ordinary types.
Future and Join handles cannot be constructed manually. Future<T> values come from future-returning functions and std.async helpers such as ready, sleep, yield, readable, writable, blocking, all, race, and timeout. Join<T> values are scoped handles produced by Scope.start() only. The async handle fields are private implementation state, not a construction API.
Portable cold frames
Future<T> where Send proves that the entire possible in-flight frame can move to another thread, not merely that its initial arguments look send-safe. The producer declaration is checked against captured arguments, every local live across suspension, registered cleanup, retained nested-await Futures, and private runtime/frame state. Shared captures require a Sync referent; exclusive captures require a Send referent plus exclusivity for the Future's region. Diagnostics name the exact capture/local/frame path that prevents the proof.
fn decode_on_worker(bytes: ^Bytes)
-> ^Future<Image throws(DecodeError)> where Send {
let header = try await read_header(bytes);
return try decode(header, <-bytes);
}The qualifier alone does not require Image or DecodeError payloads to be Send. It proves frame transfer; an API that transports completion to another thread applies separate payload bounds.
Qualification only weakens from Future<T> where Send to unqualified Future<T>. There is no strengthening, recovery cast, hidden witness, or frame reinspection. Aliases, fields, nullable values, containers, channels, generic substitution, and control-flow joins retain the chosen identity.
A qualified borrowed Future remains region-bound. It does not create or extend a loan. A parallel Scope may consume it only when mandatory join occurs before the region ends; a channel rejects it because queue storage could escape the region.
Stored future payloads cannot contain checked references. Shared Future<T>, Future<&T>, and async.ready(&value) are rejected; use owned Future<^T>. Use reference-taking future-returning calls directly while the reference is live, or pass owned values into stored and structured futures.
await is valid in any function body. It accepts a Future<T> or scoped Join<T>, starts the work if necessary on the current runtime, drives it to completion without busy-spinning, moves out the result, and consumes the handle. await f() is sequential and starts only that async.
Ordinary await, async.run, and Scope.local.start accept qualified or unqualified Futures. No explicit downgrade is needed. Scope.parallel.start accepts only ^Future<T throws(E)> where Send and separately requires the success payload and every checked-error payload to be Send, because its Join returns completion across a worker boundary.
Bare future expressions are rejected. A stored Future<T> must be consumed by await, async.all, async.race, async.timeout, Scope.start, or explicit std.drop(future) before it leaves scope. Explicitly dropping a cold future drops captured values and does not run the async body.
Async iteration is deferred
The grammar reserves for await value in source and for try await value in source as the canonical order for a future async-stream decision, but the current language defines no eligible async multi-item source. A user async_iter() method, next() -> ^Future<T?>, or next() -> ^Future<T? throws(E)> is an ordinary method shape and does not opt the type into a protocol. The removed general Awaitable surface is not used, and Generator next() remains synchronous.
Consequently both async loop forms are currently semantic errors, while for await try is a syntax error because effects are ordered try await. Synchronous for try may appear inside a Future-producing body, but it does not suspend or create an async function kind; it only propagates the iterator's ordinary checked next() failure through the Future payload contract.
The scheduler is cooperative. Once a future has been started, it runs until it returns, throws, traps, awaits a pending operation, or awaits a runtime future such as async.reschedule, async.sleep, an operation on a nominal I/O resource, a structured combinator, or Scope join. Ordinary calls, loops, arithmetic, and field access are not scheduling points. Host readiness, when a backend uses it, is private runtime machinery rather than a source-level Future.
Channel Choice
The language has no select statement. Channel choice is written as ordinary futures that return a shared event type, followed by an explicit await async.race(...) and a match on the returned value:
enum Event {
Message(msg: channel.Recv<i32>),
Timeout,
Shutdown,
}
fn wait_msg(rx: channel.Receiver<i32>) -> ^async.Future<Event> {
return Event.Message { msg: await rx.recv() };
}
fn wait_timeout(duration: time.Duration) -> ^async.Future<Event> {
_ = await async.sleep(duration);
return Event.Timeout {};
}
fn wait_shutdown(done: ^async.Future<void>) -> ^async.Future<Event> {
_ = await done;
return Event.Shutdown {};
}
let event = (await async.race(
wait_msg(rx.clone()),
wait_timeout(duration),
wait_shutdown(shutdown),
)) catch {
_ => Event.Shutdown {},
};
match event {
.Message(msg) => { ... },
.Timeout => { ... },
.Shutdown => { return; }
}match chooses by inspecting an already-produced value. It does not start futures, wait for futures, poll channels, or imply any scheduling behavior. Waiting is explicit: write await at the point where the current task may suspend. Different operation result types should be unified by a user-defined enum or struct before calling async.race.
async.all, async.race, and async.timeout are cold until awaited. When awaited:
| Primitive | Behavior on await |
|---|---|
async.all(f1, f2, ...) | starts every child concurrently and returns one result position per child, preserving void; zero children produce void() |
async.race(f1, f2, ...) | returns the first observed completion and cancels losers |
async.timeout(f, duration) | fails with TimeoutError.Expired if the deadline wins |
Cancellation is lifecycle state and is not inserted into returned checked-error sets. Completion of cancellation means quiescence, not merely delivery of a request. If an async.blocking closure or std.async.fs host operation has started and cannot yet be interrupted, its Future and structured owner retain the frame, buffers, loans, handles, and cleanup until that work truly finishes. No child or filesystem job is detached. A timeout may therefore exceed its deadline before it can safely return, and cancellation never promises rollback of host effects that happened before quiescence.
Structured result selection is deterministic. Children are started once, then scanned from left to right on each structured poll. If multiple outcomes are observable in the same scan, the lower child slot wins. For async.timeout(child, duration), the child is slot 0 and the deadline is slot 1, so a child success or child error wins a same-scan tie with the deadline. async.all stores the first observed child failure or cancellation by scan order, cancels unfinished siblings, and does not let cancellation caused by that failure replace the stored child error. async.race stores the first observed completion by scan order whether that completion is success, child error, or cancellation.
async.all never filters successful void values. For example, combining Future<void> and Future<i32> produces (void, i32) in the same child order, not i32; only the truly empty child pack normalizes to void.
The scheduler does not guarantee fairness, starvation freedom, cross-runtime ordering, exact poll counts, exact timer/resource polling counts, or any scheduling policy beyond the documented cooperative suspension points and structured result-selection rules. async.reschedule() yields cooperatively; it is not a fairness or starvation-freedom contract. The implementation may vary allocation shape, poll count, and the runtime tick on which cancellation cleanup becomes visible, but it preserves the same observable result, cancellation, and exactly-once cleanup semantics.
async.reschedule() and async.sleep(duration) are fully cold: constructing or explicitly dropping them has no scheduler or timer effect, and the runtime action is registered only when the Future is started by await or a structured future API. Their intrinsic frame state is audited portable, so they return ^Future<void> where Send.
A resource-specific I/O call likewise returns a cold Future which owns the operation protocol. Private readiness registration is anchored to the live nominal resource and its generation; no public bare integer or readiness Future can be forged, closed independently, or redirected by host identity reuse.
Structured children use the one RFC 0009 composite [try] await with async.Scope.local/parallel() as tasks { ... }. Scope.start consumes a cold Future and returns an owned scoped Join; every Join is consumed inside the body, and Scope close cancel-and-joins every unfinished child before the region ends. Ordinary with has been removed and no user type participates in an enter/exit protocol.
Scope.local.start accepts qualified or unqualified cold Futures. Scope.parallel.start requires Future<T throws(E)> where Send, then separately requires T and every checked-error payload in E to be Send. The frame qualifier does not imply those Join transport bounds. A region-bound qualified Future is accepted only when Scope close joins it before that region ends.
import std.async;
fn work(value: i32) -> ^async.Future<i32> where Send {
return value;
}
fn main() {
var total = 0;
await with async.Scope.parallel() as tasks {
let two = tasks.start(work(2));
let three = tasks.start(work(3));
total = await <-two;
total = total + await <-three;
}
assert total == 5;
}When a child or body may fail, prefix the whole construct with try. Cancellation is lifecycle state rather than an ordinary AsyncError payload, and the primary body/child outcome follows RFC 0009 precedence after all cleanup completes.
Explicitly std.dropping an unawaited structured future discards the structured operation. It does not enter child async bodies or start captured cold runtime or resource-specific operations. Already captured async child frames are cancelled and their owned argument captures are dropped when the cancellation cleanup is driven by the runtime.
The canonical async surface has no public unstructured hot-start API. Start work by awaiting a future directly or by using the structured helpers in std.async.
Direct-await reference captures are allowed, for example await read_ref(value). Stored or structured futures that capture references are rejected.
Closure captures follow the callable rules in Closures And Callables. A registered defer may live across suspension, but ordinary defer is synchronous; write defer await when its body must suspend. Both forms are infallible and normally returning at the cleanup boundary: checked errors are caught locally, and outward return/error/nonlocal-loop transfer is rejected. Cancellation drives registered defers in LIFO order before completing the task as cancelled; cleanup is masked until it completes. A runtime trap still aborts remaining cleanup. See Control Flow for cleanup ordering.
See Async Guide and std.async for the current async API surface.