Concurrency

XS has real OS threads via spawn, async/await for I/O, channels for message passing, actors for encapsulated state, and nurseries for structured concurrency.

spawn

spawn runs a block on a real OS thread. Bytecode execution is GIL-serialised, so two pure-compute threads take turns rather than running fully in parallel. Blocking calls (time.sleep, channel ops, I/O) release the GIL, so one thread can run while another waits.

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

-- 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 queues. Unbounded by default; pass a capacity to make them bounded. 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

-- bounded channel
let bch = channel(2)
bch.send("a")
bch.send("b")
println(bch.is_full())           -- true
println(bch.recv())              -- a

close() marks a channel done. Subsequent send raises ChannelClosed; recv on a drained closed channel returns null. Use recv_pair() when the channel can carry real nulls:

let ch = channel(2)
ch.send(null)
ch.send("hi")
ch.close()

ch.recv_pair()                   -- (null, true)  -- real null was sent
ch.recv_pair()                   -- ("hi", true)
ch.recv_pair()                   -- (null, false) -- closed and drained

select([ch1, ch2, ...]) returns {index, value} for the first channel with a buffered value. Returns null when nothing is ready.

Actors

Actors encapsulate mutable state and handle method calls or raw messages. They are spawned once and run as a persistent thread.

actor BankAccount {
  var balance = 0

  fn deposit(amount) {
    balance += amount
  }

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

  fn get_balance() { return balance }

  fn handle(msg) {
    if msg == "reset" { balance = 0 }
  }
}

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

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

Nurseries

A nursery block waits for all tasks spawned inside it to finish before continuing. No tasks leak out. When one task throws, the surviving siblings receive a cancellation signal and unwind cleanly.

var results = []
nursery {
  spawn { results.push("a") }
  spawn { results.push("b") }
  spawn { results.push("c") }
}
-- all tasks complete before we reach here
println(results.sort())          -- ["a", "b", "c"]
-- producer/consumer with a nursery
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]