ADR 0038 — L1 A4e: an opt-in work-stealing executor
- Status: Accepted by the owner, 2026-11-01 (every question as proposed). Phase 4 of what is left of L1 A4 (ROADMAP, the owner's "in a multiphased way").
- Scope: letting one
Async.runuse several threads, by moving tasks from a busy thread to an idle one. It covers which tasks may move, what the compiler checks, and the runtime's shape.
1. Context
ADR 0031 §3.7 chose one executor per thread, where a task never moves. So tasks need not be Send, and children borrow from their parent's frame with no thread rule (§40.5). To use several cores today, a program starts threads, each with its own Async.run, and connects them with channels (ADR 0032).
That leaves a common case awkward: a scope that fans out CPU-heavy children (parse, hash, compress per item). They all run on one thread, and splitting them across threads by hand means channels and owned copies of the input.
CeeSafe already has the rule that makes borrowing across threads safe: Thread.both's capture rule (§38.6, ADR 0018). A capture used shared needs Share, a capture used mutably needs Send, with references counting as Share/Send when their target is. Both closures end before both returns, so their loans are scoped. A scope's children end before the scope's wait returns (§40.5), the same structure.
The proposal (docs/async-await-proposal.md §6) noted that "a later opt-in work-stealing executor can require frames to be Send; the compiler knows which locals live across which await".
2. Options
2.1 What may move
| A. Marked children, before they start (proposed) | B. Every task, at any suspension | C. Marked children, at any suspension | |
|---|---|---|---|
| Opt-in | s.spawnSend(child) | Async.runWorkers(n, task) makes every task movable | s.spawnSend(child) |
| Checked | the child's captures, by §38.6's rule, and its result | every frame of every task: each local live across an await must be Send | the child's captures and every frame it awaits |
| Diagnostic | N0334 naming the capture (as Thread.both) | N0334 naming the local and the await | as B, for the child's tree |
| Runtime | a shared queue of unstarted children; each worker is today's executor | tasks, timers and I/O registrations move between reactors | as B |
| I/O, timers | the worker that started the task owns them, as today | a registration must move with the task | as B |
A is proposed. A started task stays on the worker that first ran it, so its frame never crosses threads and need not be Send. Only what the child is given crosses: its captures, as Thread.both's rule checks them, and its result. Each worker is the executor that exists today, with its own reactor, timers and wake list. The new runtime piece is a queue of unstarted children that idle workers take from.
The cost is balance. A long child cannot be split once it has started. Fan-out workloads have many children, and those spread well. B and C would also need every reactor to hand over registrations, which none of the four backends does.
2.2 Where the workers come from
D. Async.runWorkers(n, task) (proposed) | E. A process-wide pool, sized to the cores | |
|---|---|---|
| Threads | n − 1 started for this run and joined when it returns; the caller is the n-th | started on the first run that needs one, never joined |
| Without it | spawnSend children run on the scope's thread, as spawn's | — |
| Failure | a worker that cannot start leaves fewer workers; the result is the same | as D |
D is proposed. It is explicit, scoped like everything else in the model, and needs no global state. A library that calls spawnSend works unchanged under plain Async.run.
3. Proposed decision
s.spawnSend(child)in anAsync.scopebody, alongsidespawn(§40.5). It takes an async closure literal and returnsResult<void, AllocError>, likespawn. Its captures follow §38.6's rule: a shared capture needsShareand a mutable oneSend, references counting by their target, and a captured closure is refused (N0334, naming the capture). Its result's error type must beSend(N0334). The scope-loan rule (§40.5, ADR 0033) applies unchanged, so two children may not both change one value (N0241).- A child may move only before it starts. An idle worker of the same run may take it from the scope's thread. Once started, a task runs only on the worker that started it, and every task it awaits or spawns starts there. Nothing about frames is checked, because frames do not move.
Async.runWorkers(n, task)runstasklikeAsync.run(§40.4), withn − 1more worker threads for the run (n ≥ 1). It returns when the task has returned and every worker is idle. The workers are joined then.Async.run(task)isAsync.runWorkers(1, task). A worker that cannot be started is skipped: the result is the same, and a program cannot tell how many ran (asThread.both).- Cancellation and the scope work across threads. A child's error cancels its siblings wherever they run: the flag is atomic, and a worker's task is woken through that worker's wake pipe (ADR 0032 §5). The scope's count of live children is atomic. The child that finishes last wakes the parent across threads.
- Runtime. One run shares a queue of unstarted
spawnSendchildren (a mutex-protected deque in v1, which the benchmark may later replace). Each worker is aceesafe_executoras today. A worker takes from the queue only when its ready queue is empty and nothing it waits for is due. Thetaskscommand of the debugger (L1 A3) lists each worker's tasks. - The interpreter has one thread:
spawnSendisspawn, andrunWorkers(n, …)isrun. A program's output must not depend on which worker ran what (§40.8), which the corpus checks by comparing C with the interpreter. - Profiles. Hosted only, like
Thread(§38.5). The freestanding executor (phase 5) has one worker.
4. Consequences
- Checker:
spawnSendbesidespawn(src/check/async.cee), with §38.6's capture rule (checkThreadBoth's,src/check/threads.cee);runWorkersbesiderun. NIR and backend:spawnSendlowers likespawnwith a flag,runWorkerslikerunwith a worker count. Runtime: the run's shared queue, atomic scope counts and cancellation, cross-thread wakes of a parent, workers started and joined byceesafe_async_run_workers. - Spec: type-system.md §40.4, §40.5 and §40.8; c-backend.md §3a and §6; diagnostics.md N0334; profiles.md.
- Tests:
- run-pass: a scope fanning out
spawnSendchildren over a borrowedVector(the same output underrunandrunWorkers(4, …), and in the interpreter); cancellation across workers; children doing I/O and timers on their own worker; - check-fail: N0334 for a non-
Shareshared capture, a non-Sendmutable capture, a captured closure, and a non-Senderror type; - TSan on Linux for the runtime (a new contract step if clang has it), plus the cancel sweep;
benchmarks/async-parallel: a CPU-bound fan-out with its speed-up at 1, 2 and 4 workers on Debian and macOS.
- run-pass: a scope fanning out
5. Questions for the owner
- What may move: A, marked children before they start (no frame checks), rather than B or C (every task, or marked trees, at any suspension, with
Sendframes)? A. - Workers: D,
Async.runWorkers(n, task)per run, rather than a process-wide pool? D. - The name
spawnSend? (Alternatives:spawnAnywhere,spawnParallel.)spawnSend.
6. Implementation (2026-11-01)
- Checking (§3.1, §3.3):
checkSpawn(src/check/async.cee) takessend.checkSendChildapplies §38.6's rule throughisForkSafe, refuses captured closures, and requires aSenderror type and the system heap. These are the messages ofThread.both, namingspawnSend. A send child's HIRSpawnis listed inc.sendSpawns, and NIR marks itsBuiltin(AsyncSpawn)with the roletaskSend.Async.runWorkers(n, task)isBuiltinCall(AsyncRunWorkers, [n, RunTask(task)]), lowered toBuiltin(AsyncWorkers, [n])then the run.spawnreturnsvoid(a failed spawn is recorded in the scope, §40.5), sospawnSenddoes too, not theResult<void, AllocError>that §3.1 says. - Runtime (§3.4–3.5),
runtime/hosted/ceesafe_rt_async.c:- The loop of
ceesafe_async_runis shared by the caller andcee_workerthreads (cee_exec_begin,cee_exec_loop,cee_exec_end). cee_runholds a mutex, the queue of published children with an atomic count, an atomicdone, acancel_epoch, and the workers' executors.- A child is queued only by
ceesafe_async_publish, after its frame is initialised. - A shared scope's
live,failedandwaiterchange under the mutex.cancelledis atomic, and so is a task's. - The waiting parent parks
on_wake, which cancellation does not wake. The child that finishes last wakes it through its executor. - Wake pipes are non-blocking: a busy worker drains its pipe only when it waits.
ceesafe_scopegrew from 24 to 32 bytes. A task grew from 128 to 144 (its executorhome, an atomiccancelled,unpublished), andceesafe_task_slotwith it. The frame layouts follow (scopeLayout,taskSlotLayout), and so does the interpreter's model of a child's block.
- The loop of
- Found by ThreadSanitizer: each
spawnSendrewrote the scope'ssharedflag while workers read it. Now only the first does, before any child is published. - Not done: §3.5 said the debugger's
taskscommand lists every worker's tasks. It lists the stopped thread's executor, as before. - Tests:
- run-pass/async-workers: a fan-out with 1 and 4 workers, cancellation across workers, children with scopes of their own;
- check-fail/async-workers: a capture read that is not
Share, one changed that is notSend, a captured closure, an error type that is notSend,Async.scopeInwith a user allocator; - a contract step "run tsan": the async programs under ThreadSanitizer, on macOS and Linux;
- the cancel and fault sweeps reach the new program (47 and 63 points on macOS, none failing);
benchmarks/async-parallel(macOS, 8 cores, 32 children): 412 ms with 1 worker, 194 with 2 (2.12x), 111 with 4 (3.71x), 70 with 8 (5.89x).