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.