Async/Await Guide

Ergonomic asynchronous programming in Turmeric using fibers and delimited continuations.

Overview

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.

Quick Start

;; 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

Core Concepts

Fibers

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 Blocks

Futures

Where an await may park -- TUR-W0044

A 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.

Task panics

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:

(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).

Scheduling

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.

Design Decisions

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

Under the Hood

Desugaring

;; 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.

Comparison to Threads

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

Common Patterns

Sequential Operations

(async
  (def a (await (fetch-a)))
  (def b (await (fetch-b)))
  (process a b))

Concurrent Operations

;; 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)))

Error Handling

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")))

Send Requirements for Async Bodies

The Send-across-await rule

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)))

Workaround

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)))

Scope

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.

I/O Bindings

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"))))

See Also