Concurrency

Spawn, async/await, channels, actors, and nurseries for structured concurrency.

Summary

spawn { block } runs on a real OS thread; await tblocks until it finishes and returns the body's value. Bytecode execution is GIL-serialized, but blocking calls (sleep, channel recv, I/O) release the GIL so siblings actually run. Channels are FIFO queues; bounded channels block the sender when full. close() marks a channel done; recv on a drained closed channel returns null; recv_pair() distinguishes a sent null from a closed channel. select([ch1, ch2]) returns the first ready channel. Actors encapsulate state and respond to typed method calls or raw messages via !. Nurseries guarantee all spawned tasks finish before the nursery exits; one task throwing cancels siblings cooperatively.

Canonical

Spawn

spawn runs a block on a real OS thread. The body returns a future the parent can await for the value or hand to a nursery for structured-concurrency cleanup. Bytecode execution is GIL-serialized so two pure-compute spawns trade off rather than running on different cores at once, but blocking calls (time.sleep, channel send / recv, io reads) release the GIL so a sibling actually runs while one is waiting.

var done = false
let t = spawn { done = true }
println(done)                    -- false (spawn runs concurrently)
await t
println(done)                    -- true (worker has finished)

-- await returns the body's value
let result = spawn { 1 + 2 }
println(await result)            -- 3

Async / Await

async fn compute(x) {
    return x * 2
}

let r = await compute(21)
println(r)                       -- 42

async fn fetch_user(id) {
    return #{"id": id, "name": "User {id}"}
}

let user = await fetch_user(42)
println(user["name"])            -- User 42

Channels

Channels are FIFO message queues. Unbounded by default, or bounded with a capacity. A bounded channel's send blocks while the buffer is full.

-- unbounded channel
let ch = channel()
ch.send("ping")
ch.send("pong")
println(ch.recv())               -- ping
println(ch.recv())               -- pong
println(ch.len())                -- 0
println(ch.is_empty())           -- true

-- bounded channel: send blocks once cap is reached
let bch = channel(2)
bch.send("a")
bch.send("b")
println(bch.is_full())           -- true
println(bch.recv())              -- a
println(bch.is_full())           -- false

close() marks a channel done. Subsequent send raises ChannelClosed; recv on a drained closed channel returns null instead of blocking forever. When the channel itself can carry null values, use recv_pair() for a Go-style (value, ok) tuple where ok=false distinguishes "closed" from "received null":

let ch = channel(2)
ch.send(null)
ch.send("hi")
ch.close()
ch.recv_pair()                    -- (null, true)   -- a real null was sent
ch.recv_pair()                    -- (hi, true)
ch.recv_pair()                    -- (null, false)  -- closed and drained

Iterating with for v in ch drains the currently-buffered values:

let q = channel()
q.send(1); q.send(2); q.send(3)
q.close()
var got = []
for v in q { got.push(v) }
println(got)                     -- [1, 2, 3]
println(q.is_closed())           -- true

select([ch1, ch2, ...]) returns {index, value} for the first channel with a buffered value (also accepts task futures, returning their _result). Returns null when nothing is ready -- for blocking semantics, loop until you get a hit:

let a = channel()
let b = channel()
a.send("hi")
let r = select([a, b])
println(r.index, r.value)        -- 0 hi

Actors

Actors encapsulate state and respond to method calls or raw messages.

actor BankAccount {
    var balance = 0

    fn deposit(amount) {
        balance = balance + amount
    }

    fn withdraw(amount) {
        if amount > balance { return Err("insufficient funds") }
        balance = balance - amount
        return Ok(balance)
    }

    fn get_balance() { return balance }

    -- handle() processes raw messages sent with !
    fn handle(msg) {
        if msg == "reset" { balance = 0 }
    }
}

let acct = spawn BankAccount
acct.deposit(100)
acct.deposit(50)
println(acct.get_balance())      -- 150

-- send raw message with !
acct ! "reset"
println(acct.get_balance())      -- 0

Nursery (Structured Concurrency)

Nursery blocks wait for all spawned tasks to finish before continuing. No tasks leak out.

var results = []
nursery {
    spawn { results.push("a") }
    spawn { results.push("b") }
    spawn { results.push("c") }
}
-- all tasks complete before we get here
println(results.sort())          -- ["a", "b", "c"]

Nurseries compose with channels for producer/consumer patterns:

let pipe = channel()
var output = []

nursery {
    spawn {
        for i in 1..=3 { pipe.send(i * 10) }
    }
    spawn {
        for i in 0..3 { output.push(pipe.recv()) }
    }
}
println(output)                  -- [10, 20, 30]

When one task in a nursery throws, the surviving siblings get a cooperative cancellation flag. Blocking primitives (time.sleep, channel recv) check it on resume and raise Cancelled, so a sleeping sibling unwinds cleanly instead of finishing its body. The nursery surfaces the original throw, suppressing the cleanup-noise Cancelled errors.