Concurrency

Async work, channels, cancellation, isolates, and parallel workers.

moon offers two complementary ways to do work at the same time:

Channels pass values between async tasks. Isolates do not share mutable state, which keeps parallel code predictable.

Async and await

An async fn returns a Future<T>. Use await inside another async function to wait for its result:

async fn fetch_number() -> int {
  await sleep(100)
  42
}

async fn main() {
  println(await fetch_number())
}

A future starts when it is awaited or passed to spawn. This makes it safe to build the work first and choose later whether to run it sequentially or concurrently.

Start several tasks

spawn starts one future without waiting for it immediately. join_all waits for a list of futures and returns results in input order:

async fn load(name: string, delay: int) -> string {
  await sleep(delay)
  name
}

async fn main() {
  let jobs = [
    spawn(load("profile", 20)),
    spawn(load("settings", 10)),
  ]
  println(await join_all(jobs))
}

Use join((a(), b(), c())) when the futures return different types. Use select(futures) when you need the index of the first future to finish.

Cancellation and errors

cancel(future) requests cancellation. Cancellation is cooperative: the task stops at a point where it can safely yield, so application cleanup still runs.

Use Result<T, Error> for expected failures such as validation errors or missing records. Future.catch is for a task that fails unexpectedly while it is running:

async fn main() {
  let value = await risky_work()
    .catch(|failure| {
      println(failure.message)
      0
    })
  println(value)
}

Channels

std.channel provides bounded first-in, first-out channels between async tasks. Sending waits when the channel is full; receiving waits when it is empty. Once a channel is closed and drained, recv returns none.

async fn produce(channel: Channel) {
  for value in 1..4 {
    await channel.send(value)
  }
  channel.close()
}

async fn main() {
  let channel = std.channel.new(4)
  spawn(produce(channel))

  let mut open = true
  while open {
    match await channel.recv() {
      some(value) => println(value),
      none => open = false,
    }
  }
}

A capacity of 0 creates a rendezvous channel: every send waits for a matching receive.

Isolates

Use an isolate for CPU-heavy work that should run on another core. An isolate runs a named top-level function and exchanges plain values with the caller.

fn square(value: int) -> int {
  value * value
}

fn main() {
  let worker = std.isolate.spawn("square", [12])
  println(std.isolate.join(worker))
}

Arguments and results are copied across the isolate boundary. Closures, futures, and pointers cannot cross it. This means isolates cannot accidentally race over an ordinary mutable collection.

Fanning work out: std.parallel

std.parallel is the convenient interface for applying a top-level function across a collection on multiple cores:

fn squares(values: [int]) -> [int] {
  [value * value for value in values]
}

fn main() {
  let values = [1, 2, 3, 4, 5, 6]
  println(std.parallel.chunked("squares", values, 2))
}

The entry point must be a named top-level function rather than a closure.

Shared read-only data

When every isolate needs the same large read-only string, freeze it before starting workers:

fn main() {
  std.shared.put("dictionary", std.fs.read_file("words.txt"))
  std.parallel.workers("worker", 0)
}

Workers read it with std.shared.get. Treat shared entries as process-lifetime configuration or assets, not as a place for per-request mutable state.

Which should I use?

The key distinction is simple: async overlaps waiting; isolates and std.parallel use additional cores.