eskiuv0.9.2
Compiler internals · Async design

Async / Await: Design & Contract

This note is the contract reference for Eskiu's async runtime: the Future<T> ABI, the atomic four-state handshake, and the waker model. These are the parts that are expensive to change once async functions exist in the wild (see "What is locked, what is free"), so they are fixed here.

Implementation. The runtime model is built on stdlib/future.esk (the locked Future<T>/FutureHdr contract and the §3 handshake), the atomic intrinsics (<atomic>), and escaping closures (a waker/callback outlives its creating function, so it needs a heap env: escape analysis + the escaping qualifier + free_closure, spec §6.5). On top sit the Executor and leaf futures (<executor>/<net_async>), the async/await frontend, and the AST→state-machine transform (sema/async_transform.cpp). Supported: single and multiple await (fast path + suspend over real reactor reads, values threaded through frame fields across N+1 states), return await, bare await, async void, cancellation, and all control flow around await (if/while/C-style for/do-while/switch/match/for-in/try-catch, with break/continue), with full closure-env ownership, leak-free under leaks. An await may sit anywhere in an expression: a desugar hoists each one, in evaluation order, into a let of its own (§4.5). A defer in a block split by an await is kept by the transform and emitted at each exit of that block (fall-through, return, break/continue); an await inside a defer body or a finally is rejected. A lambda that captures a frame-hoisted local gets a block-local copy (T x = __fr.x;, __fr being the frame pointer) at its creation point, so the capture is still a by-value snapshot. Combinators (spawn/select2/join2, generic + cast-free) and a <timer> leaf future for deadline-based timeouts build on this shape.

Audience: compiler maintainers. Assumes familiarity with the existing closure model (fn(T)->R is a fat pointer {fn_ptr, env_ptr} that captures by value), monomorphic templates, the <eventloop> reactor, and <threading>.


1. Goals and non-goals

Goals

Prerequisite this design pulls in

Non-goals (v1: deferred, each forward-compatible)


2. The runtime model: completion + waker, atomic, with drop

Two classic shapes: poll-based (Rust; inert futures, executor polls: payoff is avoiding per-future allocation) and completion + waker (JS Promise / C# Task; the future holds state + result + continuation).

We choose completion + waker. Our reactor already dispatches on readiness (a completion event), we already heap-allocate frames (so poll's allocation savings don't apply), and completion is simpler to generate: suspend points are explicit.

The shape, an ordinary stdlib template (stdlib/future.esk):

struct Future<T> {
    int        state;     // ATOMIC. 0 pending, 1 waiting, 2 ready, 3 cancelled  (§3)
    fn()->void waker;     // completion path: schedule the awaiter on its home executor
    fn()->void on_drop;   // cancel path: release own resources + cascade
    T          value;     // valid only when state == ready; LAST (§2.1)
}

A type-erased header aliasing the first three fields for any T:

struct FutureHdr { int state; fn()->void waker; fn()->void on_drop; }

(FutureHdr*)f lets cascade-drop, the executor, and any thread touch state/waker/on_drop without knowing T.

2.1 Why value is last

Future<int> and Future<string> are distinct monomorphizations of different sizes. If value sat before the other fields, waker/on_drop/state would land at different offsets per T and no FutureHdr view would be possible; cascade-drop and cross-thread dispatch would break. value last fixes the header offsets for every T. Free today, a hard break later, hence locked.

2.2 Why state is atomic

In multi-thread, a completer (worker thread) and the awaiter (home thread) touch the same future concurrently. Without atomicity there is a lost-wakeup race: the awaiter decides to park and the completer signals readiness in between. The fix is a lock-free handshake on an atomic state with four values (§3) and acquire/release ordering so value, written before publish, is visible after the reader observes ready. Atomicity is a property of the accesses, not an extra field, but the four-value encoding and the ordering are part of the locked contract.

Why these four fields and only these (the full walk) is §5.


3. Protocols (the compiler↔generated-code ABI)

state is an atomic int: PENDING=0, WAITING=1, READY=2, CANCELLED=3. READY and CANCELLED are terminal. All transitions are CAS/swap with acquire/release; value is written before the publish to READY and read only after observing READY.

3.1 Park (awaiter, on its home thread): let x = await F;

F.waker = <resume-me>;                       // publish continuation
if (CAS(&F.state, PENDING -> WAITING)) {
    frame.awaiting = (FutureHdr*)F; return;  // parked; executor will resume us
}
// CAS failed -> F is already READY (completer beat us): take the value inline
let x: T = F.value;  frame.awaiting = null;  free_future(F);
// ...continue with x...

3.2 Complete (producer, any thread)

F.value = <result>;                          // write result first
F.on_drop = <just-free F>;                   // external resources already released
old = SWAP(&F.state, READY);                 // release
if (old == WAITING) F.waker();               // awaiter parked -> schedule its resume
// old == PENDING: awaiter not parked yet; it sees READY in its CAS and takes value inline
// old == CANCELLED: dropped first -> producer releases <result> and frees (§3.4 arbitration)

The waker does not run the continuation inline. It schedules the awaiter's resume on the awaiter's home executor (§4 captures that executor in the waker closure) and wakes that executor. So completion on a worker thread resumes the coroutine on, e.g., the UI thread, and, as a bonus, resume is never a nested call, which removes the deep-stack concern entirely.

3.3 Drop / cancel (any thread): future_drop((FutureHdr*)F)

old = SWAP(&F.state, CANCELLED);
if (old == READY) { free_future(F); }        // completed already: just free memory
else { F.on_drop(); }                        // PENDING/WAITING: release + cascade + free

3.4 Arbitration (the one invariant)

A future is finalized exactly once. The atomic swap to a terminal state (READY or CANCELLED) has a single winner; the loser observes the terminal state as its old and performs only the free, never a second resource release. Memory is freed exactly once, by whichever path reached terminal.

This is the heart of the design. The contract locks the requirements (atomic 4-state, ordering, single-free); the exact CAS choreography lives in stdlib/future.esk, exercised by a cross-thread test.


4. Lowering an async function to a state machine (AST transform)

async int fetch_len(EventLoop* lp, int listen_fd, *uint8 buf) {
    int fd = await net_accept_async(lp, listen_fd);        // await #1
    int n  = await net_read_async(lp, fd, buf, (int64)64); // await #2
    return n;
}

4.1 The frame

One struct per async function: embeds the return future (one allocation), resume state, the awaiting back-pointer (cascade-drop), the home executor, params, and locals live across an await:

struct __Frame_fetch_len {
    Future<int> ret;       // &frame.ret is the returned Future<int>*
    int         st;        // resume state
    FutureHdr*  awaiting;  // inner future currently parked on (null otherwise)
    Executor*   home;      // where this coroutine's resumes must run (thread-affinity)
    EventLoop*  lp;        // param
    int         listen_fd; // param
    *uint8      buf;       // param
    int         fd;        // local live across await #2
}

awaiting/home are frame fields, not Future fields, free to adjust, no contract cost. The frame is confined to its home executor: only that executor ever calls __resume_* on it, so the frame needs no locking. The only cross-thread object is the Future header, synchronized per §3.

4.2 Constructor + resume

switch (f.st) {
case 0:
  Future<int>* g = net_accept_async(f.lp, f.listen_fd);
  f.waker_of_g = <closure capturing f, f.home: schedule "f.st=1; __resume(f)" on f.home>;
  // park via §3.1 against g; if g already ready, fall through with g.value
  ... if parked: f.awaiting=(FutureHdr*)g; f.st=1; return;
  f.fd = g.value; f.awaiting=null; free_future(g);
case 1:
  Future<int>* h = net_read_async(f.lp, f.fd, f.buf, 64);
  ... (same park/fast-path against h, resume label 2) ...
  int n = h.value; f.awaiting=null; free_future(h);
  // return n: complete our own future (§3.2), then the awaiter's waker frees f
  f.ret.value = n; f.ret.on_drop = <just-free f>;
  old = SWAP(&f.ret.state, READY); if (old==WAITING) f.ret.waker();
  return;   // do not touch f afterward
}

The continuations are existing closures capturing f and f.home by value: no new machinery. (The §3.1 park sequence is emitted inline at each await; shown abbreviated.)

4.3 Fast path

The park's failed-CAS branch means an already-ready inner future does not suspend: read value, fall through. Zero extra round-trips when nothing blocks.

4.4 Generic async functions

async T f<T>(...) lowers once, generically: the frame struct, the resume function, and the constructor are templates over f's type parameters (__f_frame<T>, __f_resume<T>, *Future<T> f<T>(...)), instantiated per use like any generic. Each awaited type must be spelled in terms of T. The C++ transform recovers it from the type checker's per-instance records (AwaitExpr::instanceTypes: the instance's type arguments and the awaited type), choosing a spelling that reproduces every checked instance. The self-host pass resolves it from the source directly (al_await_type). A generic async function that is never instantiated is left as is.

4.5 Awaits inside expressions

Before the state split, a desugar (hoist / self-host al_hoist) moves every await, in evaluation order, into a let of its own, so the lowering only ever sees let x = await E;:

x += g() + await f();      // let __sp = g(); let __aw = await f(); x += __sp + __aw;
a[i()] = await f();        // let __sp = i(); let __aw = await f(); a[__sp] = __aw;
ok = c() && await f();     // let __sc = false; if (c()) { let __aw = await f(); if (__aw) { __sc = true; } }
while (await more()) B     // while (true) { let __aw = await more(); if (__aw) {} else break; B }

An operand written before an await is held in a temporary only when it has a side effect (a call, an assignment, ++/--); the temporary's type is the one the type checker gave the operand (the C++ transform reads the checker's expression types and spells them as a declaration would, declType; the self-host sema stamps ExprNode.aty in async functions). An assignment target keeps its place and only the parts that compute it (an index, a pointer) are held, so it is evaluated once. ?: with an awaiting arm needs its result type the same way. A do/while or for whose condition or step awaits becomes a while (true) whose first pass skips the test or the step, so continue still runs them. A switch/match subject, a for-in iterable and a range bound are hoisted before their statement (evaluated once). In a generic async function the per-instance types of those operands are not recorded, so a temporary for a side-effecting operand, a ?: arm or a match arm's payload binding there is a located error.

4.6 match and try

A match whose arms await dispatches in the current state: each arm copies its payload bindings to frame fields (typed by the checker's stamp, bindingTypes / arm_types) and selects the arm's entry state.

A try whose body or handlers await is split into states grouped in two regions (the body's and the handlers'). At the end of lowering, each state of a region is wrapped in a synthesized try:

try { <state> }
catch (T x) { <defers pending in the region>; fr.e = x; fr.st = <handler state>; }
finally-on-unwind { <defers pending in the region>; <the try's finally> }   // then rethrow

innermost region first, so an exception thrown before or after a suspension reaches the handler of the try it is thrown in whichever resume runs it. The finally of the synthesized try (TryStmt::unwindOnly, self-host SK_TRY is_err = 1) runs only when no catch matched. On the other exits the user's finally is kept like a defer: emitExit runs it on fall-through, return, break and continue, in a state outside the try's regions (an exception it throws is not caught by its own handlers), and publishes a completion outside every region. Each state of a region keeps a fixed set of pending defers (a defer registered inside one starts a new state), so the handler runs exactly the defers pending where the exception was thrown. An await inside a finally is rejected (§7).


5. Field walk: why {state, waker, on_drop, value} survives everything

Await source Completes via Drops via New field?
Socket readable (leaf) loop callback reads, §3.2 on_drop=EventLoop_del+free no
Another async function resume §3.2 on_drop cascades awaiting, frees frame no
Timer await sleep(ms) loop timeout, §3.2 on_drop cancels timer+free no
await thread_join(t) / worker pool worker completes on its thread; waker schedules resume on home executor (§3.2) on_drop detach+free no
await chan.recv() sender §3.2 on_drop unlinks+free no
UI: bg work → UI-thread continuation worker §3.2; waker enqueues on UI executor parent on_drop no
select/join (later) first child §3.2 parent drops losers no

5.1 Future<void>

async void f() lowers internally to Future<Unit> (Unit = 1-byte uint8); await f() discards the value. Invisible at source level.


6. Executors, the event loop, and thread-affinity

An Executor is a thread plus a thread-safe ready-queue and a wakeup mechanism (self-pipe / eventfd registered with that thread's <eventloop>, or a condvar). current_executor() returns the one running the calling code.

Cross-thread enqueue + wakeup and the ready-queue are executor machinery, free to evolve (single-thread, fixed pool, later work-stealing). What is locked is only that completion goes through waker() and waker is responsible for landing the resume on the right thread.


7. Memory and cancellation: one invariant

Every future is finalized exactly once: awaited to completion (the awaiter frees it), or dropped (future_drop frees it via on_drop). The atomic swap to a terminal state picks the single winner (§3.4); the other path only frees.

One allocation per async call (frame + embedded return future), one per leaf future. A future created but never awaited or dropped is a preventable bug, not an accepted leak: the transform inserts future_drop on scope-exit paths where a Future local was created and not consumed.

Cascade. Dropping a suspended coroutine drops frame.awaiting first, recursively, then frees the frame: a whole await-chain torn down by dropping its head. The combinators take part: an unresolved select2/join2 (and select2v/join2v) has an on_drop that drops both inputs (a finished join2 input is just freed), so a cancelled awaiter never leaves an input whose completion would wake freed memory.

Cleanup on drop: dropping a suspended coroutine does not continue it past the await, but the defers and finally blocks pending at that await run exactly once, innermost first, before the frame is freed (matches a scope exit by unwinding: Rust async-drop runs destructors the same way). The transform records, per await, the cleanup code pending there and emits it as a state of its own; on_drop cascades to the awaited future, then sets the resume state to that cleanup state and calls the resume function once. Because that runs inside future_drop, which cannot suspend, an await inside a defer or a finally is rejected. A coroutine that must keep running after a cancel request uses a cooperative cancellation token it checks (a normal threaded value, no contract change), a deliberate later feature.


8. Surface syntax and type rules


9. Components

The async stack decomposes into independently testable layers:

The hand-written coroutine over future.esk is the behavioural oracle: the same logic expressed in async/await must match it, and the suite adds a cancellation test (the leaf fd is deregistered and the frame freed) and a thread-affinity test (completion on a worker, resume observed on the home executor's thread).


10. What is locked, what is free

Locked now (compiler↔generated-code ABI: breaking these recompiles/rewrites every async function in existence):

Free to change later (touches only stdlib / the executor / the transform's internals: no recompile of user async code):

value-last, the atomic four-state handshake, on_drop, and the home-executor waker contract form the locked set: the ABI every async function and every leaf future is generated against.