Ergonomic asynchronous programming in Turmeric using fibers and delimited continuations.
Turmeric's async/await syntax enables direct-style asynchronous programming for I/O-bound and concurrent tasks. The implementation builds on delimited continuations and integrates with Turmeric's effect system.
;; Define an async function
(async
(def data (await (read-file "data.txt")))
(def result (await (process-data data)))
(println result))
;; Type: returns a Future<T>
(def fut (async
(+ 1 (await (fetch 2)))))
;; Block until completion
(println (await fut)) ; => 3
A fiber is a user-space thread (lightweight thread) that:
Whether it has a stack of its own depends on which kind of fiber it is (checked against the source on 2026-10-14):
async / await in a compiled program: no separate stack. The body
runs on the spawner's C stack. An await on a pending future parks a heap
copy of its CPS continuation, from the await up to the body's root, and
fulfilling the future runs that copy (__tur_await_body and
__tur_async_resume in the emitted runtime). No context switch happens.
Measured on Linux: ~137 ns per spawn + await of a ready future, ~813 ns per
park/resume turn of a body a few calls deep -- the copy and its hand-off
(WP2, R-10).fiber-new (stdlib/fiber.tur) and
task-group-spawn (stdlib/taskgroup.tur) each create a FiberBlock
(which scheduler-spawn in stdlib/scheduler.tur then runs). A
FiberBlock mallocs its own stack -- 1 MiB unless fiber-new is given a
size (tur_fiber_block_new in src/compiler/emit_module.c) -- and
switches through ucontext: libc swapcontext on Linux and macOS
(glibc's also saves and restores the signal mask), a register-snapshot
shim on Windows. Fibers no longer run effect handlers: a handle the CPS
backend cannot take is a compile error, not a fiber fallback
(TUR_TRACE_EVICT=1 says why; see the
CPS and DK internals guide).tur --interpret, every async thunk runs as a ucontext fiber
on its own 512 KiB mmap'd stack (TURI_ASYNC_STACK_SIZE).(async body) -- Creates a fiber that executes body. Returns a Future<T> that can be awaited.(await fut) -- Suspends the current fiber until fut completes. Used only inside async blocks.Future<T> -- Represents a computation that may not be done yet.(await fut).awaits sequence operations.TUR-W0044A pending await parks by capturing the continuation up to the async body's
root. Every function between the body and the await has to be on the CPS
backend for that capture to reach the root. An awaiting function reached
through a named call, a function value, a rank-2 (forall) parameter, a
lambda inside a generic, or an export-as function parks correctly. What
still puts a nearer root in the way: a function the backend cannot take for
another reason (an inline-C body, a signature shape it does not thread), and
a rank-2 call with a struct argument or result or more than eight arguments.
A constrained generic passed to a constrained rank-2 parameter parks
correctly unless its result is a float. The compiled program refuses such a
park at
run time (await: a pending future would park below a direct-style call)
instead of running the rest of the body with a dummy value.
The compiler says so up front: a function that may await -- its own body
awaits, it calls a function that does, or it is handed one -- that may run
below an async body, and that the backend evicts, draws TUR-W0044 at
its definition, with the reason (an unthreaded rank-2 call draws it at the
call). It is a warning, not an
error, because an await that only ever meets a ready future never parks and
such a program runs unchanged. To clear it, move the await (or the call to
the awaiting function) out of the named function into a caller the backend
takes, or change the function's shape. tur explain TUR-W0044 has the long
form; TUR_TRACE_EVICT=1 prints every eviction with its category.
A panic inside an (async ...) body is that task's failure, not the
spawner's. It is caught at the task boundary and the future is left in a
rejected state carrying the panic message, so:
(await f) on a rejected task re-raises the task's panic at the await,
which a catch-unwind there catches, and which prints the task's own
message and aborts when no handler is in scope.(let [fut (async boom)
r (catch-unwind (fn [] : int (await fut)))]
(if (err? r) (println "task failed") (println "ok")))
See the "Panic inside async tasks" section of the Error Handling Guide for what is still planned (cancel-vs-panic precedence, async-main exit codes, the WASM lowering).
The default scheduler is single-threaded: all fibers run on one OS thread, avoiding data races. A multi-threaded work-stealing scheduler (fibers distributed across a pool of OS threads) is available separately via stdlib/scheduler_mt.tur.
| Decision | Rationale |
|---|---|
| Fiber-based model | Leverages delimited continuations; avoids C stack issues |
await desugars to shift |
Integrates with existing CPS infrastructure |
async desugars to reset |
Minimal new machinery; natural fit for the foundation |
Future<T> as return type |
Composable, can be awaited or passed to other code |
| No implicit thread spawning | Explicit control for predictable performance |
Integration with defer |
Cleanup on fiber completion; consistent with scope model |
;; Surface syntax
(async (+ 1 (await (fetch 2))))
;; Desugars to (conceptually)
(reset
(fn []
(+ 1 (shift k
(fiber-suspend (fetch 2) k)))))
;; The scheduler later resumes k with the result of (fetch 2)
The (shift k body) spelling above is Danvy--Filinski notation for "bind the
captured continuation as k", used here to show the shape. It is not a
Turmeric form: base shift is the abortive (shift receiver value), and a
continuation the body can call comes from cloneable-shift. See the
delimited control operators guide.
| Feature | Threads | Async/Await |
|---|---|---|
| Model | OS-level 1:1 threads | User-space fibers |
| Overhead | ~10-100us per thread | ~0.14us per spawn + await, ~0.8us per park/resume (compiled; see Fibers) |
| Scalability | 100s-1000s max | 100k+ feasible |
| Stack | Real OS stack | async: CPS continuation on the heap; an explicit fiber-new fiber: a 1 MiB malloc'd stack |
| Use case | CPU-bound parallelism | I/O-bound concurrency |
(async
(def a (await (fetch-a)))
(def b (await (fetch-b)))
(process a b))
;; Fork two concurrent operations; wait for both
(async
(let [fut-a (async (fetch-a))
fut-b (async (fetch-b))
a (await fut-a)
b (await fut-b)]
(process a b)))
Effect handlers work within async blocks. try-with is sugar for handle:
the body comes first, followed by (EffectName [params] k) clause heads and
their handler bodies (see Effects System Guide):
(async
(try-with
(await (fetch-file "missing.txt"))
(FileError [path] k) (resume k "default")))
Every value whose binding is in scope at an (await ...) point within an
inline (async (fn [] ...)) closure must be Send. Non-Send types include:
| Type | Reason not Send |
|---|---|
rc<T> |
Single-threaded refcount; races on cross-thread resume |
ref<T> |
Owning reference tied to the creating fiber |
&T / &mut T |
Borrows; lifetime is fiber-bound |
cont<T> |
Continuation captures C stack frame |
The compiler enforces this conservatively: any binding in scope at the
await is treated as live, whether or not it is actually used after the
await.
;; ERROR TUR-E0022: rc<int> is not Send
(async (fn []
(let [x (rc/of 42)]
(await (async zero)) ;; x is in scope -- not Send
(rc/deref x))))
;; OK: int is Send
(async (fn []
(let [x 42]
(await (async zero)) ;; x is in scope -- Send
x)))
Consume or drop non-Send values before the await:
(async (fn []
(let [x (rc/of 42)
v (rc/deref x)] ;; extract the value first
(rc/drop x) ;; drop x before the await point
(await (async zero)) ;; only v (an int) is in scope
v)))
The check runs at every await, wherever it appears. Writing the body
inline as (async (fn [] ...)) and lifting it into a named defn handed to
(async my-fn) are diagnosed the same way -- the two are semantically
identical, so hoisting is not an escape hatch:
;; Both of these are TUR-E0022.
(defn main [] : nil
(await (async (fn [] : int
(let [x (rc/of 42)]
(await (async zero)) ;; x live across an await
(rc/deref x))))))
(defn f [] : int
(let [x (rc/of 42)]
(await (async zero)) ;; ... and so is this
(rc/deref x)))
(defn main [] : nil (await (async f)))
This is conservative in the direction of safety: every binding in scope at
an await must be Send, whether or not it is genuinely read afterwards. To
carry a non-Send value across an await, end its scope before the await point
(a nested let) or convert it to a Send representation.
Turmeric doesn't provide built-in async I/O primitives. Import via FFI:
;; Example: libuv or custom C bindings
(defn read-file [path]
(with-future-from-ffi "libuv_read" path))
(async
(println (await (read-file "data.txt"))))