Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR 0013: Structured threads, atomics and channels

Status: accepted, 2026-10-03

Context

Doc 05 section 6 lists scoped threads, Atomic[T] and channels for Phase 4. ADR 0011 already runs pure pipelines on a pthread pool, with a thread-local sspur_jb and a single-threaded conservative GC. The parser had par(a, b) but evaluated it as a tuple. Models write concurrent code badly when the language lets tasks escape, share mutable state, or deadlock silently, so the design aims for safety by construction.

Syntax

fn count(n: Int) -> Int ! conc
= do
  total = atomic(0)
  for w in par(0..4)
    add_range(total, w * n / 4, (w + 1) * n / 4)
  total.load

fn pipeline(n: Int) -> Int ! conc
= do
  a = chan()
  b = chan()
  (_, _, s) = par(produce(a, n), square_stage(a, b), drain(b))
  s

Decisions

#DecisionReason
1par(e1, ..., en) (n >= 2) runs each argument as a task and returns the tuple of results. for x in par(xs) runs the body as one task per element. Both join before continuing; there are no task handlesStructured by construction: no task outlives its scope, so nothing can leak or be forgotten. No new AST node: a one-argument par as a loop source marks the parallel loop
2Atomic[Int]: atomic(v), .load, .store(v), .add(d) (Unit, traps on overflow), .cas(expect, value) -> Bool. Sequentially consistentOne ordering is enough for v0 and is the one models get right. add returns Unit so it reads as a statement; cas covers read-modify-write
3Chan[T]: chan(), .send(x), .recv -> Opt[T] (none once closed and drained), .close, and for x in c until closed. Unbounded; send on a closed channel traps; close is idempotentUnbounded sends never block, so the only blocking point is recv, which makes deadlock detection exact
4The conc effect marks every atomic and channel operation. par itself adds no effect; a par of pure tasks is pureSignatures show where nondeterminism and blocking can happen. Pure parallelism stays deterministic, as in ADR 0011
5Data-race freedom by checking: a task can’t assign a variable declared outside it (E_PAR_RACE), can’t use a function value from outside it (E_PAR_SHARE, because closures can write captured variables), and channels can’t carry function values (E_PAR_SHARE). Everything else in SSPUR is immutable, so tasks share only immutable data, atomics and channelsSound without ownership types: the only mutable state is var cells, and they are reachable only lexically or through closures
6Tasks can’t perform log, handled user effects, yield or effect-row parameters (E_PAR_EFFECT), and can’t return (E_RETURN_IN_PAR). fail[E], div, conc and plain atoms pass throughHandlers don’t cross threads; interleaved log lines would be nondeterministic output
7Failure: the join waits for every task. If any task raised or trapped, the leftmost real failure wins; deadlock traps rank below real failuresDeterministic and identical in both tiers. A task that fails can leave its readers blocked; their deadlock is a symptom, not the cause
8Deadlock is detected, not prevented: when every live task is blocked in recv or waiting at a join, all blocked receivers trap deadlock: every task is blocked on recvExact under unbounded channels, cheap, and an AI agent gets a precise error instead of a hang

Interpreter

Tasks run on OS threads that pass a baton: exactly one runs at a time, so Rc values and RefCell state are never touched concurrently. A task runs until it blocks in recv, joins a par, or finishes; the scheduler then resumes the next ready task in FIFO order. The schedule is deterministic and is a valid schedule of the program. Each task has its own handler stack and starts at its parent’s depth.

Native tier

Every par uses real pthreads (512 MB virtual stacks, matching the main native thread). Task bodies compile to closures; task 0 runs on the parent thread, the rest each get a thread. Each task has its own Status and jmp_buf; the parent copies the chosen failure into its own status and re-raises or re-traps. Atomics use C11 __atomic builtins. Channels and deadlock accounting share one mutex; blocked receivers wait on per-waiter condition variables and are woken directly.

GC choice: allocation is allowed in tasks. While any task thread is alive (ss_live > 0), every allocation takes a global lock and collection is deferred; collections resume once the tasks have joined, when the only running native thread is the root whose stack the GC already scans. This is simpler and safer than stop-the-world over several stacks, at the cost that a long allocating task grows the heap until it joins. Two other shared structures were made thread-safe in that mode: in-place list append claims its slot with a CAS (two tasks pushing onto the same shared list must not both append in place), and the treap priority counter uses an atomic add. Single-threaded code pays one predictable branch per allocation, which the benchmarks don’t measure.

Functions whose signature contains Atomic or Chan have no interpreter entry point (the interpreter’s values can’t cross); their native copies serve native callers, as with function-typed parameters. fuzz --differential skips conc functions, which are nondeterministic by design.

Not yet

Bounded channels and select, other atomic types and orderings, Mutex[T], thread pools for fine-grained tasks (each task is one OS thread, so use par for coarse work and pipelines for data parallelism), task cancellation, and tasks that log.