Concurrency
How work is structured in q64. Scopes own tasks. Tasks are virtual
threads on Wasm 3.0 stack-switching. Channels move ownership.
Shared state is opt-in via the @shared regions and Atomic<T>
primitives from memory.md. The stream runtime is
the task scheduler.
Design goals
- Structured by default. Every task lives in a scope; scopes wait for their children. No leaked tasks, no orphan work.
- No function coloring. No
async fn, no.awaitsyntax tax. Suspension is a runtime behavior, made checkable by the effect system (@no_suspend), not a syntactic obligation. - Cancellation is plumbed, not magic. A
ctx: Cancelparameter travels with cancel-observing functions. The call site sees it. - Ownership-transfer channels. Sending consumes; the sender
cannot reuse the value.
@sendenforces what can cross threads. - One scheduler for tasks and streams. Stages are tasks; stream graphs are task graphs.
- The language never names a thread. Tasks are the unit; thread placement is a host/scheduling concern.
Vocabulary
| Word | Meaning |
|---|---|
| scope | A structured-concurrency block that owns tasks and an arena (per memory.md). |
| task | A unit of work running on its own coroutine stack. |
| handle | A typed reference to a task: h.await(), h.cancel(). Drop = cancel + join. |
| channel | Ownership-transferring queue between tasks. |
| actor | A task with private state and a typed message inbox. |
ctx | A Cancel value threaded through cancel-observing function signatures. |
| virtual thread | A q64 task. M:N onto Wasm threads via stack-switching. |
Scopes
Every task lives in a scope. The scope cannot exit until every spawned task has completed:
scope { let h1 = spawn { compute_thing_a() } let h2 = spawn { compute_thing_b() }
let a = h1.await() let b = h2.await()} // any unawaited tasks joined hereProperties:
- A scope’s lifetime bounds every task spawned inside it.
- A scope cannot return a handle to a child task (the handle would
outlive the scope).
CONC010(“handle escapes its scope”). - Cancellation propagates parent → child: cancelling a task that owns a scope cancels every task inside that scope.
- The scope carries the implicit
scopearena frommemory.md. The firstscopeof a function body also opens it. - A scope can declare a
catchblock to handle a child panic (see §Panics).
Nested scopes inherit cancellation from the parent and add new
bounds for their own children. Same shape as Swift TaskGroup,
Trio nursery, Kotlin coroutineScope — picked for the
twenty-year-old safety story, not novelty.
Scope effect annotations
A scope may carry an effect annotation that applies as an
assert to every task spawned directly inside it:
scope @realtime { loop { select { // rx_frame: Receiver<Frame, RingBuffer> — no ctx; RingBuffer is non-cancel-aware. // Cancellation is still observed at the implicit select branch below. f = rx_frame.recv() -> play(move f), } }}Grammar: scope (@<effect> ("+" @<effect>)*)? { … } (catch (e: T) { … })*.
The effect list applies the standard implication closure from
effects.md, then propagates to every task body
inside the scope. A child task whose effect set exceeds the
scope’s annotation is EFF110 at its spawn site.
The combined form spawn scope @<effect> { … } is sugar for
spawn { scope @<effect> { … } } — it spawns a task whose body
is a single annotated scope. Useful when an @realtime worker
needs its own scope but no surrounding parent scope wants the
constraint:
spawn scope @realtime { audio_loop(ctx, rx_frame)}@realtime is by far the most common annotation; @no_alloc
and user-defined effects are also legal. Asserts compose; a
scope @realtime + @no_panic redundantly tightens (with
@realtime already implying @no_panic) and is EFF132 lint.
Tasks
spawn { … } // fire-and-forget within current scopelet h = spawn { compute() } // handle for explicit join/cancellet v = h.await() // suspends until done; returns the resulth.cancel() // cooperative cancellation (see §Cancellation)A handle is Handle<T> where T is the body’s return type.
spawn { … } (no let) is Handle<()> and may be dropped freely.
Handle ownership
Dropping a Handle<T> cancels the task and waits for it to
acknowledge cancellation before the drop completes:
scope { let h1 = spawn { compute_a() } let h2 = spawn { compute_b() } let v = h2.await() // h1 dropped here → cancelled, awaited, result discarded} // scope exits as soon as both finishThis is a deliberate departure from “auto-join unawaited handles” (Swift TaskGroup, Trio nursery): explicit cancellation on drop means a scope exits as soon as its useful work completes, not when its sibling work happens to finish.
Three ways to dispose of a handle:
| Form | Behavior |
|---|---|
h.await() | Suspends until done; returns the value; consumes the handle. |
h.cancel() | Sends cancellation; consumes the handle; does not await. |
| drop | Cancel + await acknowledgement. Synchronous within the drop site. |
A handle that has been awaited or cancelled is consumed; using it
again is CONC011 (“use of consumed handle”).
Virtual threads: M tasks on N Wasm threads
q64 tasks are virtual threads. Thousands or millions can coexist; each costs a few KB of stack, not the megabyte of an OS thread. The scheduler multiplexes them onto a pool of Wasm threads:
N Wasm threads = ≈ CPU cores (typically 2-32)M q64 tasks = application work (typically 100s-10000s)Same M:N pattern as Goroutines, Java virtual threads (Loom), Kotlin coroutines, Erlang processes. q64’s contribution is Wasm 3.0 stack-switching as the primitive — yielding is a single Wasm instruction, not a CPS transform or a function-color rewrite.
Implications:
- Single-threaded targets (audio worklet, some Wasmtime
configurations) get one Wasm thread; tasks still multiplex
cooperatively. The single-thread case is not a special API —
it’s just
N = 1. - Multi-threaded targets get a Wasm-thread pool sized to host
capability. The scheduler does work-stealing across Wasm
threads for tasks whose payload types are
@send(pereffects.md). @realtimetasks are pinned to a real-time-capable Wasm thread (audio worklet on browser, low-latency pool on native). Migration would break their bounded-time contract.
Cancellation
A task observes cancellation through an explicit ctx: Cancel
parameter. The convention is C-style: ctx is the first
parameter; it threads through call trees:
fn fetch(ctx: Cancel, url: str) -> Result<Response, IoError> @cancel { var buf: Vec<u8> = Vec.new() loop { if ctx.cancelled() { panic Cancelled } let chunk = try http.read_chunk(ctx, url) if chunk.is_eof() { break } buf.extend(move chunk) } Ok(Response.parse(buf))}
scope { let h = spawn { fetch(ctx, "https://...") } sleep(2.s) h.cancel() h.await() // panics with Cancelled payload}Cancelled is the auto-prelude payload type from
errors.md §“Auto-prelude payload types”; it fits
Panic with code() = None — cancellation is a runtime
control-flow event, not a diagnostic, and the CONC* codes are
reserved for compile-time diagnostics. Cancellation observation
unwinds the task; it does not consume a recoverable-error slot in
the function’s return type.
Cancel is an opaque value of type Cancel. ctx.cancelled()
is @no_alloc + @no_suspend (cheap to call in hot loops).
The @cancel effect documents that a function observes
cancellation. A @cancel function calling a non-@cancel
function is fine (the callee just doesn’t check); a non-@cancel
function cannot take a ctx: Cancel parameter (per
effects.md, the effect must match the
signature).
Where ctx comes from
Four sources introduce a ctx: Cancel binding visible to the
code below:
- Function parameter. A function that declares
ctx: Cancelin its signature receives the caller’sctxand forwards it to@cancelcallees. This is the most common path —@cancelfunctions takectxas a parameter (§“Cancellation”, above). - Spawned task. A
spawn { … }block introduces a freshCancelnamedctxthat’s bound to the handle.h.cancel()flips thatctx; the task seesctx.cancelled()go true at its next observation. - Scope binding. Every
scope { … }block introduces an implicitctx: Cancelwhose lifetime is the scope. The scope’sctxis signalled when the scope’s parent task is cancelled or when a sibling task panics (per §“Panics across tasks”). Inside a scope,ctxresolves to the lexically nearest binding: the spawned task’s own ctx when insidespawn { … }, otherwise the enclosing scope’s ctx. - Stage in a graph. A stage that declares
ctx: Cancelin its signature observes cancellation explicitly and acquires@cancelpereffects.md. A stage withoutctxis still cancellable: the runtime injects a shutdown path equivalent topanic Cancelledat the next channelrecv/sendon a cancel-aware policy, or at the stage’s natural completion.g.start()returns aHandle<Out>whose ctx is the graph’s root;g.stop()is semanticallyh.cancel()on that handle, which propagates to every stage’s ctx through the standard scope-cancellation path above. Seestreams.md§“Effects on stages”.
main’s top-level body has an implicit ctx tied to the program’s
lifetime: it never flips (the program ends via env.exit() or by
falling off the end), but it allows @cancel calls and select
arms from inside main to type-check. Programs that need to
shut down on a signal (SIGINT, browser beforeunload) install
a signal handler via the runtime adapter that calls
top_ctx.cancel() — the same mechanism as h.cancel().
Cancellation is cooperative. h.cancel() does not interrupt
running code. The task observes at:
- Explicit
ctx.cancelled()checks. - Every
select(see §Select). - Channel
recv/sendoperations on cancel-aware channels (BackpressureandLatestValue; notRingBuffer/Unbounded). - Any function that takes
ctx: Canceland chooses to check.
Once observed, cancellation unwinds the task via panic Cancelled
(per errors.md); the panic propagates per
§“Panics across tasks”, and the nearest enclosing scope … catch (e: Cancelled) { … } (or catch (e: Panic) { … }) can intercept
it.
@realtime tasks cannot be cancelled at arbitrary points; they
run to their next natural yield. Documented in §Effects.
@uncancellable cleanup
Code that must complete (releasing a lock, flushing a buffer) opts out of cancellation observation:
fn flush_journal(j: ref Journal) @uncancellable { db.write_all(j.pending) // no Cancelled panic even if ctx flips}The body of an @uncancellable function cannot call a @cancel
function (the propagation would defeat the marker). CONC012.
Channels
Channels move values between tasks. Sending consumes the value (move semantics); the sender cannot use it after.
let (tx, rx) = channel<Frame>(policy: Backpressure, capacity: 16)
spawn { let f = capture_frame() tx.send(ctx, move f) // ownership transferred; suspends if full}
spawn { loop { let f = try rx.recv(ctx) // suspends until a value is available render(move f) }}channel<T>(…) construction
channel<T>(…) is a built-in factory returning (Sender<T>, Receiver<T>). Policy is required — there is no default. A
bare channel<T>(capacity: N) is CONC050:
let (tx, rx) = channel<Cmd>(capacity: 16)// ^^^^^^^^^^^^^^^^^^^^^^^^^^ CONC050: channel policy requiredForcing the policy at every construction makes the bounded / overwriting / blocking choice visible at the call site.
The full constructor signature is:
pub fn channel<T, P: Policy>( region: R = scope, // backing allocator; defaults to scope arena policy: P, capacity: i64 = 0, // required for Backpressure / RingBuffer) -> (Sender<T, P>, Receiver<T, P>)regionis normally elided; pass an explicitPool<T, N>(permemory.md) to back the channel from a fixed slot pool — required when an@realtimeconsumer reads from it (see the audio example at the end of this spec).policyis one of the four below.capacityis required forBackpressureandRingBuffer; ignored byLatestValue(single slot) andUnbounded(grows).
Channel policies
| Policy | Behavior on overflow |
|---|---|
Backpressure | Sender suspends; resumes when there’s room. |
RingBuffer | Overwrites the oldest value. |
LatestValue | Single-slot; only the most recent retained. |
Unbounded | Grows; never blocks; lints as CONC051 (advisory). |
Backpressurefor lossless data: file streams, network protocols, work queues. Cancel-aware (observesctx).RingBufferfor time-windowed data: audio history, log tails, sensor traces. Not cancel-aware; never blocks.LatestValuefor state mirroring: UI state propagation, render-thread “what’s the latest?” reads. Cancel-aware.Unboundedfor emergencies only;q64 fmt --lintwarns.
Sender<T, P> and Receiver<T, P> API
The endpoints’ surface depends on whether the policy is
cancel-aware (Backpressure, LatestValue) or not
(RingBuffer, Unbounded). Two pairs of faces; the
channel<T, P>(…) factory returns the variant matching P.
// Common methods on every Sender / Receiverpub face SenderBase<T, P: Policy> { fn try_send (self, move x: T) -> Result<(), SendError<T>> // never suspends fn close (self) // signal end-of-stream}
pub face ReceiverBase<T, P: Policy> { fn try_recv (self) -> Result<T, RecvError> // never suspends fn closed (self) -> bool}
// Non-cancel-aware policies — send/recv never observe ctxpub face Sender<T, P: NonCancelPolicy> : SenderBase<T, P> { fn send (self, move x: T) // never suspends (RingBuffer overwrites; Unbounded grows)}
pub face Receiver<T, P: NonCancelPolicy> : ReceiverBase<T, P> { fn recv (self) -> T // suspends but does not observe cancellation}
// Cancel-aware policies — send/recv take ctx and are @cancelpub face Sender<T, P: CancelPolicy> : SenderBase<T, P> { fn send (self, ctx: Cancel, move x: T) @cancel // may unwind via panic Cancelled}
pub face Receiver<T, P: CancelPolicy> : ReceiverBase<T, P> { fn recv (self, ctx: Cancel) -> T @cancel // suspends; observes cancellation}The two declarations each of Sender and Receiver are the canonical
instance of the bound-disjoint face overload form specified in
faces.md §“Bound-disjoint face overload”: CancelPolicy
and NonCancelPolicy are provably disjoint, so the compiler treats
each pair as a single face whose send / recv signature is selected
by P. The two declarations must not share a method name (TYP220);
common methods live on SenderBase / ReceiverBase.
Policy is the auto-prelude umbrella face;
CancelPolicy and NonCancelPolicy are its two disjoint
sub-faces. Backpressure and LatestValue fit CancelPolicy;
RingBuffer and Unbounded fit NonCancelPolicy. The compiler
picks the correct Sender/Receiver fit per the policy at the
channel-construction site.
Notes:
recvreturnsTdirectly; it does not return aResult. End-of-stream is detected viaclosed()or by thefor x in rx { … }loop form (below).- Calling
recvon a closed-and-empty channel unwinds the task withpanic Closed—Closedis the auto-preludePanic-fitting payload fromerrors.md§“Auto-prelude payload types”. A receiver that wants to observe close cleanly testsclosed()first or iterates withfor x in rx { … }. try_recvreturnsErr(RecvError::Empty)when no value is ready andErr(RecvError::Closed)when the sender has closed and the buffer is empty.SendError<T>carries the rejected value back so the caller can recover ownership on failure:Err(SendError { value: T, reason: SendErrorReason })whereSendErrorReason ∈ { Full, Closed }.
for x in rx { … } loop form
Receiver<T, P> does not fit the auto-prelude Iterator face
— Iterator.next() -> Item? is non-suspending, and recv needs
to suspend (possibly observing cancellation). The for x in rx { body } form is a compiler-recognized desugaring, not an
Iterator fit:
for x in rx { body(x) }desugars per the receiver’s policy. For a cancel-aware policy
(Backpressure, LatestValue):
loop { match rx.try_recv() { Ok(x) -> body(x), Err(RecvError::Empty) -> { let x = rx.recv(ctx); body(x) }, Err(RecvError::Closed) -> break, }}For a non-cancel-aware policy (RingBuffer, Unbounded):
loop { match rx.try_recv() { Ok(x) -> body(x), Err(RecvError::Empty) -> { let x = rx.recv(); body(x) }, // no ctx Err(RecvError::Closed) -> break, }}Requirements: for a cancel-aware policy, a ctx: Cancel must be
in lexical scope (the desugaring uses it for the suspending
recv). A for x in rx over a CancelPolicy channel with no
ctx in scope is CONC053 (“for-loop over cancel-aware
receiver without ctx”). For a non-cancel-aware policy, ctx is
not required.
Cross-thread channels
The channel API is the same whether the endpoints are on one thread or two. The runtime adapter chooses the backing:
- Same-thread endpoints: in-memory ring buffer, no atomics.
- Cross-thread endpoints: SAB-backed ring buffer with
Atomics.wait/notify(browser) or futex (native).
User code is identical. The runtime picks the implementation
based on whether the endpoints end up in different shared
scopes. Cross-thread channels require the payload type to be
@send (per effects.md); the check happens at
channel<T>(…) construction if the channel will be passed across
a thread boundary.
Select
select waits for the first ready branch and dispatches:
select { msg = rx1.recv(ctx) -> handle_message(move msg), cmd = rx2.recv(ctx) -> handle_command(move cmd), _ = timeout(100.ms) -> handle_timeout(),}Timeouts and channels are uniform: timeout(d) returns a
Receiver<(), LatestValue> that fires once after d elapses.
The single-slot LatestValue policy is the natural fit for a
one-shot signal; the receiver observes ctx per the
cancel-aware contract above.
Implicit cancellation branch
Every select carries an implicit cancellation branch resolved
to the lexically nearest ctx: Cancel:
fn worker(ctx: Cancel, rx: Receiver<Cmd, Backpressure>) { loop { select { cmd = rx.recv(ctx) -> handle(cmd), _ = timeout(1.s) -> tick(), // _ = ctx.cancelled() -> panic Cancelled ← implicit } }}The injected branch unwinds with panic Cancelled. A user-written
_ = ctx.cancelled() -> … branch overrides the default for
cleanup-before-unwinding:
select { msg = rx.recv(ctx) -> handle(move msg), _ = ctx.cancelled() -> { flush(); panic Cancelled },}If no ctx: Cancel is in scope, q64 fmt --lint warns
(CONC041) — the select will wait forever.
Actors
A task with private state and a typed message inbox. The actor type enforces invariants that a bare desugaring would not.
actor Counter { state count: i64 = 0
handle Increment { state.count += 1 }
handle Get -> i64 { state.count }}
scope { let c = Counter.spawn() c.tell(Increment) c.tell(Increment) let n = c.ask(Get).await() // 2}tell vs ask
Two verbs split by reply semantics:
| Verb | Used for handlers declared as | Returns |
|---|---|---|
c.tell(Msg) | handle Msg { … } (no -> T) | () |
c.ask(Msg) | handle Msg -> T { … } | Future<T> |
Cross-use is a compile error:
c.tell(Get) // ← CONC020: handler `Get` declares a reply; use `ask`c.ask(Increment) // ← CONC021: handler `Increment` has no reply; use `tell`Future<T>.await() suspends until the reply arrives. A Future
is consumed by await; dropping a Future is fine (the reply is
discarded).
Properties enforced by the actor type
- Isolation. Outside code cannot read or write
statefields directly — only by sending messages.CONC022. - Serial. One message processed at a time; no re-entrancy.
- Crossable. Actor handles are
@sendby construction; they can be passed across threads. - Lifetime-bound.
Counter.spawn()inside ascope { … }ties the actor’s lifetime to that scope.
Desugared form
Counter desugars to roughly:
enum CounterMsg { Increment, Get(reply: Sender<i64, LatestValue>),}
fn run_counter(ctx: Cancel, rx: Receiver<CounterMsg, Backpressure>) { var count: i64 = 0 loop { select { msg = rx.recv(ctx) -> match msg { Increment -> count += 1, Get(reply) -> reply.send(ctx, count), }, } }}The actor sugar is this shape lifted into the type system, with field-access discipline checked by the compiler.
Shared state
Default: every region is thread-local. Sharing is opt-in via the
@shared annotation and Atomic<T> / Shared<T, P> primitives
specified in memory.md:
@sharedstruct World { counter: Atomic<i64>, grid: Shared<Grid, RwLock>,}
scope { let world = World.new() spawn { world.counter.add(1) } spawn { world.counter.add(1) } // world.counter == 2 at scope exit}See memory.md §“Shared regions” for the full table of policies
(Mutex / RwLock / LockFree / Disjoint<F>) and §“Atomic<T>”
for the primitive operations.
@send is derived (per effects.md): types
containing only @send fields are @send. The cross-thread
channel check uses this derivation to reject sharing a
non-shareable value at compile time.
Panics across tasks
A failing task’s behavior depends on whether its scope declares
a catch. Panic payloads — typed per errors.md §“panic and
trap” — flow from the failing task to the
enclosing scope, optionally intercepted by a catch arm.
Without catch: cancel siblings, re-panic at scope close
scope { spawn { db.connect() } // panics spawn { cache.warm() } // cancelled when sibling panics do_other_work()}// ← panic re-emerges here, into the parent scope, with the same payloadThe first panic in a child:
- Marks the scope as failing.
- Cancels every sibling task (signals their
ctx, which observes it at its next yield point and unwinds viapanic Cancelled). - Waits for siblings to acknowledge.
- Re-panics with the original payload at the scope’s closing brace.
No silent loss. No orphan work.
With catch: intercept the panic, suppress propagation
scope { spawn { db.connect() } spawn { cache.warm() } do_other_work()} catch (e: Panic) { log.error("subsystem startup failed: {e.fmt()}") bring_up_degraded_mode()}If any child panics, siblings are cancelled (same as above) but
the panic’s payload is delivered to the first matching catch arm
instead of re-panicking. The scope completes normally after the
catch returns.
A catch block runs after every child task has finished (either
normally or by cancellation). It cannot spawn new tasks into the
just-closed scope; spawning in catch is CONC030.
Typed catch arms
A scope may have one or more catch arms; each binds the
payload by type. Arms are tried top-down, most-specific first.
A catch (e: Panic) (or catch (e: dyn Panic) — equivalent
syntax) is the catch-all.
scope { spawn { fetch_with_timeout(ctx, url, 30.s) }} catch (e: Cancelled) { env.out("shutting down cleanly")} catch (e: RuntimeDenied) { log.warn("denied: {e.code} — {e.detail}")} catch (e: Panic) { log.error("unexpected: {e.fmt()}")}- A
catch (e: T)arm matches when the panic payload’s runtime type fitsTandTitself fitsPanic. Non-Panictypes in catch position areTYP307. - Unmatched panics fall through to the next arm; an unmatched
panic with no further arms re-panics at the scope’s closing
brace (per §“Without
catch”). - Re-panic from inside a
catch (e: …)body usespanic e(pererrors.md) — there is no separate keyword. - Two arms with the same type, or a catch-all arm preceding a
more specific one, are
CONC033(“unreachable catch arm”).
Result-returning tasks
A spawned task that returns Result<T, E> does not “panic” when
returning Err(e); the error is delivered through
h.await(). Only an actual panic / trap invokes the scope
unwinding above.
A stage inside a graph { … } is the exception: stages have no
h.await() consumer, so a stage returning Err is converted to
a panic at the stage boundary and cascades per the rules above.
This is specified in streams.md §“Error propagation”.
scope { let h = spawn { connect_to_db() } // returns Result<Conn, Error> match h.await() { Ok(conn) -> use_db(conn), Err(e) -> log.error("db unavailable: {e}"), }}Coroutines (no async/await coloring)
Wasm 3.0 stack-switching means q64 doesn’t need function colors.
There is no async fn, no .await syntax at I/O call sites, no
“what color is your function” problem:
fn fetch_user(ctx: Cancel, id: UserId) -> User { let resp = http.get(ctx, url_for(id)) // suspends here; reads env.net parse_user(resp.body)}fetch_user is just a function. The HTTP call suspends the task;
another task runs; when the response arrives, the task resumes
from the exact instruction. Callers don’t have to annotate
themselves.
The compiler still tracks “this function can suspend” via the
@no_suspend effect (per effects.md). A
@no_suspend function calling something that suspends is
EFF111. The @realtime effect implies @no_suspend.
Effects across concurrency
The effect markers from effects.md apply to
tasks and channels:
| Marker | Meaning |
|---|---|
@realtime | Bounded execution, no alloc, no blocking, no suspending. |
@no_alloc | No heap allocation (linear or managed). |
@no_suspend | Cannot yield to the scheduler. |
@send | Safe to transfer across thread boundaries. |
@pure | No mutation, no observable side effects. |
@cancel | Function observes ctx.cancelled(). |
Specific interactions:
- A
@realtimestage cannot call a non-@realtimefunction or send on aBackpressurechannel (which would block). UseRingBufferorLatestValue(non-blocking). - A
@realtimetask is pinned to a real-time-capable Wasm thread; cannot be migrated. - Cross-thread channels require the payload to be
@send. The compiler verifies the graph at build time. @cancelcannot appear in@uncancellablecall trees.@uncancellablecannot appear in@realtime(cancellation is already moot in real-time; the marker would just confuse).
The graph is verified at build time — runtime surprises (audio glitches, deadlocks, sharing violations) get caught as type errors.
Threading: a host concern
The language never names a thread. Tasks are the unit; whether two tasks happen to run on the same OS thread or different ones is a scheduling decision made by the runtime adapter.
| Host | Coroutines | Multi-threading | Channels | Atomics |
|---|---|---|---|---|
| Browser | Wasm stack-switch | Web Workers + SAB | SAB ring + Atomics.wait | Atomics.load/store/cas |
| Wasmtime | Wasm stack-switch | OS threads | shared-memory ring | futex / pthread atomic |
| Wasmer | Wasm stack-switch | OS threads | shared-memory ring | futex / pthread atomic |
| Audio host | host scheduler | audio worklet thread | host-managed lock-free FIFO | host primitives |
User code is identical across all. The runtime adapter translates
q64’s scope / spawn / channel primitives to platform
mechanisms. SAB, Worker.postMessage, Atomics.*, COOP/COEP
headers — all behind the adapter, never in user code.
The one thing that leaks through is deployment configuration:
COOP/COEP headers, thread-pool sizes, audio-worklet affinity.
These live in qube.json5 target profiles, not in the language.
Encapsulating SharedArrayBuffer
What user code never touches:
SharedArrayBuffer,ArrayBuffer.transferToFixedLengthWorker,Worker.postMessage,MessagePortAtomics.wait,Atomics.notify,Atomics.compareExchangeBigIntmarshaling fori64across the JS boundary- COOP/COEP headers in HTML/HTTP responses
- Wasm thread bootstrap (creating shared memory, initializing workers)
What user code does see:
scope,spawn,channel<T>,select@shared,Atomic<T>,Shared<T, P>(permemory.md)actor,handle,tell,askSignal<T>,Event<T>,Stream<T>(perstreams.md)
The runtime adapter is the boundary. Application code is portable across browser, Wasmtime, and audio-host targets without changing a line.
Stream runtime = task scheduler
There is no separate “async runtime” alongside the stream graph executor. They’re the same.
- Each
stagein a stream graph is a task. - Stream channels (
|>between stages) are the channel system above. - Adjacent stages with compatible effects can be fused by the compiler into a single task — one suspension point per fused group, fewer context switches, better SIMD usage.
- Backpressure propagates through the graph automatically.
@realtimegraph segments get pinned to real-time-capable threads.
Audio DSP, a UI event loop, an LLM token pipeline, and a worker queue all use the same primitives, the same scheduler, and the same effect-checking. One concurrency story for the whole language.
Detailed in streams.md.
Diagnostic codes
All concurrency diagnostics use the CONC prefix. Numbers stable,
never reused. CONC060-CONC099 reserved for expansion.
Grammar (informal)
ScopeStmt := "scope" EffectAnnot? Block CatchArm*EffectAnnot := "@" Ident ("+" "@" Ident)*CatchArm := "catch" "(" Ident ":" TypeExpr ")" Block
SpawnExpr := "spawn" Block | "spawn" "scope" EffectAnnot? Block // sugar | "spawn" "scope" EffectAnnot? Block CatchArm*
ActorDecl := Visibility? "actor" Ident ActorBodyActorBody := "{" ActorItem* "}"ActorItem := StateDecl | HandleDeclStateDecl := "state" Ident ":" TypeExpr ("=" Expr)?HandleDecl := "handle" Ident ("(" Params? ")")? ("->" TypeExpr)? Block
ChannelExpr := "channel" "<" TypeExpr ("," PolicyExpr)? ">" "(" ChanArgs ")"ChanArgs := (RegionArg ",")? "policy" ":" PolicyExpr ("," "capacity" ":" Expr)?
SelectStmt := "select" "{" SelectArm ("," SelectArm)* ","? "}"SelectArm := (Pattern "=")? Expr "->" (Block | Expr)ActorDecl joins the top-level Item list in
modules.md §Grammar; pub effect declarations
introduced in effects.md do the same. The
EffectAnnot form on scope reuses the same @<ident> syntax as
function-level effect annotations from effects.md.
| Code | Short message | When |
|---|---|---|
CONC010 | handle escapes its scope | A function returns a Handle<T> whose task was spawned in one of its scopes. |
CONC011 | use of consumed handle | .await() / .cancel() called on an already-disposed handle. |
CONC012 | @cancel call from @uncancellable context | A function marked @uncancellable calls one marked @cancel. |
CONC013 | @uncancellable inside @realtime | The two markers are mutually meaningless; flagged at definition. |
CONC020 | tell on a reply-bearing handler | c.tell(Get) where handle Get -> T is declared. |
CONC021 | ask on a non-reply handler | c.ask(Increment) where handle Increment { … } (no -> T) is declared. |
CONC022 | outside access to actor state | Code outside the actor reads or writes a state field directly. |
CONC030 | spawn inside a catch block | catch may not start new tasks into the just-closed scope. |
CONC031 | spawn outside any scope | The top-level spawn requires an enclosing scope (or the implicit main scope). |
CONC032 | actor handler not declared in face | c.tell(Msg) / c.ask(Msg) where Msg is not a declared handle. |
CONC033 | unreachable catch arm | A later catch arm cannot be reached because an earlier arm subsumes it. |
CONC040 | select without timeout or cancel branch | Lint (advisory). Suppressible with @allow(CONC040). |
CONC041 | select with no ctx: Cancel in scope | Lint. The implicit cancel branch can’t resolve; select may hang forever. |
CONC050 | channel policy required | channel<T>(capacity: N) with no policy: argument. |
CONC051 | Unbounded channel | Lint (advisory). channel<T>(policy: Unbounded). |
CONC052 | non-@send payload in cross-thread channel | A channel passed across a thread boundary whose T isn’t @send. |
CONC053 | for x in rx over cancel-aware receiver without ctx | The for-loop desugaring needs a ctx: Cancel for the suspending recv(ctx) step on Backpressure/LatestValue policies. |
All codes are emitted using the envelope from
diagnostics.md.
Examples
Cancellation-aware HTTP client
fn fetch_with_retry( ctx: Cancel, url: str, max_attempts: i64,) -> Result<Response, Error> @cancel { for attempt in 0..max_attempts { if ctx.cancelled() { panic Cancelled } match http.get(ctx, url) { Ok(r) -> return Ok(r), Err(_) -> sleep(ctx, backoff(attempt)), } } Err(Error.new("exceeded retries"))}
scope { let h = spawn { fetch_with_retry(ctx, "https://...", 3) } select { r = h.await() -> use_response(r), _ = timeout(10.s) -> h.cancel(), }}Producer / consumer with backpressure
scope { let (tx, rx) = channel<Frame>(policy: Backpressure, capacity: 8)
spawn { loop { let f = capture(env.camera) tx.send(ctx, move f) // suspends if buffer full } }
spawn { loop { let f = rx.recv(ctx) encode_and_write(move f) } }}Actor with reply
actor Cache { state entries: Map<str, Value> = Map.new()
handle Put(key: str, value: Value) { state.entries.insert(move key, move value) }
handle Get(key: str) -> Option<Value> { state.entries.get(key).cloned() }}
scope { let cache = Cache.spawn() cache.tell(Put("user/42", Value.from(user))) let v = cache.ask(Get("user/42")).await() print(v)}Panic recovery at scope boundary
fn boot { scope { spawn { primary_db() } spawn { primary_cache() } spawn { primary_search() } } catch (e: Panic) { log.warn("primary startup failed: {e}") scope { spawn { fallback_db() } spawn { fallback_cache() } } }}Real-time audio with shared command channel
fn audio_engine { region pool: Pool<Frame, 64> { let (tx_frame, rx_frame) = channel<Frame>(policy: RingBuffer, capacity: 64) let (tx_cmd, rx_cmd) = channel<Command>(policy: LatestValue)
spawn { capture_loop(ctx, tx_frame) }
spawn scope @realtime { loop { select { f = rx_frame.recv() -> play(move f), // RingBuffer: no ctx cmd = rx_cmd.recv(ctx) -> apply(cmd), // LatestValue: ctx-aware // implicit cancel branch } } }
ui_loop(tx_cmd) }}The @realtime scope cannot suspend on a Backpressure channel;
RingBuffer and LatestValue are non-blocking. Cancellation is
observed at the select boundary, where the realtime thread can
clean up bounded resources before exit.
Open items deferred
- Stage / graph DSL. The
stageandgraphkeywords from the design doc — fusion rules, pipe|>semantics, backpressure propagation — live instreams.md. selectwithdefault(non-blocking poll). Common in Go; deferred for v0. Workaround:timeout(0.ms).- Task-local storage. Go’s
context.Value, Trio’s run-vars. Today: passed viaenvor actor state. - Detached tasks. Tasks that survive scope close. Deliberately unsupported; structured concurrency requires it.
- Custom schedulers. v0 ships one work-stealing scheduler with
a
@realtimepinning lane. Per-graph scheduler overrides deferred. - Per-channel priority. All channels are equal; priority is
encoded by select ordering. May add
priority: Priorityargument later.
Related specs
memory.md— scope’s implicit arena,@shared,Atomic<T>,Shared<T, P>policies,transfer(to: …), multi-memory layout.effects.md—@realtime,@no_suspend,@send,@cancel,@pure; the implication graph and@sendderivation that channel and shared-state checks rely on.errors.md—Result<T, E>,panic,Cancelled(a structured-cancellation error type).generics.md— generic parameters onSender<T>,Receiver<T>,Handle<T>,Future<T>.faces.md—Sender,Receiver,Handle, theActorblessed pattern.streams.md—stage,graph, the dataflow DSL; the stream runtime that is this scheduler.modules.md—scope,spawn,channel,select,actor,tell,ask,Cancel,Handle,Future,timeout,sleepare auto-prelude; no import required.diagnostics.md— envelope format for theCONC*codes.