Tasks and async
Axle has two concurrency worlds behind one handle type, Future<T>, and
they differ in one thing: what the waiting work needs while it waits. A Future<T> has exactly one consuming verb — await — whichever backing it
holds; the backing is a property of the handle, never a choice the consuming
site makes.
What this page covers: the two backings behind Future<T> and how to pick
between them · the third backing, Thread · what is refused at compile time ·
how the runtime runs a task and a coroutine, and what neither can do. What each
backing looks like in a real program has its own page: The task world and The async world.
- The task world —
spawn f(args)→Future<T>. Work that blocks gets a stack of its own out of a per-thread pool, and the carrier switches into it by hand. - The async world —
async fn→Future<T>. Work that is waiting for readiness gets a saved state machine driven by a cooperative event loop, and no stack at all.
Neither of them gives the work a thread of its own: a spawned task runs on
a carrier — a thread of the process’s small pool — and a coroutine runs on
the thread that drives its loop. The third handle, Thread, is the one that
owns a platform thread neatly — see The third backing once the two task worlds read
clearly. Read the next two paragraphs before the APIs — almost every mistake
with spawn comes from expecting something the model deliberately does not
do.
- Parallelism — two things happening at the same instant, on two physical CPU cores. Only the operating system places two streams of instructions on two cores.
- Concurrency — many things in flight but only one running at any instant, interleaved by handing control back and forth.
Both worlds give you the second, and the task world reaches a core of its
own too. A spawn is run by a peer carrier, another OS thread of the
pool the first spawn boots, so its body can be running at the same instant as
the frame that spawned it. The pool is sized from the machine (cpus - 1,
never fewer than two nor more than 64, AXLE_CARRIERS to say otherwise), so a thousand spawned
tasks spread over the cores rather than queueing onto one — up to that many,
after which they take turns. A coroutine never
overlaps anything: the loop that drives it is the thread that awaits it. Thread::run(f, args…) is the surface that gives a call a platform thread of
its own — one per call, with no pool ceiling.
So the question these two worlds answer is not “how many cores?” but “what does the waiting work need?” A call that blocks — a socket read on a hand-written loop, a third-party library that owns its own control flow — needs a stack of its own to sit on; that is the task world, and it is the reason a task is a real stack rather than a saved frame. A wait that can be expressed as readiness — a socket the reactor watches, a timer — needs no stack: one thread holds a whole population of state machines and advances each the moment its wait resolves. That is the async world.
The two also differ in how readily they give the thread back. A task yields
at an await it performs and at any wait the runtime owns — a channel, a
lock, a latch, a Thread.join, a blocking socket call, sleep — so a spawned function that makes none of those runs start to finish with the
thread to itself. A coroutine yields at every suspension, which is what lets
thousands share one thread.
The two worlds
Task world — a stack you switch into
spawn f(args) mints a stackful task: it takes a stack out of a thread’s
pool, switches into it by hand, and returns a Future<T> handle. The task is
run by a carrier — one thread of the process’s small carrier pool, taken by
whichever carrier is free. It runs whether or not anything awaits it; that is
the one place spawn and a call to an async fn differ, since both hand back
the same Future<T> and a coroutine waits for a driver
(The async world). The task table, the armed reactor and the fault
handler are thread-local, so nothing migrates once a task has started; a
task that has not started is published to the pool’s injector, and any carrier
but the one that spawned it may take it. That holds however deep the spawn is
written: a function that spawns in a loop spreads its work over the pool rather
than piling it onto its own thread, and a task that has been published waits for
a carrier to take it — a cost bounded by a peer’s loop, which is what the
distribution buys itself. When every peer is busy, the carrier that spawned the
task takes it back after a short grace and runs it itself — and an await on a
task no carrier has started yet takes it back at once, since the awaiting
carrier has nothing else to do until the result exists. A spawn from a Thread::run body goes to the pool the same way, and its result comes back to
that thread.
A plain main (or Thread::run body) that calls sleep while tasks of its own
carrier are waiting to run keeps running them through the pause, so a loop that
sleeps between checks of a flag its tasks set does not hold those tasks back.
A task is placed once, and runs on that carrier for life. A carrier that already
holds a task tries to leave the next one of a burst to an idle peer that holds
fewer, but placement is a scheduling choice, not a promise: a crew of spawned
workers launched in one tight burst may still share carriers. A worker that
lives as long as the program, and must have a core to itself, is better written
as a Thread::run: it owns a platform thread instead of a share of a carrier.
A wait the runtime owns parks the task and frees the carrier, so a sibling
keeps running meanwhile: 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, whose syscall runs on the worker pool.
A task may also block — it has a real stack, so its frames survive the wait — and what such a wait blocks is the carrier: the thread it was switched onto, and every task already on that thread waits with it. That is every 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. Such a wait is safe only while nothing else on the carrier has to advance before it returns.
the frame, a peer carrier, one task
──────────────────────────────────────────────────────
carrier 0 (the frame) another carrier
───────────────────── ──────────────────────
let t = spawn encode(data) ──→
(published to the
pool's injector) fn encode(data) { … }
(runs from its start,
in parallel with the
frame that spawned it)
let r = await t … parks
carrier 0, watching
its own doors … ←──── result
main resumes spawn takes a function call — spawn encode(data), never a block spawn { … }. A capturing closure is rejected (E0734): the arguments
travel into the task explicitly, as the call’s parameters.
The task keeps every argument alive for its whole run, even when the function
that launched it returns first — the shape of return spawn work(data):
| Argument | What the task holds |
|---|---|
a Shared<T> | its own reference: the count goes up at the launch and down when the task’s call returns |
an object, an array, a struct or a string you made in this function and do not use after the launch | the value itself: the task frees it when its call returns, and your function no longer does — unless the function may hand back part of it (fn child(h : Holder) : Node { return h.n; }), in which case it stays yours |
a stdlib handle (AtomicI32, CountDownLatch, a channel, a lock) | the handle stays open — your function no longer closes it at its end, since any number of tasks may still be using it |
a value passed to an own parameter | the called function owns it, as with any call |
An owned object or array you keep using after the launch stays yours, and is
released at the end of your function: await the task before then.
(Thread::run and Thread::create follow the same rules.)
The value await hands back is yours to keep, and is released at the end of
its scope, when the task’s function returns a value it made — as for the
result of a direct call. A function returning a field of its argument hands
back an object that argument still owns: you may read it, and its owner
releases it.
Reach for it when the work needs a frame it can block in — a file read, a
third-party library, a call shaped as “start it, do something else, collect
it”. You get a real stack for it, a handle you settle with await, and a core
other than the one you are on.
Async world — a state machine the loop drives
An async fn body is compiled into a coroutine state machine. Calling
it does nothing: it allocates a frame and returns a Future<T> handle.
The body starts running only when await enters the event loop — a
scheduler on the calling thread that drives every live machine in
round-robin. When a machine hits an await, it suspends: it saves its
locals into the heap frame, yields to the scheduler, and the scheduler
picks up the next ready machine. When the awaited thing resolves (a socket,
a timer, a nested await), the scheduler resumes the machine where it left
off.
single thread — the event loop
───────────────────────────────────────────────────────────
machine A: connect to API → suspends (waiting for TCP)
machine B: read from socket → suspends (waiting for data)
machine C: 50ms timer → suspends
machine C: timer fires → resumes → done
machine B: data arrives → resumes → done
machine A: TCP ready → resumes → done Reach for it when the work is waiting, not computing: thousands of simultaneous connections, an HTTP fan-out, anything bounded by a socket or a timer. A suspended coroutine costs its frame and nothing else, so ten thousand of them on one thread is routine.
A coroutine must never block. One thread hosts thousands of them, so a single blocking call freezes the lot. A task has more room — a carrier does run one task at a time — but no immunity either: a call that holds the carrier stalls every task on it, which is a hang the moment one of those tasks is the thing the call is waiting for.
Side by side
| Dimension | Task world — stackful backing (spawn) | Async world — coroutine backing (async fn) | Thread — platform-thread backing (Thread::run) |
|---|---|---|---|
| The unit is | a stack, taken from a pool and switched into by hand | a coroutine state machine, driven by an event loop | a real OS thread, created by the runtime |
| What it costs | 16 KiB committed resident, on a 1 MiB reserved mapping grown on demand | a heap frame, a few hundred bytes for typical locals | a platform thread — a kernel thread and its stack |
| Where it runs | a carrier — one OS thread; a spawn’s task on another carrier of the pool | the thread that drives the loop — one OS thread | whichever core the OS schedules it on |
| Parallel across cores? | across the pool: each carrier is a core, sized from the machine | no | yes, a thread per call |
| Blocking a call is | allowed; the call stalls this carrier — every task on it waits too, so it hangs if the call is waiting on one | poison — stalls every coroutine on the loop | what the thread is for; it stalls that thread alone |
| Practical ceiling | deep, bounded by the 1 MiB reservation, then a named abort | tens of thousands of live machines | bounded by the process’s thread limit |
| Handle | Future<T> | Future<T> | Thread |
| Consume with | one verb: await, or .timeout(ms) with a deadline / .isAlive() (peek) | one verb: await, or .timeout(ms) with a deadline / .isAlive() (peek) | one verb: .join() / .isAlive() (peek) |
| Best at | keeping a blocking call in a frame of its own | interleaving thousands of waits | a hot loop or a blocking call that must not stall the carrier |
The dividing line for the two task worlds is what holds the carrier, not
cost and not scale. A carrier runs one task at a time, so a spawn is safe
exactly when no other task on that carrier has to advance before it returns.
A task that awaits another parks, and leaves its carrier to whatever else it holds — the awaited task included. What has no way out is a call that holds the carrier while a task on that same carrier is the thing it waits for. A coroutine has no escape at all: it may not block anything, because the same thread is hosting thousands and each one was about to be advanced.
Thread is the answer to the one question neither task world can answer. It
does not share the carrier, so it is not on either side of that dividing
line — see The third backing.
Which one
| Situation | Backing |
|---|---|
| A blocking call with nothing else on the carrier waiting on it | spawn — stackful |
| A file read (there is no async verb for files) | spawn — stackful |
| A result you want to settle later, from a call that can throw | spawn — stackful |
HttpClient::getAsync (its body is a spawned blocking exchange) | spawn — stackful |
| Waiting on readiness: sockets, DNS, timers | async fn — coroutine |
| Thousands of concurrent connections | async fn — coroutine |
| A core per call, rather than a share of the pool’s carriers | Thread::run(f, args…) — a platform thread |
The two worlds compose in both directions — see Composing the two worlds.
The third backing — Thread
A coroutine runs on the carrier that drives it, and a spawn runs on a carrier
of the pool — one of a fixed, machine-sized set. One call is bound by neither: Thread::run(f, args…) creates a real platform thread, runs f on it, and
hands back a Thread handle. It is the only surface that gives a call a
thread nothing else shares. Thread::create(f, args…) prepares the same
thread without starting it, and the handle’s run() starts it — for a
program that must hold the handle before the body runs. A join() before run(), or a second run(), raises IllegalStateException. A task that join()s parks until the thread is done, so its carrier keeps running the
others.
use std::concurrent::Thread;
use std::lang::IllegalStateException;
fn sumRange(lo : i32, hi : i32) : void {
let acc : i32 = 0;
for (i of lo..hi) { acc = acc + i; }
println(acc);
}
fn main() : void ! IllegalStateException {
let a : Thread = Thread::run(sumRange, 0, 50);
let b : Thread = Thread::run(sumRange, 50, 100);
a.join();
b.join();
} A Thread shares its handle space with Future<T> — both are one-word handles
into the same registry, so an i64 handle is unambiguous — but not its
verbs. A thread is consumed by .join(), which waits until the body
finishes — a task parks, a plain frame blocks — and re-raises whatever it
threw. join() returns nothing: the body’s own return value stays on the
thread and is not handed back — freed there when the body made it (a new object, a built string, a Shared<T> reference), left to the caller when the
body may return a value it was passed; .isAlive() is the
non-consuming liveness peek. await on a Thread is E0719, exactly as .join() on a Future<T> is: the wrong verb for the handle, refused at
compile time.
Two limits follow from the entry contract. The entry is a name — Thread::run(f, args…) passes f and the values to call it with — never a
closure: the thread’s one-word entry has nowhere to put a captured
environment (E0001; a call with no entry at all is E0006). And the handle is one-shot: a second join() throws IllegalStateException, and dropping the handle without joining detaches the
thread — its work still happens, and only the ability to wait for it is gone. join() declares that refusal (! IllegalStateException, as run() does), so
a function that joins catches it or lists it in its own ! set — E0005 otherwise.
Reach for it when the work is genuinely CPU-bound for a long stretch, or must
make a blocking call that nothing else can be waiting behind. The task worlds
are the cheaper answer to every other question, and spawn remains what you
want for thousands of waits at once.
Restrictions
These all concern the body of an async fn. A plain fn (including main) may await / .timeout() freely, anywhere — those calls park the
task, or drive the event loop inline from a plain frame. That is why a for loop over an array of
tasks is the normal fan-in shape, and why an accept loop that must repeat
lives in a plain fn.
| Error | Construct (inside an async fn body) | Workaround |
|---|---|---|
| E0721 | await or .timeout(ms) inside a loop | Make the loop body a recursive async fn |
| E0722 | await or .timeout(ms) inside defer / try-finally / synchronized | Restructure; bare try/catch is fine |
| E0723 | async fn main | main is the event-loop driver — it can’t itself be a coroutine |
| E0725 | Arena allocation inside async fn | Use heap allocation (new) instead |
| E0726 | async fn with no suspension point | Add an await, or drop async |
| E0767 | A call to a plain function whose frame parks — it awaits, enters synchronized, or makes a parking wait such as sleep(ms), a channel receive() or a lock() — directly or through the functions it calls; a synchronized block in the body itself; or any call through a function value — a lambda or a named function held in a variable, capturing or not — since the value names no body the compiler can check | spawn the call and await the handle: the spawned task parks on its own stack. For a function value, call the function by name, or spawn a plain function that makes the call |
The recursive shape an accept loop needs is worth spelling out: each open level keeps its coroutine frame alive until its callee resolves, so a recursion with no base case holds one live frame per connection it has served, for the lifetime of the process. Bound it.
These apply anywhere, not only inside an async fn:
| Error | Construct | Workaround |
|---|---|---|
| E0719 | The wrong consumption verb for the handle | A Future<T> is consumed with await; a Thread with .join(). The other verb is rejected at compile time |
| E0728 | await of something that is not a Future<T> | Drop the await — .timeout(ms) already resolves the handle |
| E0510 | A second consumption of the same Future<T> on every path reaching it — a second await f or f.timeout(ms), through a copy (let g = f;), of the same constant element fs[0], a second await all fs (or any element after it), or after passing f to a function that awaits it on every return | A handle is one-shot; await f, f.timeout(ms) and await all fs drain it. A second consumption on some paths only, or of a handle reached through a field or a collection, raises IllegalStateException at run time instead |
| E0734 | A capturing closure passed to spawn — directly, or in a field of an object or struct handed to it — or held live across an await | Pass the captured values as parameters instead. A named function or a lambda capturing nothing carries no environment and is not refused |
| E0729 | spawn / await / t.timeout(ms) of a function whose result can’t cross the handle, or a Future<T> handed to spawn / Thread::run — as an argument, or inside an object (a Shared<T> field) passed to it | Return a portable result (scalar, string, any pointer, tuple or array, class instance / Shared<T>, stdlib handle); box anything else behind new. Await a handle in the task that launched it and pass the value |
| E0738 | Async::all(x) / await all x where x is not a Future<T>[] | Pass an array of Future<T> |
| E0739 | Async::all with more or fewer than one argument | Async::all(futures) takes exactly the array |
spawn of an async fn is allowed — the spawned task runs the
coroutine on its own event loop.
A result handed back through a handle — spawn and await — travels across
the task or coroutine boundary through one uniform handle, so it must be a portable value. A Thread::run entry’s result is not handed back at all,
so it is not checked — unless the entry is an async fn, whose result still
crosses the coroutine’s own handle on the thread. Integer and float scalars, bool / char,
strings, every pointer (heap, borrow, arena, raw), struct / tuple /
fixed-array values, class instances and Shared<T> references, and stdlib handles all round-trip cleanly. A few
shapes can’t ride the handle — a SIMD vector, a function value, a Range, a
nullable wrapper, or a nested Future<T>. If a return type is one of
those, the compiler reports E0729 at the launch or consumption site; wrap
the value in a struct or a small class and return that instead — a struct
value and a new-allocated instance both ride the handle:
struct Report {
rows : i32;
bytes : i64;
}
fn analyse(path : string) : Report {
let r : Report = Report { rows: path.length, bytes: path.length as i64 };
return r;
}
fn main() : i32 {
let job : Future<Report> = spawn analyse("/var/log/app.log");
let report : Report = await job;
return report.rows;
} Sharing state with a task
A spawned task can see the memory its spawner sees — one process, one address
space — and the other carrier touches that memory at the same instant the
spawning frame does. What may cross into the task is a portable value, and
the tier that survives a spawn site on purpose is Shared<T>. A Shared<T> handed to a spawn is what makes its class cross-boundary traffic, so the
compiler counts it with atomic operations; a refcount that never reaches a
spawn site stays a plain non-atomic count. Atomicity covers the handle, not
the payload: the refcount is exact, the object behind it is not.
Two tasks of one carrier cannot interleave a section that makes no wait,
because a task yields to a sibling of its own carrier only at an await or a
wait the runtime owns (a channel, a lock, a latch, sleep, a blocking socket
call…). A task on another carrier is a different thread and waits for no
yield: what the spawning frame and the task it spawned both touch needs an
atomic or a lock. See Concurrency primitives for the case where a lock is load-bearing.
Under the hood
Task world
spawn fn(args) emits a private thunk — an extern "C" fn(*mut u8) -> i64 that unpacks the argument block,
calls the target, frees the block, and widens the result to i64.
Blocks come from a per-thread recycling pool only while the process
has spawned no thread at all — the pool is keyed by the freeing
thread, so it stays coherent only when the freeing thread is the
allocating one, which is the single-threaded case (an event-loop
thread minting and releasing coroutine frames of one shape). The first spawn boots the carrier pool, so from then on blocks are freed directly
through the global allocator, and a tight spawn / await loop pays one
allocator call per spawn. The i64 is narrowed back at the await (truncated, bitcast, or pointer-cast as needed).
Async world
An async fn is rewritten into a flat state machine before codegen. Its
locals move into a heap frame (word 0 is __state); each suspension point
becomes a numbered state; the resume function dispatches on __state and
returns 0 (“still running”) or the widened result (“done”). Each OS
thread that drives coroutines keeps its own task registry and its own
reactor, so two loops never take a lock on each other’s behalf. Exceptions
thrown inside a coroutine are captured (class + message) in a Done entry
and re-thrown on the consuming thread at the await, so a try/catch around the await intercepts them naturally. When the coroutine finishes
(by any path), the runtime frees any string fields in the frame before
releasing it — no leaks from strings built inside an async fn.
The calls the async world does not answer on its own are the ones with no
readiness to wait for: a name lookup blocks for as long as a resolver takes,
and there is no fd to watch. Those run on an offload worker pool — four
workers kept for good, growing on demand up to a ceiling, with a worker beyond
those four retiring once it stays idle — and the loop wakes the coroutine when the job publishes its result. A hostname connectAsync takes that path; an IP literal does not, because connecting
to a literal never reaches the resolver. A socket write that would block
does not: it waits on the reactor like a read. The pool is the escape hatch
for the calls the reactor has no descriptor for.
That pool is one of the places the runtime starts OS threads without a thread
asked for by name: the carrier pool a spawn runs on is another, and Thread::run starts the one the program did ask for.
What reaches the pool is a submitted job. The stdlib’s own awaits submit one,
and so do the blocking socket verbs of std/net: a synchronous accept, connect, read or write parks its task and hands the syscall over, so a call
in your own Axle code that goes through Socket or ServerSocket reaches it
too, and so does Process.waitFor. The prelude sleep(ms) parks its task on
a timer instead. What does not is a blocking call no stdlib verb stands behind
— a file read or write, a call into C of your own: those hold the carrier they
were made on, and Thread::run is the surface that puts one on a thread of its own instead, at
the price of a platform thread rather than a pool slot.
Limitations
The boundaries below are properties of the model, not gaps waiting to be filled. Each one is something to design around rather than something the compiler will grow out of.
| Limit | Why it holds |
|---|---|
A Future<T> handle does not leave the task, thread or loop that minted it — a nested Future<T> is not a portable payload (E0729) | the handle is a key into a per-thread table, and the key does not migrate |
A handle is consumed once: a second await f or f.timeout(ms) is E0510 | await and .timeout(ms) both drain the handle, so a second read has nothing left |
A value crossing a handle must be portable — a SIMD vector, a function value, a Range, a nullable wrapper and a nested Future<T> are not | one uniform handle carries the result across the boundary |
.timeout(ms) on a spawned task stops the wait, not the task | a task is never cancelled: it runs to its end and its value is dropped |
A Thread has no .timeout(ms) | it is consumed by .join() alone |
An async fn may not allocate in the arena (E0725) | its frame outlives the arena scope the allocation would have been bounded by |
| There is no async verb for regular files | a file has no readiness to report; use the task world |
| A task’s recursion is bounded by its 1 MiB reservation | the reserve is the ceiling, and reaching the guard page stops the process with a diagnostic |
| The offload pool is bounded, at 256 workers | past its ceiling a blocking call queues behind the ones in flight rather than starting another worker — and a blocking socket wait holds its worker for as long as it waits, so a server keeping more idle connections than that through blocking reads stalls; an …Async read holds none |
The error codes an async fn body raises for await placement, and the
workarounds for each, are in Restrictions.
See also
- Concurrency primitives — locks, channels, atomics, and when you need them
- Async I/O — sockets, DNS and HTTP in depth
Shared<T>— the tier that may cross aspawnsitestd/concurrentreference