Skip to content
CryoCryo home
LanguageProgram structure

19Asynchronous Programming

Cryo supports async / await as a first-class language feature. An async function is compiled into a state machine: its body is split at every suspension point, the values that must survive a suspension become fields of a generated struct, and calling the function builds that struct instead of running the body. Nothing runs until the resulting future is polled.

The model is stackless. A future is an ordinary struct with no hidden heap allocation, no separate stack, and no runtime machinery of its own - async is a compile-time transformation, and the executor that drives futures is an ordinary library (std::future), not part of the language.

19.1 Async Functions

async before function makes the function asynchronous. The declared return type is the value the function eventually produces, not the future:

import std::future;

async function add_later(a: i64, b: i64) -> i64 {
    const base: i64 = await ready_value(a);
    return base + b;
}

add_later(1, 2) returns a future whose output is i64. The body has not begun.

An async function may return void, be generic, and be declared in any order relative to its callers:

async function log_it(msg: Str) -> void { ... }

async function first<T>(a: T, b: T) -> T { ... }

19.2 The await Operator

await suspends the enclosing async body until its operand - which must implement Future - completes, and evaluates to that future's output.

const n: i64 = await add_later(1, 2);

await is an ordinary prefix operator and may appear anywhere an expression may appear: in a condition, a loop body, an operand of a larger expression, a match subject, a match arm body, and a match arm guard.

async function drain(mut q: Queue) -> i64 {
    mut total: i64 = 0;
    while (await q.has_more()) {
        total = total + (await q.pop()) * 2;
    }
    match (await q.status()) {
        Status::Clean          => { return total; }
        Status::Dirty(n) if await q.can_retry() => { return total - n; }
        _                      => { return 0; }
    }
}

await is only legal inside an async function or method. Using it elsewhere is an error.

19.3 The Future Trait

await is defined in terms of one trait, from std::future:

type trait Future {
    type Output;
    poll(mut &this, cx: Context*) -> Poll<Output>;
}
TypeMeaning
Poll<T>Poll::Ready(T) or Poll::Pending. Helpers: is_pending(), is_ready(), into_ready().
ContextThe poll context. cx.waker() yields the Waker to register for a wake-up.
WakerA handle that reschedules the task. Waker::noop() is the no-op waker used by simple drivers.

poll returns Poll::Pending only after arranging for cx.waker() to be invoked when progress becomes possible; a future that returns Pending without registering a waker will never be polled again by a real executor.

The bound is written Future<T>, which binds the associated Output positionally (see section 11.5):

function drive<F, R>(fut: F) -> R where F: Future<R> { ... }

Hand-written futures are ordinary types implementing the trait, and are indistinguishable to await from compiler-generated ones:

implement trait Future for struct Countdown {
    type Output = i64;

    poll(mut &this, cx: Context*) -> Poll<i64> {
        if (this.n == 0) { return Poll::Ready(this.total); }
        this.n = this.n - 1;
        cx.waker().wake();
        return Poll::Pending;
    }
}

19.4 How an Async Function Is Compiled

Each async function generates one struct - the state machine - plus a Future implementation for it. The original function becomes a constructor returning that struct.

  • A state field records which suspension point to resume at. Each poll dispatches on it and re-enters there.
  • Every parameter, and every local that is live across a suspension, becomes a field. A local used only between two suspensions stays an ordinary stack local.
  • Every awaited sub-future is stored in a field and polled from the resuming state.
  • Poll::Pending from a sub-future records the state and returns Pending; the next poll resumes at that state.

Two consequences are worth stating because they are guarantees, not implementation details:

  • No hidden allocation. The state machine is a value type. Its size is known at compile time, and a future is only heap-allocated if you put it somewhere that allocates (spawning it as a task, for example).
  • Futures are freely movable. Cryo futures are never self-referential, so no pinning discipline exists and none is needed - there is no Pin type. This is guaranteed by the restrictions in section 19.7, which reject the constructs that would create a self-reference. Moving a future between polls, including polling it by hand, is well defined.

19.5 Async Methods

async applies to methods in type struct, type class, and implement blocks, with any receiver form:

type struct Counter {
    base: i64;

    async bump(&this, n: i64) -> i64 {
        const step: i64 = await tick();
        return this.base + n + step;
    }

    async consume(this) -> i64 { ... }

    static async make(n: i64) -> Counter { ... }
}

A &this / mut &this receiver stays borrowed for the whole operation, including across suspensions: the enclosing frame re-establishes the receiver's address before every poll, so a method may read and write through this after an await exactly as it does before one. The receiver must be a place the caller can name again - see section 19.7.

19.6 Async Trait Methods

A trait method may be async, with or without a default body:

type trait AsyncRead {
    async read(mut &this, buf: Array<u8>) -> AsyncIo;

    /// A default body, written once over `read`.
    async read_exact(mut &this, buf: Array<u8>) -> AsyncIo {
        mut io: AsyncIo = await this.read(buf);
        while (io.is_short()) {
            io = await this.read(io.take_buf());
        }
        return io;
    }
}

Each implementation's async method lowers to its own state machine, so two implementors return two different concrete future types while the trait declares one signature. This is expressed with an implicit associated type: async read(...) -> AsyncIo declares an associated future type and returns it, and each implement trait block binds that associated type to its own generated future. Default bodies are instantiated per implementation and may be overridden.

A generic consumer needs only the trait bound. The future's own bound is implied by the trait and is never respelled at the use site:

async function slurp<S>(mut s: S, buf: Array<u8>) -> AsyncIo
where S: AsyncRead {
    return await s.read(buf);
}

async may not be combined with virtual or override (E0364): those share a vtable slot, and each implementation's future is a distinct type.

19.7 Restrictions

The rules below exist to keep futures free of self-references, which is what makes them movable and removes the need for a pinning discipline.

RuleDiagnostic
A reference may not be held live across an await. Owned values are unrestricted.E0455
The address of a local or parameter of the current frame may not be handed to a future that outlives the current step.E0455
An async method must be awaited on a receiver that names storage - a local, parameter, field, or dereference. A temporary or call result must be bound to a local first.E0455
An async method's future must be awaited at the call, not stored and awaited later.E0455
An async function may not await itself, directly or transitively - the state machine would contain itself and have no finite size. Rewrite the recursion as a loop.E0600
async may not be combined with virtual or override, and there are no async constructors, destructors, or fields.E0364

Each suspension runs on a fresh frame, which is why frame addresses do not survive one:

async function bad(n: i64) -> i64 {
    const p: i64* = &n;          // address into this frame
    const step: i64 = await tick();
    return *p + step;            // E0455: `p` is dangling here
}

async function good(n: i64) -> i64 {
    const step: i64 = await tick();
    return n + step;             // the owned value is carried for you
}

The owned-value rewrite is always available, and it is the idiom the standard library's async I/O follows: an operation that needs a buffer owns the buffer for the duration and hands it back on completion, rather than borrowing one from the caller's frame.

19.8 async function main

main may be declared async:

import std::future;

async function main() -> i32 {
    const body: Str = await fetch("example.com");
    return body.length() as i32;
}

The compiler renames the asynchronous body out of the entry-point slot and synthesises a synchronous main in its place that creates an Executor scoped to the entry point, drives the body to completion on it, and tears the runtime down at exit. Parameters are forwarded, so argument handling is identical to a synchronous main.

-> i32 and -> void are both accepted. A generic main, or any other return type, is an error (E0365).

19.9 Driving Futures

A future does nothing until something polls it. std::future provides two drivers.

block_on runs a single future to completion on the calling thread, polling in a loop with a no-op waker. It has no reactor, so it suits compute-only futures that make progress on every poll:

import std::future;

const n: i64 = future::block_on(add_later(1, 2));

Executor is the real runtime: a worker pool with a ready queue, a reactor for I/O and timer readiness, and catch_unwind isolation at the poll boundary so one panicking task cannot take down a worker.

mut ex: Executor = Executor::new();

mut h1: JoinHandle<i64> = ex.spawn(work(1));
mut h2: JoinHandle<i64> = ex.spawn(work(2));

const a: i64 = h1.join();
const b: i64 = h2.join();
OperationMeaning
Executor::new()Create a runtime with a default worker pool and its reactor.
ex.spawn(fut)Schedule fut as an independent task; returns a JoinHandle<T>.
ex.block_on(fut)Drive one future to completion on this executor and return its output.
h.join()Wait for the task and take its output.
h.abort()Cancel the task.
h.detach()Let the task run without holding the handle. Dropping a handle detaches.

Executor implements Drop, so an executor held in a local tears its runtime down at the end of that scope; join the work you care about before then. Timer and I/O futures require an executor's reactor, so they cannot be driven by the plain block_on.

19.10 Timers and Combinators

Sleep completes after a delay, using the reactor's deadline chain rather than blocking a thread:

import std::time;

await Sleep::new(Duration::from_millis(50));
await Sleep::until(deadline);

The Futures namespace composes futures. Each takes two futures and nests for higher arities:

CombinatorBehaviour
Futures::join(a, b)Completes when both complete; yields both outputs.
Futures::select(a, b)Completes with whichever finishes first; the loser is dropped.
Futures::timeout(fut, dur)fut's output, or Elapsed if the deadline passes first.
Futures::timeout_at(fut, instant)The same against an absolute deadline.
match (await Futures::timeout(fetch(url), Duration::from_secs(5))) {
    Result::Ok(body) => { ... }
    Result::Err(_)   => { ... }   // timed out
}

19.11 Cancellation

Cancellation is a plain drop. There is no cancellation token and no separate cancel operation: dropping a future before it completes cancels the operation it represents, and the future's Drop releases whatever it holds - deregistering a waker from the reactor, disarming a timer, closing a socket it owns. This is why select cancels its loser and timeout cancels its victim simply by releasing them, and why Executor::drop cancels tasks still parked.

Because a future owns the resources of an in-flight operation, cancellation is complete by construction: there is no window in which a cancelled read is still writing into a buffer someone else now owns.

19.12 Async I/O

std::net is asynchronous. The reactor is epoll on Linux and IOCP with \Device\Afd on Windows, presented through one interface.

Async I/O futures own the handle and the buffer for the duration of the operation and hand both back on completion. That is what makes them sound rather than merely careful: a future may move between polls, so a buffer identified by a pointer into the caller's frame would be written through a stale address.

mut io: TcpIo = await TcpRead::start(stream, buf);
mut stream: TcpStream  = io.take_stream();
mut buf:    Array<u8>  = io.take_buf();
match (io.outcome()) {
    Result::Ok(n)  => { ... }      // n == 0 means the peer closed
    Result::Err(e) => { ... }
}
FutureCompletes with
TcpConnectA connected TcpStream.
TcpAcceptTcpAccepted - the listener back, plus the accepted connection.
TcpRead / TcpWriteTcpIo - the socket and buffer back, plus the byte count or error.
TlsHandshakeA negotiated TlsIo.
TlsRead / TlsWriteTlsIo, with the same hand-back contract.

The AsyncRead and AsyncWrite traits (section 19.6) abstract over transports, so protocol code is written once and runs over plain TCP or TLS:

async function greet<S>(mut conn: S) -> Result<(), IoError>
where S: AsyncWrite {
    await conn.write_all(Str::new("HELLO\r\n").as_bytes().to_array());
    return Result::Ok(());
}

TlsConnector::connect and TlsAcceptor::accept are async and drive a TLS handshake without blocking: each WANT_READ / WANT_WRITE becomes a reactor registration rather than a spin, and the direction to wait on is taken from the TLS error code. start_connect / start_accept stop one step short and return the TlsHandshake future itself, for a caller that needs to select over it or give it a deadline.