Async and the event loop
The async line landed in phases: futures and the event loop in 0.4.0, the epoll reactor in 0.4.1, and the async func, await, and async_run keyword layer in 0.4.2. TCP networking and an awaitable channel receive followed in 0.4.3, and 0.4.4 made the reactor portable behind a poller seam and hardened it against closed peers, interrupted syscalls, and descriptor exhaustion. This page is the normative reference for the keyword layer and the substrate beneath it. An async func compiles to a single poll function over a heap-allocated task frame; await is a statement-level suspension inside an async body; and async_run is the only bridge from synchronous code into the loop.
For a task-oriented walkthrough, see the async guide. For the stdlib API surface, see std.async.
@import std.async.future@import std.async.loop
async func amain() -> int32 { println("in") return 7}
func main() -> int32 { le := loop_init() le.ignore() rc := async_run(amain()) loop_free() println(rc) return 0}Calling amain() mints a task and its Future<T> and does no work. async_run cranks the event loop until that future completes, then yields its value.
The signature rules
Section titled “The signature rules”An async func’s task frame and future are laid out at one declared shape, so it takes no type parameters: an async func cannot take type parameters. A method cannot be async, a method cannot be async, since a method call cannot suspend across the receiver’s borrow. main cannot be async, main cannot be async; call an async func with async_run instead, since it is the C entry point the runtime calls directly, with no task frame around it yet.
A parameter or return type may not be a future, since a future belongs to the event loop thread and the caller should await it instead: an async func cannot take '<name>': a future belongs to the event loop thread; await it in the caller instead, and symmetrically for a return, an async func cannot return a future; a future belongs to the event loop thread, so await it in the caller instead.
A parameter or return may not be a slice, a closure, or an interface value either, since the task frame outlives the call that made it and any of the three may view the caller’s stack: an async func cannot take '<name>': a slice, closure, or interface value may view the caller's frame, which the task outlives, and for a return, an async func cannot return a slice, closure, or interface value; the value would outlive the task frame it views. Both walks see through a struct or tuple parameter or return type, so a future or a view buried in a field is still caught.
An async func’s name is only a callable; it cannot be stored or passed as a plain value: '<name>' is async; call it with await or start it with async_run. A bare call that mints a future and drops it before it is ever awaited or released is rejected for the same reason a leak is rejected everywhere else: the future from '<name>' is never awaited; bind it so it can be awaited or released. A future that is bound then follows the ordinary unused variable rule.
await, in exactly four statement positions
Section titled “await, in exactly four statement positions”await is not an operator. It never appears mid-expression: 'await' cannot appear mid-expression; give the awaited value a name, as in v, e := await f. It is legal in exactly four statement shapes, and nowhere else, each keeping every value live across a suspension named and stored in the frame rather than sitting in an SSA register a resume cannot see.
v := await f // single bind: the value; the completer's error is discardedv, e := await f // destructure: the value and the completer's pending errorawait f // void discard, legal only when f's element is voidreturn await f // propagation: forwards the awaited tuple wholeThe void discard form is rejected when the awaited element is not void: 'await f' discards a value; bind it, as in v, e := await f. When the awaited future’s element is itself a tuple, such as the (int64, error) a fallible async func returns, a matching-arity destructure binds each member directly instead of the value-plus-error pair, and a mismatched name count is rejected: await destructures this future into {n} values, but {m} names are bound.
The error word of a two-bind await is a pending error like any other and falls under the ordinary must-handle rule; left unhandled it is the error '<name>' is never handled; inspect it with exists, handle it with check, or discard it with ignore.
await only suspends inside an async func body, and only directly inside it, never inside a lambda literal created there, since a lambda has no task frame of its own to suspend. Outside an async context, or inside such a lambda, a leading await not written as the plain call await(f) is rejected: 'await' is only legal inside an async func. Under defer, which runs at completion and can never suspend, a leading await is rejected the same way: 'await' cannot appear under defer; a defer runs at completion and cannot suspend.
await composes with every statement shape a value can sit in: a while, an if, a for over a named fixed array, and a match arm reading its payload after the await each survive the resume, because the loop counter, the array’s data pointer, length, and index, and the match payload are all frame slots reloaded on the resume edge. Ordinary rules keep applying underneath the keyword: move(p) into an awaited call still kills the mover’s name at compile time, so touching p after v := await consume(move(p)) is use of a moved pointer exactly as it would be with no await in the way.
async_run
Section titled “async_run”async_run(g(args)) takes a direct call of an async func, written at the call site, never a stored future: a future does not carry which async func minted it, so async_run takes a direct call of an async func, written at the call site is rejected even when the stored future genuinely came from one. It cannot be called from inside an async func, since the enclosing task frame can simply await the call instead: async_run cannot be called inside an async func; await the call instead.
Calling it from a synchronous helper the loop invokes while already cranking, an async body reaching a sync function that itself calls async_run, is not a compile error, since the checker cannot see through an arbitrary call graph. The loop refuses the re-entry by name at runtime instead: fatal: async_run re-entered the event loop.
The frame and the state machine
Section titled “The frame and the state machine”Below sema, an async func lowers to define void @async.<name>.poll(ptr %frame) plus an @async.<name>.framesize constant the call site reads. The frame is a heap block: a fixed 48 byte C task header the runtime owns, immediately followed by the dusk-visible frame the poll addresses, state word first, then the pending future’s data pointer and generation the last await wrote, then the result region, then every parameter in declaration order, then every local that must survive an await in emission order, each aligned to its own requirement and the whole frame rounded up to 16 bytes.
The poll’s entry block GEPs every one of those slots once, so every frame pointer is born in the entry block and dominates every resume edge, then loads the state word and switches on it: state 0 enters at the body’s start, and each await site registers its own state and its own resume label. An await stores the state that names its resume label, records the pending future’s data pointer and generation, suspends by returning from the poll, and the loop’s crank later calls the poll again at that state. A resume reloads whatever it needs from its frame slots rather than trusting an SSA value, since nothing survives a suspension except what a frame slot holds. A return, including the implicit one that falls off the end, replays every registered defer in reverse order exactly once, then completes the task with its result bytes and retires it. A state the switch does not recognize is impossible by construction and traps rather than guessing: fatal: a task resumed in an invalid state.
A closure created inside an async body is one exception to the frame-slot rule: its environment allocates from a per-task environment arena instead, one block per closure execution, freed in one pass when the task completes. The same per-execution allocation covers a slice backed by an array literal and an interface value boxed inside the frame, so a loop that builds a fresh closure, slice, or boxed interface on each iteration and stores it for later keeps every iteration’s value distinct rather than aliasing the last one through a reused slot.
Determinism
Section titled “Determinism”The whole async substrate runs on one loop thread with a FIFO ready queue. A task that becomes runnable, because its await found the future already complete or a completer enqueued it, joins the tail of that queue, and the crank runs one task to its next suspension or return before picking up the next. An await always costs exactly one scheduler turn, even against an already complete future, and never resumes inline, so two tasks each printing a line before yielding interleave in exact, reproducible program order: two worker tasks each printing a label and a counter around await tick() produce a0 b0 a1 b1 a2 b2, not a race. Anything that crosses the pool, a spawned thread, or the reactor funnels back through one future completion and one enqueue, so the loop thread’s own ordering is never in question.
Run to completion, no cancellation
Section titled “Run to completion, no cancellation”A task runs to completion once started; there is no mechanism to cancel one mid-flight. This is what makes the defer replay at true completion sound: a suspension is never a premature exit, so a resource acquired before an await and deferred for release is guaranteed to see that release, in reverse registration order, exactly once, whenever the task actually returns, never at a suspension partway through.
Errors as values
Section titled “Errors as values”There is no rejection channel. A completer hands its value and its error through together, and the awaited tuple destructures through the same must-handle machinery every other fallible result uses; return await f propagates the pair whole. await is monadic bind performed by the compiler: it sequences a suspending computation, threads its result into the frame that continues, and forwards its error alongside the value rather than short-circuiting through an exception.
The completer doctrine
Section titled “The completer doctrine”A future belongs to the event loop thread. A completer running on another thread, a pool worker or a spawned thread, never captures the typed Future<T> handle: capturing one is rejected wherever it would cross, a spawned lambda’s captures and a submitted lambda’s captures alike, since a heap-copied environment would carry the typed handle off the thread that owns it. Instead the completer carries the future’s two raw words, its handle and its generation, lifted out before the spawn or the submit, and completes through complete_raw, the completer surface built for exactly this crossing. complete and complete_raw are otherwise identical: exactly one completion wins and a late loser is refused and dropped, whether it arrives before or after the awaiter consumes the future.
The pumping doctrine
Section titled “The pumping doctrine”Inside an async body the only way to wait on a future is await. Nothing inside an async body may call the loop’s blocking await, await_timeout, or try_poll primitives directly on some other future to pump it manually, since that would park the one thread the whole loop cranks on, along with every other task, timer, and completion behind it. A pumped await that stalls the only crank thread does not hang silently; it converts a stuck task into the same named idle fatal an ordinary deadlocked await produces, since from the loop’s own gauges the thread is simply gone.
TCP networking
Section titled “TCP networking”std.async.net, added in 0.4.3, puts TCP over the reactor’s readiness futures, a thin library layer over the non-blocking socket calls and the readable and writable watches with no compiler change. tcp_listen, tcp_local_port, and tcp_close are synchronous; tcp_accept, tcp_connect, tcp_read, and tcp_write are async funcs. Each async call tries its non-blocking socket operation and, when it would block, awaits readable or writable on the descriptor and retries, so a server accept loop and its clients run as tasks under async_run and never pump the loop from inside a task.
tcp_write sends every byte, looping over writability and the non-blocking write until the whole buffer is gone, so a short write never silently drops the tail. tcp_connect finishes the non-blocking connect handshake by awaiting writability and then reading the socket error, so a connection refused after the handshake began surfaces as a clean error rather than a descriptor that fails on first use. Addresses are literal IPv4 dotted quads; there is no name resolution yet. A listener bound to port 0 is assigned an ephemeral port the caller reads back with tcp_local_port.
Awaiting a networking future is subject to the same rule as any other await: it is legal only inside an async func. Awaiting tcp_accept or tcp_connect from a synchronous function is rejected at the parse where await appears, 'await' is only legal inside an async func, exactly as awaiting any other future outside an async body is.
Reactor portability
Section titled “Reactor portability”The reactor’s kernel wait sits behind a six-function poller seam, create, destroy, arm, disarm, wait, and wake, over a normalized readiness mask. The thread above it, the watch registry, the armed gauge, and the fire path that completes a future, stays one portable core with no platform split, and the backend is chosen at compile time by a platform guard. reactor_epoll.c is the Linux backend, the existing epoll descriptor, eventfd sentinel, and one-shot arm lifted verbatim, so every reactor and net golden is unchanged. reactor_kqueue.c is the BSD and macOS backend over kqueue and kevent, an EVFILT_USER event as the wake sentinel in place of the eventfd. It is written and reads clean but stays unverified until a BSD or macOS runner compiles and exercises it, since this project builds on Linux with no kqueue header.
One behavior diverges and is documented rather than smoothed over: a close-while-armed then reused file descriptor re-arms clean on epoll, whose registration the close already dropped, but faults on kqueue, since EV_ADD cannot fail on a duplicate the way EPOLL_CTL_ADD returns EEXIST. The kqueue backend reproduces the already-armed fault by probing the registry first. Both backends reject a readiness watch on a regular file.
Hardening
Section titled “Hardening”Three guarantees hold across the non-blocking byte surface and the TCP surface built on it. SIGPIPE is ignored process-wide by a load-time constructor, so a write to a closed peer, a pipe or a socket, returns an error value instead of killing the process. The non-blocking write classifies that broken pipe distinctly, broken pipe, from a generic the write failed; a peer reset, which is ECONNRESET rather than EPIPE, falls to the generic one.
Every blocking syscall the reactor and its shims make, the poller’s own wait, a read, a write, an accept, and a connect, retries in place on EINTR rather than surfacing a spurious interruption as a failure the caller must handle. close treats EINTR as success rather than retrying, since the descriptor is already gone on return and a retry could close a reused one. A file descriptor mint, a pipe, a socket, or an accepted connection, that finds the process or the system out of descriptors, EMFILE or ENFILE, surfaces the named too many open files error rather than a crash. The mint that hits it opens nothing and leaks nothing, and the reactor stays usable, so a program recovers once the limit lifts. On accept the exhausted return is terminal, not a would-block, so the accept loop cannot spin on a listener that stays ready.
Awaitable channel receive
Section titled “Awaitable channel receive”chan_recv_async(c: Channel<T>) -> Future<T>, added in 0.4.3, makes a channel receive awaitable on the loop instead of blocking the caller, since a blocking chan_recv on the loop thread stalls every task. It mints a future and hands the blocking receive to a detached helper thread that completes the future off the loop thread; the loop awaits it like any other. The live-thread gauge is raised before the helper starts and dropped strictly after the completion, so the deadlock detector keeps the awaiter parked while the receive is outstanding rather than declaring the loop idle. A closed and drained channel completes the future with receive on a closed, drained channel, the message its blocking twin uses. Because the helper is detached and cannot be joined, the drain discipline is close and settle, not close then join: closing the channel releases the helper with the closed error, and the completion settles before the channel is freed. The future element obeys the same ban as future_new, so a slice, closure, or interface element is rejected where the future is minted.
The fault family
Section titled “The fault family”Every abort under the async keyword layer and the substrate beneath it is named and pinned by a golden.
| Message | Fires when |
|---|---|
fatal: use of a dead future | a future or task result is awaited, polled, or freed a second time |
fatal: two tasks await one future | a second task parks on a future that already carries a waiter |
fatal: the event loop is not running | a loop touch, an await, a completion, or a task start runs before the loop starts or after the owning loop is freed |
fatal: async_run re-entered the event loop | async_run is called while the loop is already cranking |
fatal: the event loop is idle but work is still pending | an await parks with no timer, no live thread, no in flight pool task, and no armed watch left to complete it |
fatal: a task resumed in an invalid state | a poll’s entry switch sees a state its own emission never produced |
fatal: a task resumed on a pending future | a resumed poll tries to take a future still in flight, an internal invariant, not a user reachable path |
fatal: out of memory | a task, its frame, or a closure environment block cannot be allocated |
The cost table
Section titled “The cost table”Nothing here differs from the cost 0.4.0 already names for a hand-rolled future; the keyword layer changes how the frame is built, not what completing one costs.
| Operation | Cost |
|---|---|
| An async call | one frame allocation, the task header plus the dusk-visible frame in a single block, and one future record in the generational heap; nothing runs until the loop schedules it |
| An await | one enqueue when its future is already complete, or one waiter registration followed by one enqueue from whichever completion reaches it, and one scheduler turn either way; it never resumes inline |
| A leaf future | one generational record, the kind future_new or a timer mints |
Ordering
Section titled “Ordering”A complete happens before the await, timed await, or poll that consumes the future it completed, so the awaiter reads exactly the pair the completer supplied. Code confined to the event loop’s thread gets the stronger memory story for free: one thread orders every free against every use, so the dereference check there is the deterministic single-threaded guarantee, never the degraded racing mode the thread memory model describes.