Skip to content

Concurrency Primitives

Complete reference for the :service profile concurrency system. All primitives described here require the :service profile or higher.


A nursery is a structured concurrency scope — a bounded region where concurrent tasks live. The nursery does not exit until every task spawned inside it has completed, failed, or been cancelled. This guarantee eliminates orphan tasks by construction.

nursery do
// spawn tasks here
end

With a binding (for nested spawn or cancellation control):

nursery |n| do
n.spawn(some_task)
// n is the nursery handle
end

A nursery transitions through three states:

Open ──────► Closing ──────► Closed
│ │
│ spawn() │ cancel fires or
│ allowed │ last child exits
│ │
│ ▼
│ No new spawns.
│ Waiting for running
│ children to finish.
Tasks run concurrently.
awaitAll() blocks until
all children are done.
Statespawn allowedawait returnsDescription
OpenYesBlocksNursery is active, tasks are running
ClosingNoBlocksCancel fired or all work submitted; waiting for children
ClosedNoReturnsAll children done, nursery scope exits

When any child task fails (returns an error), the nursery enters fail-fast mode:

  1. The nursery’s CancelToken fires
  2. All sibling tasks receive the cancellation signal at their next yield point
  3. The nursery waits for all fibers to complete their defer cleanup
  4. The first error is propagated to the nursery scope’s caller
nursery do
spawn task_a() // Succeeds
spawn task_b() // Fails with ConnectionError
spawn task_c() // Cancelled when task_b fails
end catch |err| do
// err == ConnectionError
println("Nursery failed: ", err)
end

If multiple tasks fail concurrently, the first error wins. Subsequent errors from cancelled siblings are suppressed.

Tasks can create sub-nurseries for hierarchical concurrency:

nursery |root| do
spawn func() do
nursery |inner| do
spawn subtask_a()
spawn subtask_b()
end
end
end

Cancellation propagates transitively: cancelling the root cancels all inner nurseries and their children.

Each task spawned inside a nursery gets its own dedicated fiber stack. The default stack size is determined by the active profile:

ProfileDefault Stack
:core64 KB
:service256 KB
:cluster256 KB
:sovereign512 KB

Override per-spawn when a task needs more (or less) stack:

nursery |n| do
n.spawnWithOpts(heavy_crypto_task, .{ .stack_size = 512 * 1024 })
end

spawn launches a new fiber task inside the current nursery. The task runs concurrently with the spawning fiber and all other tasks in the same nursery.

// Fire-and-forget
spawn some_function(arg1, arg2)
// Capture handle for await
let handle = spawn some_function(arg1, arg2)

Arguments to spawn are captured by value. The scheduler heap-allocates a thunk containing the function pointer and all argument values. This is safe because fibers may outlive the spawning stack frame within the nursery scope.

func main() do
let name = "Alice"
nursery do
// 'name' is copied into the spawn thunk -- safe even if
// main's stack frame were to change (it won't, nursery blocks,
// but the invariant holds for nested spawns)
spawn greet(name)
end
end

spawn returns a task handle — an opaque reference to the spawned fiber’s result slot. The handle is used with await to retrieve the task’s return value.

If you do not need the result, discard the handle:

spawn background_work() // handle discarded -- fire-and-forget

The task still runs; you simply choose not to collect its result. The nursery still waits for it to complete.

Nurseries enforce a spawn budget (configurable via Budget.spawn_count). Attempting to spawn beyond the budget returns a BudgetExhausted error. Default service budget allows 1024 concurrent spawns per nursery.


await suspends the current fiber until the target task completes, then returns the task’s result.

let result = await handle

With error handling:

let result = await handle catch |err| do
// Handle task failure
default_value
end
ScenarioBehavior
Task already finishedReturns immediately (fast path — atomic CAS check)
Task still runningCurrent fiber yields; scheduler runs other work
Task was cancelledReturns Cancelled error
Task failedReturns the task’s error

Each await targets a specific task handle. This is an individual await — it does not wait for all nursery children, only the specified one.

The protocol uses an atomic CAS waiter registration:

  1. Awaiter atomically registers itself on the target task’s waiter slot
  2. If the task is already done, CAS fails and the awaiter reads the result immediately
  3. If the task completes while the awaiter is parked, the completing fiber wakes the awaiter

This gives O(1) await with no lock contention.

Multiple fibers can await different tasks simultaneously:

nursery do
let h1 = spawn compute_a()
let h2 = spawn compute_b()
let h3 = spawn compute_c()
// These three awaits run concurrently --
// the fiber yields on each until that specific task completes
spawn func() do
let a = await h1
use_result_a(a)
end
spawn func() do
let b = await h2
use_result_b(b)
end
let c = await h3
use_result_c(c)
end

Typed, bounded, closeable message-passing channels for inter-task communication. Follows the Communicating Sequential Processes (CSP) model.

let ch = channel(capacity)
  • capacity = 0 — Unbuffered (rendezvous). send blocks until a recv is ready.
  • capacity > 0 — Buffered. Up to capacity messages can be queued before send blocks.

The channel type is inferred from usage:

let ch = channel(10) // Channel(i64) inferred from first send/recv
ch.send(42)

Or declared explicitly:

let ch: Channel(String) = channel(10)
ch.send(value)

Send a value into the channel. If the buffer is full, the calling fiber yields until space is available. If the channel is closed, send fails with ChannelClosed.

let value = ch.recv()

Receive a value from the channel. If the buffer is empty, the calling fiber yields until a value is available. Returns null when the channel is closed and empty — this is the signal that no more values will arrive.

Idiomatic consumption loop:

while let msg = ch.recv() do
process(msg)
end
// Channel closed, all messages consumed
let ok = ch.trySend(value)

Non-blocking send. Returns true if the value was enqueued, false if the buffer was full. Does not yield.

let maybe_value = ch.tryRecv()

Non-blocking receive. Returns the value if one was available, null otherwise. Does not yield. Does not distinguish between “empty buffer” and “closed channel” — use isClosed() to disambiguate.

ch.close()

Close the channel. After closing:

  • send() fails with ChannelClosed
  • recv() drains remaining buffered values, then returns null
  • isClosed() returns true

Closing is idempotent. Calling close() on an already-closed channel is a no-op.

if ch.isClosed() do
println("Channel is done")
end

Returns true if the channel has been closed.


select multiplexes over multiple channel operations. It blocks until one of the cases is ready, then executes that case’s body. If multiple cases are ready simultaneously, one is chosen non-deterministically.

select do
recv channel_a |value| do
// Received from channel_a
end
recv channel_b |value| do
// Received from channel_b
end
send channel_c(expression) do
// Sent to channel_c
end
timeout milliseconds do
// No channel ready within timeout
end
default do
// No channel ready right now (non-blocking)
end
end
recv ch |value| do
// value is the received message
end

Waits for a value on ch. The |value| binding receives the message. If the channel is closed and empty, this case is skipped.

send ch(expression) do
// Executed after successful send
end

Attempts to send the evaluated expression into ch. If the buffer is full, the case blocks. If another case becomes ready first, this send is not executed.

timeout 5000 do
// Fired if no other case is ready within 5 seconds
end

The value is in milliseconds. At most one timeout case per select. Mutually exclusive with default.

default do
// Executed immediately if no channel case is ready
end

Makes the entire select non-blocking. If any channel case is ready, it wins. If none are ready, default fires. Mutually exclusive with timeout.

The common pattern is select inside a while loop to continuously react to events:

var running = true
while running do
select do
recv work_ch |job| do
process(job)
end
recv quit_ch |_| do
running = false
end
timeout 10_000 do
println("Idle for 10 seconds")
end
end
end

Cooperative cancellation primitive. A CancelToken is a thread-safe, atomic flag that tasks check at yield points to determine if they should stop.

let token = CancelToken.create()

Nurseries create cancel tokens automatically. You can also create standalone tokens for manual cancellation control.

if token.isCancelled() do
// Clean up and exit
return
end

Tasks should check cancellation at natural yield points — between iterations of a loop, before starting expensive work, after channel operations.

token.cancel()

Sets the cancellation flag. All subsequent calls to isCancelled() return true. This is an atomic operation — safe to call from any fiber.

When a nursery’s cancel token fires (either from a child failure or explicit nursery.cancel()), all children see isCancelled() == true at their next yield point.

nursery |n| do
spawn func() do
while not n.isCancelled() do
do_work()
end
// Nursery was cancelled -- exit cleanly
end
spawn func() do
// This failure cancels the nursery, triggering
// the other task's isCancelled() check
fail FatalError
end
end

Every nursery and task has a resource budget that limits consumption. Budgets enforce resource discipline and prevent runaway tasks from starving the system.

FieldTypeDescription
opsu64Maximum operation count
memoryu64Maximum memory allocation in bytes
spawn_countu64Maximum child spawns
channel_opsu64Maximum channel send/recv operations
syscallsu64Maximum system call count
let b = Budget.serviceDefault() // Generous limits for :service profile
let b = Budget.childDefault() // Per-task budget slice
let b = Budget.zero() // No budget (unlimited -- :core profile)

When a task exhausts any budget dimension, it transitions to BudgetExhausted state. A supervisor task can recharge it:

nursery |n| do
let handle = spawn worker_task()
// Monitor and recharge if needed
spawn func() do
while not n.isCancelled() do
if handle.isBudgetExhausted() do
handle.rechargeBudget(Budget.childDefault())
end
yield()
end
end
end

Starting with v2026.3.23-service-async, main() itself runs as a fiber on the M:N scheduler. This means:

  • main() can use nursery, spawn, await, channel, and select directly
  • No special setup or scheduler initialization required
  • The scheduler starts automatically when the program begins
  • Worker threads are created based on available CPU cores
func main() do
// This is already running on the fiber scheduler
nursery do
spawn task_a()
spawn task_b()
end
println("Done")
end

The underlying scheduler is a Capability-Budgeted Cooperative M:N Scheduler (CBC-MN):

  • M:N mapping — M fibers multiplexed onto N OS worker threads
  • Work-stealing — idle workers steal tasks from busy workers’ deques (Chase-Lev)
  • Cooperative yield — fibers yield at channel operations, await, and explicit yield points
  • Per-fiber stacks — each fiber has its own stack (default size varies by profile)
  • Context switch — architecture-specific assembly (x86_64, aarch64) — fast register swap

For deep scheduler internals, see M:N Scheduler & Fibers.


SpecDescription
SPEC-021Capability-Budgeted Cooperative M:N Scheduler
SPEC-019Cancellation Tokens and Structured Failure Propagation
SPEC-022Scheduling Capabilities
SPEC-017Janus Syntax (nursery/spawn/select grammar)

“Concurrency without structure is just chaos with more threads.”