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, andawaitit later — exactly like the handle of anyasync 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
- Tasks and async — the model behind the three backings, and how to pick one
- The task world — the stackful backing, for a call that blocks
- Async I/O — sockets, DNS and HTTP in depth
- Concurrency primitives — locks, channels, atomics, and when you need them