spawn starts one child task and evaluates to Task[T], where T is the type
of the spawned expression. It takes either a bare expression or a block.
fn fetch_booking(id: int): int {
return id * 2
}
fn start(id: int): int {
task := spawn fetch_booking(id)
return task.await()
}The expression is scheduled as child work; it is not evaluated to completion
before the handle comes back. The Spawn and Suspend effects both arrive with
it — Spawn because a child is created, Suspend because the join can pause.
Block form
A block lets a child do setup, several statements, or its own control flow. The block’s tail expression is the task’s result.
fn fetch_booking(id: int): int { return id * 2 }
fn render_booking(booking: int): string { return "booking ${booking}" }
fn start(id: int): string {
task := spawn {
booking := fetch_booking(id)
render_booking(booking)
}
return task.await()
}A block with no value-producing tail yields Task[void]:
fn remove_expired_entries(): void {
println("sweeping")
}
fn sweep(): void {
cleanup: Task[void] = spawn {
remove_expired_entries()
}
cleanup.await()
}The tail can be an arbitrary expression, so a child can compute several intermediates and return their combination:
fn measure(sample: int): int { return sample * sample }
fn start(): int {
task := spawn {
low := measure(3)
high := measure(9)
high - low
}
return task.await()
}Spawn is an expression, not a statement
spawn { ... } produces a value. In a void function, letting it fall in the
tail position is a type error, because the block’s value is a Task[void], not
void:
fn record_metric(event: string): void {
println(event)
}
fn emit(): void {
spawn { record_metric("started") }
}That reports ATOLL2002: type mismatch: expected 'void', got 'Task[void]'. Bind
the handle — to _ if you truly want to discard it:
fn record_metric(event: string): void {
println(event)
}
fn emit(): void {
_ := spawn { record_metric("started") }
println("continuing")
}In a non-tail position the value is simply dropped, so a bare spawn statement
followed by more code is fine:
fn record_metric(event: string): void {
println(event)
}
fn emit(): void {
spawn { record_metric("started") }
println("continuing")
}Parallel start
Calls are sequential unless you spawn them. Start every child before joining any of them, or you get no overlap at all.
struct Profile { name: string }
struct Orders { count: int }
fn load_profile(user_id: int): Profile { return Profile { name: "user${user_id}" } }
fn load_orders(user_id: int): Orders { return Orders { count: user_id } }
fn dashboard(user_id: int): (Profile, Orders) {
profile_task := spawn { load_profile(user_id) }
orders_task := spawn { load_orders(user_id) }
profile := profile_task.await()
orders := orders_task.await()
return (profile, orders)
}This is the replacement for the removed spawn.all { ... } form; there is no
such construct in the language today.
Spawn order controls when each child becomes eligible to run, not which one runs first. Only messages, selection, or an actual join constrain visible completion order.
Join discipline
Once a child is started, pick exactly one outcome for it:
- await it and consume its result;
- cancel it because its result is no longer wanted;
- hand the
Task[T]to another owner who will do one of those; - deliberately discard the handle under a documented best-effort policy.
Early exits are where this accounting slips. When one join fails, the sibling is still running:
error JoinError { LeftFailed, RightFailed }
fn load_left(): int ! JoinError { return 1 }
fn load_right(): int ! JoinError { error RightFailed }
fn combine(a: int, b: int): int { return a + b }
fn both(): int ! JoinError {
left := spawn { load_left() }
right := spawn { load_right() }
left_result: Result[int, JoinError] = left.await()
left_value := left_result catch {
LeftFailed => {
right.cancel()
error LeftFailed
}
RightFailed => {
right.cancel()
error RightFailed
}
}
return combine(left_value, right.await()?)
}Each failing arm cancels the sibling before leaving. The exact error mapping is yours to choose; the rule that does not vary is that a started sibling must not become unobserved work just because another join failed first.
Captures
A spawned block may reference values from the enclosing function. The compiler builds a task body with hidden capture parameters.
struct Booking { id: int }
fn load_booking(id: int): Booking {
return Booking { id: id }
}
fn schedule(id: int): Task[Booking] {
prefix := "booking"
return spawn {
println("${prefix}:${id}")
load_booking(id)
}
}Capture the smallest stable value the child actually needs, not the graph it was reached through:
struct User { id: int, name: string }
struct Request { user: User, headers: []string, body: []byte }
struct Profile { user_id: int }
fn load_profile(user_id: int): Profile {
return Profile { user_id: user_id }
}
fn start(request: Request): Task[Profile] {
user_id := request.user.id
return spawn {
load_profile(user_id)
}
}Capturing request would keep the headers and body alive for as long as the
child runs, to read one integer.
Captures are values. Assigning to a captured local inside the child does not
mutate the parent’s binding, and a captured mut local is not a synchronized
shared cell. For genuinely shared state, use a Stream[T] — its handle
deliberately aliases one control block across copies:
fn tally(out: Stream[int], upto: int): void {
mut i := 1
for i <= upto {
if !out.send_await(i) { return }
i = i + 1
}
out.close()
}
fn run(): int {
results := Stream.new[int](4)
worker := spawn { tally(results, 5) }
mut total := 0
mut open := true
for open {
match results.recv() {
Some(v) => total = total + v
None => {
if results.is_closed() { open = false }
}
}
}
worker.await()
return total
}Both results handles refer to the same ring, which is why the parent sees what
the child sent.
Fallible bodies
The spawned body is checked exactly like a function body. When it returns a
Result, the handle is Task[Result[T, E]] and awaiting hands you that result.
error ProfileError { Missing }
struct Profile { user_id: int }
fn load_profile(user_id: int): Profile ! ProfileError {
if user_id < 0 { error Missing }
return Profile { user_id: user_id }
}
fn fetch(user_id: int): Profile ! ProfileError {
task := spawn { load_profile(user_id) }
return task.await()?
}Discarding a fallible task also discards the only place its failure could be observed. Do that only when the child handles its own errors.
Nesting
A child can spawn its own children; the descendants stay in the same structured tree, so cancelling an ancestor reaches them.
fn stage(n: int): int {
println("stage ${n}")
return n
}
fn pipeline(): int {
outer := spawn {
inner := spawn { stage(2) }
stage(1) + inner.await()
}
return outer.await()
}Child completion does not publish anything to the parent by itself. The typed
handle is the transfer point; a Stream[T] is the separate multi-value protocol.
Fire-and-forget
Discarding a handle starts work with no source-level join.
fn record_metric(event: string): void {
println(event)
}
fn handle_request(path: string): int {
spawn { record_metric("request ${path}") }
return path.len()
}This is still a child in the task tree, not a detached process. Use it only when the result is genuinely irrelevant, failures have a boundary policy, and parent shutdown ending the work is acceptable. “Fire-and-forget” is not an unbounded background queue: cap the concurrency, tolerate cancellation, and handle errors inside the child.
At a library boundary, prefer returning Task[T] to discarding it internally.
The caller can then decide whether to join, cancel, race, or transfer ownership.
Effects
spawn adds Spawn and Suspend to the enclosing row. Effects used inside
the child body are analysed while checking the enclosing function, so they can
widen the parent’s inferred row too:
error ChildError { Failed }
fn risky(): int ! ChildError {
error Failed
}
fn parent(): int {
task := spawn { risky() }
return task.await().unwrap_or(0)
}parent never writes error itself and discharges the Result with
unwrap_or, but Error still shows up on the call edge into the spawn body.
Account for that when you write an explicit public row.