Skip to content

Concurrency

Nomi’s concurrency is structured: every spawned task is bound to a surrounding concurrent { ... } block, and the block doesn’t return until every task has been joined. No leaked tasks, no fire-and-forget. If one task fails, the rest are cancelled and unwound automatically.

The smallest example. Three tasks compute in parallel; the block returns once they’re all awaited:

import std/io: IO

fn double(n: Int): Int {
  n * 2
}

fn main() {
  total = concurrent {
    a = async(|| double(10))
    b = async(|| double(20))
    c = async(|| double(30))
    await(a) + await(b) + await(c)
  }
  IO.print(total)
}
  • concurrent { ... } opens a scope where tasks can be spawned. The block’s value is its last expression — here the sum of the three awaited results.
  • async(|| expr) spawns the lambda as a Task<'T> and returns its handle immediately, so the next line of the block can run before the spawned body has finished.
  • await(task) waits for a task to finish and yields its return value.

The compiler enforces two structural rules: every Task<'T> must be await’d (no orphan tasks), and a Task<'T> can’t escape the concurrent block it was spawned in (no leaks).

For streaming values between tasks, use a channel. Producers send, the consumer receives until the channel is closed.

import {
  std/channel: Channel
  std/io: IO
}

fn produce(ch: Channel<Int>, n: Int): Unit {
  case Channel.send(ch, n) {
    Ok(_) -> Unit
    Err(_) -> IO.print("send failed")
  }
}

fn consume(ch: Channel<Int>): Int {
  Iter.loop(|sum = 0|
    case Channel.receive(ch) {
      Some(v) -> sum + v
      None -> break sum
    }
  )
}

fn main() {
  ch = Channel.new<Int>(capacity: 4)

  total = concurrent {
    p1 = async(|| produce(ch, 10))
    p2 = async(|| produce(ch, 20))
    p3 = async(|| produce(ch, 30))
    cons = async(|| consume(ch))

    // Drain the producers, close so the consumer's loop ends on None.
    await(p1)
    await(p2)
    await(p3)
    Channel.close(ch)
    await(cons)
  }

  IO.print(total)
}

Channel.receive returns Maybe<'T>Some(v) while values are buffered or producers are still active, None once the channel is closed and drained.

The block guarantees no task escapes — including when one fails. try on an await’d Result short-circuits the block, cancels the sibling tasks, and waits for them to unwind before returning the error:

import {
  std/duration: Duration
  std/io: IO
  std/timer: Timer
}

fn short_fail(): Result<Int, String> {
  Timer.sleep(Duration.milliseconds(10))
  Err("boom")
}

fn long_sleep(): Result<Int, String> {
  Timer.sleep(Duration.seconds(5))
  Ok(0)
}

fn main() {
  outcome = concurrent {
    short = async(|| short_fail())
    long = async(|| long_sleep())
    n = try await(short)
    _ = try await(long)
    Ok(n)
  }

  case outcome {
    Ok(_) -> IO.print("ok")
    Err(e) -> IO.print("err: ${e}")
  }
}

If you ran this without structured concurrency, the long-sleep task would keep running for five seconds after the short one already errored. With concurrent, the block returns in ~10 ms — the moment try saw the Err, the sibling was cancelled and unwound.

The next chapter — Dates & Times — covers Nomi’s civil-time types: Date, Time, DateTime, NaiveDateTime, and the DST handling that comes with them.