Axle v0.17.0

The async world — a state machine the loop drives

How a coroutine works

Calling an async fn allocates a frame and returns immediately. The body starts only when await enters the event loop — which is where it parts company with spawn: both hand back the same Future<T>, but a spawned task has already been given to a carrier and runs whether or not anything ever awaits it, while a coroutine sits still until something drives the loop.

async fn greet(name : string) : string {
    let msg : string = "Hello, " + name;
    await Async::sleep(100);          // suspension point
    return msg + "!";
}

fn main() : i32 {
    let f : Future<string> = greet("world");   // registered — the body has not run
    let s : string = await f;                  // the loop drives it to the end
    println(s);
    return 0;
}

At Async::sleep the machine saves msg into its heap frame and yields. The event loop parks until that deadline — in capped rounds, so it drives other machines meanwhile — then resumes the machine exactly where it left off — msg is still there.

The compiler splits greet at its one await into a small state machine. The locals live in a heap frame that outlives every suspension; a hidden __state word records where to resume:

  heap frame (survives the suspension)
  ┌─────────────────────────────────────────────┐
  │  __state : 1        msg : "Hello, world"     │
  └─────────────────────────────────────────────┘
         ▲                       │
         │ resume dispatches     │ locals restored from the frame
         │ on __state            ▼
  ── state 0 ─────────────────────────────────────────────────
     msg = "Hello, " + name
     arm the 100 ms timer;  __state = 1;  yield ──► event loop
  ── event loop drives other machines meanwhile ──────────────
  ── state 1  (timer fired → scheduler resumes this machine) ──
     return msg + "!"   ──►  Done("Hello, world!")

A plain function keeps its locals on the call stack and unwinds when it returns; a coroutine cannot, because it must pause and let the stack be used by other machines. Moving the locals into a heap frame is what lets it freeze and thaw — and is why a suspended coroutine costs only its frame, not a whole thread stack.

A thousand concurrent timers on one thread

When you await f, the loop drives every registered machine, not just f. Concurrent machines make progress automatically:

async fn tick(id : i32) : i32 {
    await Async::sleep(30);          // all 1000 sleep simultaneously
    return id;
}

fn main() : i32 {
    let futures : Future<i32>[1000];
    for (i of 0..1000) {
        futures[i] = tick(i);
    }

    let sum : i32 = 0;
    for (j of 0..1000) {
        sum = sum + (await futures[j]);
    }
    return 0;                       // done in one timer period, not a thousand
}

When await futures[0] starts the loop, all 1000 machines hit Async::sleep in the first few rounds. The loop parks until the timer fires — and all 1000 complete together.

Joining a set of futures — await all

Awaiting futures one by one already runs them concurrently, but you often want the whole set as a single value. await all (sugar for Async::all(…)) joins a Future<T>[] into one Future<T[]>:

async fn fetch(id : i32) : i32 {
    await Async::sleep(1);
    return id * 2;
}

fn main() : i32 {
    let futures : Future<i32>[] = [ fetch(1), fetch(2), fetch(3) ];
    let results : i32[] = await all futures;
    return results[0] + results[1] + results[2] - 12;   // 2 + 4 + 6
}

The results come back in array order, not completion order. Two properties are worth knowing:

  • The joined handle is an ordinary Future<T[]>. Async::all(futures) mints it without awaiting anything, so you can store it in a variable, pass it to another function, return it, and await it later — exactly like the handle of any async fn.
  • If several children throw, the one with the lowest array index wins, even if another failed sooner. Every child is still driven to a terminal state so its resources are released; the losing outcomes are discarded.

await all 5 or await all over an i32[] is E0738 — the operand must be a Future<T>[]. Calling Async::all with anything other than that one argument is E0739.

HTTP fan-in — many endpoints, one carrier

use std::net::HttpClient;

async fn check(url : string) : bool {
    try {
        let body : string = await HttpClient::getAsync(url);
        return body.length > 0;
    } catch e : IOException {
        return false;
    }
}

fn main() : i32 {
    let endpoints : string[] = [
        "https://api.service-a.com/health",
        "https://api.service-b.com/health",
        "https://api.service-c.com/health",
        "https://api.service-d.com/health",
    ];

    let checks : Future<bool>[4];
    for (i of 0..4) { checks[i] = check(endpoints[i]); }

    let healthy : i32 = 0;
    for (j of 0..4) {
        if (await checks[j]) { healthy = healthy + 1; }
    }
    println("Healthy: " + healthy + "/4");
    return 0;
}

What the shape buys is the handle and the frame. Each check settles on its own Future<bool>, with a frame of its own, instead of four nested calls whose failures you thread back by hand. HttpClient::getAsync is spawn blockingGet(url) — the blocking exchange, run as a spawned task and answered as a Future<string> — and each of that exchange’s blocking steps parks its task while the kernel does the work, so the four round-trips are in flight together rather than one after the other.

The four exchanges are spawned tasks, so they are spread over the carrier pool like any spawn. The kernel waits themselves run on the offload pool, which keeps four workers and grows past them on demand — a job arrives while every worker is busy and the pool starts another, up to a ceiling beyond which the call queues. So the four round-trips overlap without a thread asked for one. Putting each blocking exchange on a platform thread bounds it to no pool — The third backing is that surface.

A wait that only a sibling could end is the shape to check. A server task waiting in accept() parks, so the client task on the same carrier gets its turn and a loopback exchange across two tasks of one carrier completes. The waits that park are the ones the runtime owns: an await, a channel, a lock or synchronized, a semaphore, a latch, a Thread.join, the prelude sleep(ms), Process.waitFor, and the std/net family’s accept, connect, socket reads and writes and blocking name lookups. What the task world cannot hand over is a wait nothing takes over — a spin loop, a poll of a flag only a sibling can set, a blocking call into C of your own, a file read or write: those hold the carrier, and a sibling parked behind them waits with it.

Files go to the task world, not the reactor

A regular file has no readiness to report — it is always “ready”, and the read blocks anyway — so there is no File coroutine verb. File work belongs in the task world: hand each file to a spawned Future and await the results. Each read gets a frame of its own rather than one the caller’s locals share:

use std::io::BufferedReader;
use std::io::FileInputStream;

fn lineCount(path : string) : i32 ! IOException, FileNotFoundException {
    let reader : BufferedReader = new BufferedReader(new FileInputStream(path));
    defer reader.close();
    let n : i32 = 0;
    let line : string = reader.readLine();
    while (!line.isEmpty()) { n = n + 1; line = reader.readLine(); }
    return n;
}

// `spawn`ing a throwing function propagates its error set to the caller,
// so `main` re-declares `! IOException, FileNotFoundException`.
fn main() : i32 ! IOException, FileNotFoundException {
    let files : string[4] = ["a.txt", "b.txt", "c.txt", "d.txt"];
    let jobs  : Future<i32>[4];
    for (i of 0..4) { jobs[i] = spawn lineCount(files[i]); }

    let total : i32 = 0;
    for (j of 0..4) { total = total + await jobs[j]; }
    return total;
}

Four file reads. Nothing in lineCount parks, and a read holds the carrier it runs on while the kernel does the work: two of these tasks that share a carrier read one after the other, and tasks spread over the pool read side by side. spawn here buys a frame per file — the loop’s own locals do not have to survive a read — and a result per file at the awaits.

The async world (async fn / reactor) is for sockets, DNS and HTTP, where the reactor has an fd to watch — see Async I/O.

DNS + async connect

use std::dns::dnsResolveAsync;
use std::net::Socket;

async fn connectTo(host : string, port : i32) : Socket
    ! IOException
{
    let ip   : string = await dnsResolveAsync(host);
    let sock : Socket = new Socket(ip, port);
    await sock.connectAsync();
    return sock;
}

fn main() : i32 ! IOException {
    let sock : Socket = await connectTo("db.internal", 5432);
    await sock.writeAsync("SELECT 1");
    let reply : string = await sock.readAsync(256);
    sock.close();
    return 0;
}

DNS lookup, TCP handshake, write and read each suspend the coroutine and let the event loop do other work. No thread is blocked.

Timeouts

.timeout(ms) is await with a deadline, on either backing. If the handle resolves in time you get its value; if the deadline hits, TimeoutException is thrown and the handle is spent. What happens to the work differs with the backing: a coroutine is cancelled, while a spawned task is never stopped — the caller stops waiting, the task runs on to its end, and its value is dropped. A Thread has no .timeout.

fn slowSum(n : i32) : i32 {
    let acc : i32 = 0;
    for (i of 0..n) { acc = acc + i; }
    return acc;
}

fn main() : i32 {
    let f : Future<i32> = spawn slowSum(100000000);
    try {
        return f.timeout(50);              // the value, if it lands in 50 ms
    } catch e : TimeoutException {
        return -1;                         // the task runs on; nobody waits
    }
}

A request that carries its own deadline needs no Future to bound it:

use std::net::HttpClient;

fn fetchWithFallback(url : string) : string {
    try {
        // A request carries its own deadline, so nothing here waits on a
        // `Future` to bound it: `timeoutMillis` belongs to this request and
        // the verb closes it.
        return HttpClient::request(url)
            .timeoutMillis(2000)
            .get()
            .body();
    } catch e : IOException {
        return "";
    }
}

Catching errors across an await

A bare try/catch around an await works exactly like around a regular call:

use std::net::HttpClient;

async fn fetchSafe(url : string) : string {
    try {
        return await HttpClient::getAsync(url);   // the await suspends
    } catch e : IOException {       // getAsync declares `! IOException`
        return "";
    }
}

Where a task’s exception is checked

A spawned function or an async fn raises where its handle is consumed — at the await, the .timeout(ms) or the await all — not where it was launched. The compiler checks the exception there (E0005): a try around the await alone covers it, and a try around the spawn covers nothing the task raises.

use std::lang::IllegalStateException;

fn boom(v : i32) : i32 ! IllegalStateException {
    if (v > 0) { throw IllegalStateException("from the task"); }
    return v;
}

fn main() : i32 {
    let f : Future<i32> = spawn boom(1);    // nothing to catch here
    try {
        return await f;                     // the task's throw surfaces here
    } catch e : IllegalStateException {
        return 0;
    }
}

A handle that leaves the function — returned, passed to another function, stored in a field — is consumed out of the function’s sight, so it is the function’s own ! set that must declare what the handle may raise:

use std::lang::IllegalStateException;

fn boom(v : i32) : i32 ! IllegalStateException {
    if (v > 0) { throw IllegalStateException("from the task"); }
    return v;
}

fn launch() : Future<i32> ! IllegalStateException {
    return spawn boom(1);                   // the caller's `await` raises it
}

fn main() : i32 ! IllegalStateException {
    return await launch();
}

The standard library’s …Async functions follow the same rule: their ! set names what the returned handle may raise.

Composing the two worlds

An async fn can spawn a Future and await it across a suspension. The machine yields at the await while the task’s own stack carries its work to the end:

use std::net::HttpClient;

fn lz4Compress(data : string) : string {
    return "lz4:" + data.length;
}

fn compress(data : string) : string {
    return lz4Compress(data);        // runs as a spawned task
}

async fn processRequest(raw : string) : string ! IOException {
    let extra : string = await HttpClient::getAsync("https://config/schema");
    let worker : Future<string> = spawn compress(raw + extra);
    return await worker;             // suspends — loop keeps running
}

The other direction works too: spawn an async fn and the spawned task drives that coroutine. The tasks are spread over the carrier pool like any other spawn, and each one parks while its coroutine waits:

async fn crunch(n : i32) : i32 {
    await Async::sleep(0);
    return n * n;
}

fn main() : i32 {
    let jobs : Future<i32>[4];
    for (i of 0..4) {
        jobs[i] = spawn crunch(i);   // 4 tasks, 4 independent loops
    }
    let total : i32 = 0;
    for (j of 0..4) {
        total = total + await jobs[j];
    }
    return total - 14;               // 0 + 1 + 4 + 9
}

A loop’s tasks are private to the loop that minted them, so do not hand a Future<T> handle itself to another task — the compiler rejects the static cases as a non-portable handle payload (E0729).

See also

concurrencyasyncfuturesawaitreactor