Skip to content

Suspension

Where Atoll code may yield, what survives across the yield, and why call syntax never changes.

Updated View as Markdown

Suspension is an effect, not a second kind of function. Atoll has no async fn modifier and no prefix await operator, so a suspending call and a pure call look identical at the call site.

fn fetch_status(url: string): int ! HttpError {
    response := Http.get(url)?
    return response.status
}

Http.get is a @suspend host op: the task parks, the host performs the request, and the scheduler resumes the continuation with the result. Nothing in the source marks that — there is no await keyword and no annotation. The compiler infers the Suspend effect from the call graph.

What can suspend

Source Example
A @suspend host call println, Http.get, Tcp.connect, DateTime.now
Joining a task task.await()
A blocking selection select { v := src => ... } with no default
Producer backpressure stream.send_await(v) on a full ring
Anything that calls one of the above inference propagates it

Each of those, as code:

fn via_host(): int {
    println("host call")
    return 1
}

fn via_join(): int {
    child := spawn { 41 }
    return child.await() + 1
}

fn via_select(): int {
    a := spawn { 1 }
    mut got := 0
    select {
        v := a => got = v
    }
    return got
}

fn via_backpressure(out: Stream[int]): void {
    mut i := 0
    for i < 100 {
        if !out.send_await(i) { return }
        i = i + 1
    }
    out.close()
}

A suspension source may take a synchronous fast path. await() on a child that already finished returns without yielding, and send_await into a ring with room never parks. The static contract still says Suspend, because the caller cannot know in advance.

The converse trap matters more: ordinary-looking code suspends when something deep in its call graph does. Read the contract, not the punctuation.

fn write_audit(line: string): void {
    println(line)
}

fn record_purchase(item: string, cents: int): int {
    write_audit("bought ${item} for ${cents}")
    return cents
}

Neither function names a concurrency construct. Both carry Suspend, inherited from println.

Resume behaviour

At a suspension point the compiler saves the values needed afterwards, records a resume continuation, and returns control to the scheduler. When the completion source is ready the scheduler re-enters the generated continuation, which observes the operation’s result exactly as an ordinary call result.

fn total(task: Task[int]): int {
    offset := 2
    value := task.await()
    return value + offset
}

offset is defined before the yield and used after it, so it lives across the suspension. The compiler decides whether that means a fixed frame slot, managed storage, or something else; source code never names a state machine, a program counter, or a resume function.

Local control flow keeps working through the resume. match, ?, loops, and defer all compile into the generated blocks:

error FetchError { Timeout, Refused }

fn fetch(id: int): int ! FetchError {
    if id < 0 { error Refused }
    println("fetch ${id}")
    return id * 10
}

fn best_of(ids: []int): int ! FetchError {
    mut best := 0
    for id in ids {
        value := fetch(id)?
        if value > best {
            best = value
        }
    }
    return best
}

The ? inside the loop is an error exit from a continuation that has already resumed several times.

Keep the live set small

Values that are not needed after a suspension do not have to survive it. Reading the one field you need before the call is cheaper than keeping the whole aggregate alive:

struct Request {
    id: int
    payload: []byte
    headers: []string
}

fn fetch(id: int): int {
    println("fetch ${id}")
    return id
}

fn handle(request: Request): int {
    request_id := request.id
    return fetch(request_id)
}

Only request_id — one integer — is live across the call to fetch. Passing request.id inline would be equivalent; the anti-pattern is holding request itself for a field you read afterwards.

Long computations and safepoints

Cooperative scheduling means straight-line code runs until it reaches a suspension point or a generated safepoint. Loops get safepoints, which is what lets cancellation and fairness make progress inside a long computation.

fn crunch(n: int): int {
    mut acc := 0
    mut i := 0
    for i < n {
        acc = acc + i * i
        i = i + 1
    }
    return acc
}

crunch declares no effects and performs no host calls, but its loop still yields to the scheduler’s cancellation checks. Do not treat “no suspension points” as a mutual-exclusion guarantee — it is not an ownership contract. Pass values into children deliberately and use streams for cross-task communication.

Cleanup across a suspension

Owned locals are dropped on every exit path a continuation can take: normal return, error propagation, and cancellation. A resource wrapper with Drop therefore releases its host handle even if the task is cancelled while parked.

struct Lease {
    id: int
}

impl Drop for Lease {
    fn drop(self): void {
        println("releasing lease ${self.id}")
    }
}

fn acquire(id: int): Lease {
    return Lease { id: id }
}

fn use_lease(id: int): int {
    lease := acquire(id)
    println("working under lease ${lease.id}")
    return lease.id
}

drop runs when use_lease returns, and it would also run if the task were cancelled at the println. Cleanup itself is synchronous: a drop body cannot perform a suspending operation. Acquire the resource through a typed wrapper, keep it in an owned local, and let the synchronous Drop close it.

defer composes the same way when the cleanup is a plain statement rather than a type’s responsibility:

fn open_slot(): int {
    println("open")
    return 7
}

fn close_slot(slot: int): void {
    println("close ${slot}")
}

fn serve(): int {
    slot := open_slot()
    defer close_slot(slot)
    println("serving on ${slot}")
    return slot
}

Composition stays uncolored

Because no call site changes shape, a function can gain or lose a suspension without any caller being rewritten.

error LoadError { Missing }

struct User { id: int, name: string }
struct Settings { theme: string }
struct Page { user: User, settings: Settings }

fn load_user(id: int): User ! LoadError {
    if id < 0 { error Missing }
    return User { id: id, name: "ada" }
}

fn load_settings(user_id: int): Settings ! LoadError {
    println("loading settings for ${user_id}")
    return Settings { theme: "dark" }
}

fn load_page(id: int): Page ! LoadError {
    user := load_user(id)?
    settings := load_settings(user.id)?
    return Page { user: user, settings: settings }
}

load_settings calls a host op and load_user does not, yet load_page treats them identically. If load_user later grows a database call, load_page needs no edit — inference widens its row, and the continuation ABI follows.

The one place the difference becomes visible is a declared row. Once load_page is annotated, adding a suspension downstream turns a silent widening into an ATOLL2032 warning, which is exactly why public signatures are worth annotating.

Boundaries

Host adapters mark waiting operations with @suspend; operations that complete inline, such as closing a socket, stay synchronous. That annotation is about the ABI the call site lowers to, not about latency — a handler that schedules a resume needs it even when it never actually waits.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close