Atoll has one function kind: fn. There is no async fn, no await keyword,
and no coloring split between “sync” and “async” callers. A function that may
pause is described by its effect row, not by its declaration syntax.
Everything explicit about concurrency happens through five constructs:
spawn, Task[T], race, select, and Stream[T].
fn double(n: int): int {
return n * 2
}
fn main(): int {
left := spawn { double(10) }
right := spawn { double(11) }
return left.await() + right.await()
}Both children start before either join runs, so their work overlaps. .await()
is a method on Task[int]; it joins the child, yields its int, and reaps the
finished task.
| Construct | Purpose | Result |
|---|---|---|
spawn expr / spawn { ... } |
Start a child task | Task[T] |
task.await() |
Join, retrieve, reap | T |
task.cancel() |
Request cooperative cancellation | void |
race { a b } |
First arm to complete; losers cancelled | The arms’ unified type |
select { p := src => body } |
Run one arm when its source is ready | void (statement) |
Stream.new[T](cap) |
Shared bounded channel | Stream[T] |
There is no spawn.all, no async fn, no await keyword, and no concurrent
block in the current language.
Fan-out and fan-in
The common shape is: start every independent unit of work, then collect. Handles can live in a list, which makes the fan width a runtime value.
fn fetch_shard(shard: int): int {
return shard * 100
}
fn total(shards: int): int {
mut running: []Task[int] = []
mut i := 0
for i < shards {
running.add(spawn { fetch_shard(i) })
i = i + 1
}
mut sum := 0
for task in running {
sum = sum + task.await()
}
return sum
}Note the two separate loops. Awaiting inside the first loop would serialize the whole thing: each child would finish before the next one started.
Errors cross task boundaries as values
A spawned body that returns T ! E produces a Task[Result[T, E]]. Awaiting it
hands you the Result, and ? applies the caller’s error policy as usual.
error LoadError {
NotFound { id: int }
Corrupt
}
struct Profile { id: int, name: string }
struct Orders { count: int }
struct Page { profile: Profile, orders: Orders }
fn load_profile(id: int): Profile ! LoadError {
if id < 0 { error NotFound { id: id } }
return Profile { id: id, name: "ada" }
}
fn load_orders(id: int): Orders ! LoadError {
if id < 0 { error NotFound { id: id } }
return Orders { count: 3 }
}
fn load_page(id: int): Page ! LoadError {
profile_task := spawn { load_profile(id) }
orders_task := spawn { load_orders(id) }
profile := profile_task.await()?
orders := orders_task.await()?
return Page { profile: profile, orders: orders }
}If the first ? fires, the second child is still running. That is a real
decision, not an oversight to ignore — see
Cancellation for the patterns that close
it.
Streams carry sequences
Stream[T] is a shared handle to a bounded FIFO ring. Copies of the handle —
including copies captured by a spawned body — alias the same channel, which is
what makes producer/consumer pairs work.
fn produce(out: Stream[int], count: int): void {
mut i := 0
for i < count {
if !out.send_await(i) {
return
}
i = i + 1
}
out.close()
}
fn consume(): int {
events := Stream.new[int](4)
producer := spawn { produce(events, 10) }
mut total := 0
mut running := true
for running {
match events.recv() {
Some(v) => total = total + v
None => {
if events.is_closed() { running = false }
}
}
}
producer.await()
return total
}The capacity of 4 bounds how far ahead the producer may run. When the ring is
full, send_await suspends until the consumer frees a slot.
Race picks the first completion
fn from_cache(key: int): int {
return key
}
fn from_origin(key: int): int {
return key + 1
}
fn lookup(key: int): int {
return race {
from_cache(key)
from_origin(key)
}
}Each arm becomes a child task. The first to complete wins; every other arm is cancelled. This is a completion race, not a success race — an arm that fails first still wins.
Select dispatches on readiness
select waits across heterogeneous sources and runs exactly one arm’s body. It
is a statement and evaluates to void, so results leave through enclosing
mutable state or a return.
fn tick(): int { return 1 }
fn pump(): int {
events := Stream.new[int](8)
shutdown := spawn { tick() }
mut total := 0
mut running := true
for running {
select {
v := events => total = total + v
_ := shutdown => running = false
}
}
return total
}Unlike race, select does not cancel the sources it did not choose. They stay
alive and stay yours.
Effects describe what a function may do
Every function has a row drawn from Suspend, Error, Alloc, Spawn, and
Cancel. The compiler computes it by inference — in practice you never write
one, and the code in this chapter carries no effect annotations.
fn pure_math(n: int): int {
return n * n
}
fn may_pause(n: int): int {
println("computing")
return n * n
}
fn starts_children(n: int): int {
return (spawn { pure_math(n) }).await()
}Declaring a row that omits an inherited effect produces the ATOLL2032
warning; declaring one that includes it produces the ATOLL2031 hint, which
just reports where the effect came from. Neither is an error.
Effects covers both in detail.
Execution model
Scheduling is cooperative. A running task keeps executing until it reaches a
suspension point — a @suspend host call, a task join, a blocking select, a
backpressured send_await, or a generated loop safepoint — at which point
control returns to the scheduler.
Tasks form a parent/child tree. A spawned computation stays in that tree even
when its handle is discarded, so cancellation and shutdown reach it structurally.
Dropping a Task[T] removes your ability to join the child; it does not detach
the child into an unmanaged background process.
A composed example
A bounded work queue: one producer feeds a stream, a fixed pool of workers drains it, and the coordinator collects their totals.
fn cost(job: int): int {
return job * job
}
fn worker(queue: Stream[int]): int {
mut subtotal := 0
for Some(job) := queue.recv() {
subtotal = subtotal + cost(job)
}
return subtotal
}
fn fill(queue: Stream[int], jobs: int): void {
mut i := 1
for i <= jobs {
if !queue.send_await(i) {
return
}
i = i + 1
}
queue.close()
}
fn run(jobs: int, workers: int): int {
queue := Stream.new[int](8)
feeder := spawn { fill(queue, jobs) }
mut pool: []Task[int] = []
mut w := 0
for w < workers {
pool.add(spawn { worker(queue) })
w = w + 1
}
feeder.await()
mut total := 0
for task in pool {
total = total + task.await()
}
return total
}Every started task is joined exactly once. for Some(job) := queue.recv() is
the drain form: it loops while the pattern matches and stops on the first None.
Reading order
Start with Effects and Suspension for the model, then Tasks and Spawning for the mechanics. Cancellation covers shutdown, Streams covers communication, and Select and Race cover the two multi-source constructs.