Skip to content

Concurrency

Spawn tasks, join them, race them, select over them, and move values through streams — all with ordinary call syntax.

Updated View as Markdown

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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close