Async/Await in Synoema
Stackless concurrency — from a single timer to a TCP echo server
Phases D, E, G, H1, H2, H3 deliver a complete async stack: stackless state machine compilation in JIT, a three-component IO reactor (timer wheel + file-IO pool + mio TCP), race/gather task combinators, an async TCP server API, scope_result/try_await error propagation, and cancellation tokens. All backed by 58+ tests.
1. Async/Await basics
An async fn returns a Task a — an asynchronous computation that will eventually produce a value of type a. Inside an async fn, await suspends execution until the inner task completes and unwraps its result.
-- async fn: declares a function that returns Task a
async fn fetch_hostname =
content = await (async_read "/etc/hostname")
content ++ " (fetched)"
-- await unwraps Task a → a inside async fn
async fn pipeline src dst =
raw = await (async_read src)
n = await (async_write dst (upper raw))
n
-- main uses await at the top level
async fn main =
result = await (pipeline "/tmp/input.txt" "/tmp/output.txt")
print "Wrote ${show result} bytes"
Type Task a
Task a is an asynchronous task that eventually returns a value of type a. It is distinct from Chan a: a task models a single result, a channel models a stream.
| State | Meaning |
|---|---|
Task:Pending | Task is still running in the IO pool or reactor |
Task:Done v | Task completed; result is v : a |
Stackless state machine (JIT)
In JIT mode (sno jit), an async fn with 1–4 await points is compiled into a stackless state machine: a polling function __poll_name(task, frame) with states S0..SN dispatched over a br_table. Each state is the code between two suspension points.
-- JIT compilation of a 2-await async fn:
S0 → launch async_read → save frame → return Pending
S1 → receive content → launch async_write → save frame → return Pending
S2 → receive n → return Done(n)
An async fn with 5 or more await points automatically falls back to a blocking stub that inlines the body and parks the calling OS thread — correct, but without the state-machine efficiency.
2. IO Reactor
All async primitives dispatch through a three-component reactor backend. The reactor maintains a waker map that reschedules parent tasks when a child completes.
┌─────────────────────────────────────────────────────────────┐
│ Reactor (G1) │
│ Mutex<HashMap<task_id, PendingTask>> — waker map │
│ Reschedules parent on wake_parent(child_task) │
└───────────────┬──────────────────┬──────────────────────────┘
│ │
┌────────▼──────┐ ┌────────▼────────┐ ┌─────────────┐
│ Timer Wheel │ │ File-IO Pool │ │ mio TCP │
│ (G2) │ │ (G3) │ │ Loop (G4) │
│ 1 OS thread │ │ 4 workers │ │ 1 OS thread│
│ BinaryHeap │ │ VecDeque-256 │ │ mio::Poll │
│ + Condvar │ │ + Condvar │ │ + Waker │
└───────────────┘ └─────────────────┘ └─────────────┘
- Timer Wheel (G2) — a single background OS thread handles all
async_sleepcalls. 1000 concurrent 5 ms timers fire in approximately 5 ms. - File-IO Pool (G3) — bounded queue of 256 tasks.
SNO_FILE_IO_THREADScontrols worker count (default 4, range 1–16). When the queue is full new tasks block until a slot is free. - mio TCP Loop (G4) — non-blocking TCP via
mio::Poll; READABLE/WRITABLE events wake the reactor. Requires theevent-loopfeature (default on).
Async builtins
| Builtin | Type | Description |
|---|---|---|
async_sleep ms | Int -> Task Int | Async delay; returns 0 when done |
async_read path | String -> Task String | Async file read |
async_write path data | String -> String -> Task Int | Async file write; returns bytes written |
async_tcp_connect host port | String -> Int -> Task Int | TCP connect; returns fd |
async_tcp_read fd | Int -> Task String | Read from TCP fd |
async_tcp_write fd data | Int -> String -> Task Int | Write to TCP fd; returns bytes written |
async_tcp_close fd | Int -> Task Int | Close TCP fd; returns 0 |
async_tcp_listen port | Int -> Task Int | Bind TCP server; returns listener fd |
async_tcp_accept listener | Int -> Task Int | Accept connection; returns client fd |
3. Task Combinators
Two combinators run tasks in parallel and collect results.
| Combinator | Type | Semantics |
|---|---|---|
race tasks | [Task a] -> Task a | Run all tasks in parallel; return the first to finish (others are dropped) |
gather tasks | [Task a] -> Task [a] | Run all tasks in parallel; return a list of results in original order |
-- async_race.sno: race and gather in action
async fn fast_task = 1
async fn slow_task = _ = await (async_sleep 200); 2
async fn main =
-- race: first to complete wins; slow_task result is discarded
winner = await (race [slow_task fast_task])
-- gather: both finish, results in order
both = await (gather [fast_task fast_task])
winner -- → 1
race uses an AtomicBool winner flag so only the first completion is propagated. gather uses an AtomicUsize completion counter and collects results in original index order. Each sub-task gets its own OS thread.
4. Async TCP Server
The async TCP server API uses the same fd handle pattern as the synchronous tls_* builtins. async_tcp_listen binds a port and returns a listener fd; async_tcp_accept waits for an incoming connection and returns a client fd.
-- async_tcp_server.sno — async echo server
-- Run: sno jit examples/async_tcp_server.sno
-- Test: echo "hello" | nc 127.0.0.1 9877
async fn handle_client client data =
await (async_tcp_write client ("echo: " ++ data))
async fn server_once listener =
client = await (async_tcp_accept listener)
data = await (async_tcp_read client)
await (handle_client client data)
async fn main =
listener = await (async_tcp_listen 9877)
await (server_once listener)
Client fds returned by async_tcp_accept are compatible with async_tcp_read, async_tcp_write, and async_tcp_close. The mio::Poll background loop delivers READABLE and WRITABLE events to wake the reactor without blocking the calling thread.
Pass 0 as the port to let the OS choose a free port (useful in tests).
5. Error handling
Two builtins convert runtime errors inside async code into typed values rather than panics.
| Builtin | Type | Semantics |
|---|---|---|
scope_result thunk | (Unit -> a) -> Result a Error | Run thunk (); if the body calls error, return Err e instead of aborting |
try_await task | Task a -> Result a Error | Await a task; if the task panicked, return Err e |
-- scope_result: wrap a block that may fail
async fn safe_read path =
result = scope_result (\_ -> await (async_read path))
? is_ok result -> unwrap_ok result
: "read failed: " ++ show result
-- try_await: await a task that might have panicked
async fn run_with_fallback task fallback =
result = try_await task
? is_ok result -> unwrap_ok result : fallback
Internally scope_result/try_await use a thread-local soft-error channel (SCOPE_RESULT_ERROR / TASK_ERROR). When synoema_error is called inside a scope, it stores the message in TLS instead of panicking, then returns a sentinel 0. The enclosing scope_result or G1 reactor intercepts the sentinel and constructs an Err value. This means synoema_error never unwinds through Cranelift-compiled frames.
6. Cancellation
Cancel tokens let you stop a long-running task from outside.
| Builtin | Type | Description |
|---|---|---|
new_cancel_token () | Unit -> CancelToken | Create a new cancel token (starts uncancelled) |
cancel token | CancelToken -> Unit | Mark the token as cancelled |
is_cancelled token | CancelToken -> Bool | Check whether the token has been cancelled |
with_cancel token task | CancelToken -> Task a -> Maybe a | Run task; if token is cancelled before completion, return Nothing |
await_with_timeout ms task | Int -> Task a -> Maybe a | Await task for up to ms milliseconds; return Nothing on timeout |
-- Create a token, pass it to a long-running task, cancel externally
async fn guarded_fetch path token =
result = with_cancel token (async_read path)
? result == Nothing -> "cancelled"
: "got: " ++ (from_just result)
-- Timeout pattern: give up after 500 ms
async fn fetch_with_timeout path =
result = await_with_timeout 500 (async_read path)
? result == Nothing -> "timed out"
: from_just result
async fn main =
token = new_cancel_token ()
cancel token -- cancel before the task even starts
r = await (guarded_fetch "/etc/hostname" token)
print r -- → "cancelled"
7. When to use what
| Situation | Recommendation |
|---|---|
| Parallel file or network I/O | async fn / await |
| First result wins, rest discarded | race [task1 task2 ...] |
| All results needed in order | gather [task1 task2 ...] |
| CPU-bound parallelism (list transform) | pmap f xs |
| Independent long-running background work | scope { spawn ... } |
| Producer/consumer with backpressure | bounded_chan n |
| Fan-in from multiple channels | select [chan1 chan2 ...] |
| Async error propagation | scope_result / try_await |
| Deadline or timeout | await_with_timeout ms task |
| External cancellation | new_cancel_token / with_cancel |
8. Limitations (honest)
The async system ships and works. These are the current known constraints:
- JIT state machine covers 1–4
awaitpoints. Anasync fnwith 5 or moreawaitpoints compiles correctly but uses a blocking stub (D3b). The 4-await stackless path covers the vast majority of real use cases. - File-IO pool defaults to 4 workers. Set
SNO_FILE_IO_THREADS=1..16to adjust. The queue depth is 256; when full, new tasks block. awaitoutsideasync fnis a type error. You get anAWAIT_OUTSIDE_ASYNCdiagnostic. Wrap the call in anasync fn.sno runevaluates async primitives eagerly. The interpreter mode is correct but does not use the reactor; usesno jitfor the full async stack and performance.- Async TCP requires the
event-loopfeature (on by default). Building with--no-default-featuresdisablesasync_tcp_*. Use the synchronoustcp_connect/tls_connectbuiltins instead. - No
epollintegration for file descriptors. File I/O goes through the thread pool (G3), not the mio event loop. Deferred.