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.
concurrent, async, await
Section titled “concurrent, async, await”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 aTask<'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).
Channels
Section titled “Channels”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.
Errors cancel siblings
Section titled “Errors cancel siblings”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.