ADR-async-3: Every Task Is Started in a Group and Lives at Most as Long as It; One Word, async, Starts Every Task, and spawn Becomes Its Option

Status: Accepted Date: October 30, 2026 (owner’s decision, option C, in the third design round, #983) Answers: can the things that run beside other code - an async child, a spawn task with its TaskHandle, a select arm, a supervisor’s child, a connection task under serve - be one kind of task, whose lifetime, captures and wait point follow from where it is started or from an option, instead of five concepts with their own rules? Supersedes / Related: ADR-async-1 D1, D5, D8, D13 (changed: a child is a task of its group; the five forms are the policies of D4); ADR-async-4 (the surface of D5: async e, async(handle), async(deferred), async(detached); cancelling through handles; D13 the names, D14 join(within:), beside D7 here); ADR-346 D1-D6 (every task error has a receiver; the handle must be taken; join(within:); detached tasks; they hold for async(detached)); ADR-292 D12-D15 (select; one cancel mechanism; h.cancel(); the pair); ADR-328 D1, D7, D8, D10 (supervisors; a child keeps no state; a crash is an error to the waiter; how a group runs its tasks); ADR-326 D2, D3 (admission for connections, the shape ADR-async-1 D14 took); ADR-327 D5 (a block per task); ADR-298 D7 (spawn is released once the migration is built); ADR-258 D1-D4 (the four weights); Part I 5.4, 8.1, 8.1.2, 8.2, 8.3; Part II 11.2, 12.4, 12.8; Part III A.2, 17.1

The owner chose option C (§3) over the recommended B: one word starts every task now, not after the naming round. Sections 1-3 are the decision paper as it was put; §4 is the decision.

1. Question

1.1 From zero

A program has several ways to have code run while other code runs:

async let user = db::load_user(id)          // a child: borrows, waited at `user`
let h = spawn fn { refresh(cache) }         // a task: moves, may outlive, a handle
select {                                    // arms: borrow, the first wins
    r = fetch(u) => r,
    _ = time::sleep(5.seconds()) => throw TooSlow,
}
supervisor::run([supervisor::child(fn { worker(config) })])  // restarted children
http::serve(addr) fn(req) { … }             // one task per connection

Each has its own section and its own words for the same few facts:

  lifetime captures waited at its failure goes to cancelled by memory
async child (Part I 8.1.2) its group borrows what outlives the group, takes what does not (ADR-async-1 D5) first mention, or the group’s end the mention, the fallback, the group’s end task::cancel(x), a sibling’s panic, within: a block of its own, “as a task”
spawn task (Part I 8.2, 8.3) may outlive the function moves (@detached, Part I 5.4) h.join(), or nobody the joiner, or standard error h.cancel(), join(within:) a block of its own
select arm (Part II 12.4) the block borrows the block, first one the block losing not stated
supervisor child (Part II 12.8) until run gives up lent to every attempt; no mut run’s end the policy, then Escalated the strategy a block per task
connection task (serve, ADR-326) one connection the handler’s serve the protocol, else as a detached task (Part III) the server’s shutdown a block per task

And the specification says, in Part I 8.1.2 and Part III’s NK2104 row, “A child is not a task” - yet a child’s tools live in task::, it has a task’s block, it ends with task::OverBudget and task::Crashed. Part I 8.1 still says “a task exists only where the program writes one (spawn, par_iter)” and “a pause never forks one”, which async now contradicts. Part II 12.4 is titled “Racing Tasks” though an arm is not called one.

Three smaller seams:

1.2 Where the question comes from

The owner, on #983 (2026-10-30): async beside spawn is confusing; unify the concepts first, names last. Nothing is built yet of async (#984 blocked), of supervisors (ADR-328: no) or of admission (ADR-326: no); spawn with TaskHandle is built in part (ADR-346, #605). A change now costs specification text and the shape of a runtime not yet written.

1.3 What the rules already share

Read side by side, the five already agree on more than the table shows:

What really differs is the group each one is started in, and that group’s policy: wait for all (async { }), keep the first (select), restart (supervisor::run), one per connection with admission (serve), and the process itself, which nobody waits for except main’s drain (Part III A.2).

1.4 The question

Should the specification say that every task is a child of a group - its lifetime the group’s, its captures by the one rule of §1.3, its failure the group’s receiver’s - with the group’s policy as the only difference, and spawn the start of a child of the process? And, separately: does that model keep its five spellings, or does the start of a task become one word with an option (§3, C)?

2. How others do it

All rows are from memory, not checked against a current manual.

Language / tool Its answer What it costs there
Swift every unit is a Task: child tasks (async let, TaskGroup.addTask) are bound to their scope and may borrow; unstructured Task { } (inherits priority and actor) and Task.detached { } outlive it, capture by escaping closure, and give a Task<T, E> with .value and .cancel() one word “task”, three ways to start one; cancellation is cooperative and one mechanism; the colored async/await
Kotlin launch (a Job) and async (a Deferred<T>, a Job with a value) are both children of a CoroutineScope; the lifetime is the scope the builder is called on - coroutineScope { }, an object’s own scope, or GlobalScope one kind of coroutine; lifetime by which scope; GlobalScope is discouraged; suspend colors
Trio only nurseries: nursery.start_soon(f). To outlive a function, a task is started in a nursery passed in from further out one concept; lifetime = the nursery chosen; no handle, values by side effect
Java virtual threads are one kind of thread; StructuredTaskScope.fork() gives a Subtask bound to the scope, an executor or Thread.ofVirtual().start() one that outlives one kind of thread, two ownership models, the structured one a library object
C# / .NET one Task for everything: Task.Run, async methods, WhenAll, Parallel.ForEachAsync; no structure; cancellation is a CancellationToken passed by hand maximal unification, no lifetime guarantee, errors of an unobserved task lost
Go one form, go f(): no handle, no value, no structure; errgroup, context for cancellation one concept; every guarantee is a library’s or the program’s
Erlang / OTP one kind of process (spawn, spawn_link); supervisors and links are policies over it uniform processes; nothing is borrowed, everything is copied
Rust std::thread::scope borrows, std::thread::spawn needs 'static; tokio spawn / JoinSet / JoinHandle::abort(); Rayon scope a scoped and an unscoped spawn, written differently, the difference in the type ('static)
Cilk / OpenMP cilk_spawn is always a child, implicitly synced at the function’s end; OpenMP task belongs to the enclosing taskgroup no unstructured task at all

What transfers. Kotlin’s and Trio’s point: lifetime is which group the task is started in, not a second kind of task. Swift’s vocabulary, child task against unstructured (free) task, under one word. Erlang’s: a supervisor is a policy over the one kind, not a kind of its own. What does not: C#’s and Go’s absence of structure (every error here has a receiver, ADR-346 D1); Trio’s passing of a nursery object to every function that starts work, which puts a handle back into every signature; the coloring of Swift and Kotlin.

3. Options, weighed

Option A - five forms, the vocabulary straightened

Nothing changes in what a program writes. The text says “child task” and “free task” instead of “child” and “task”, drops “a child is not a task”, titles 12.4 for arms, and picks one cancel spelling.

Option B - one model: every task is a child of a group; the forms keep their spelling

The specification states once, in one section, what a task is: started in a group; lives as long as the group lets it; borrows what outlives the group and takes what does not (the one rule of §1.3, which yields both today’s borrow and today’s move); allocates from its block; ends with a value, a failure, a crash or task::OverBudget, which the group’s receiver gets. Each form then states only its group’s policy:

Form Its group Policy
async … the enclosing async { } or block wait for all, keep every failure
select { } the block first wins, the rest are cancelled
supervisor::run the call restart by strategy and policy
serve the server one per connection, by admission
spawn the process nobody waits but main’s drain; a handle, if bound, is the receiver

Two seams close inside the model: one cancel spelling, task::cancel(x), for a handle and a pending binding alike (h.cancel() goes); and within: means one thing: a limit counted from where the wait starts, on join and at an async binding’s wait point alike - or, the other way, a deadline from the start on both (the owner picks; §4 recommends one).

async let page = http::get(url)             // a child task of this block
let h = spawn fn { refresh(cache) }         // a child task of the process
task::cancel(h)                             // one spelling for both

Option C - B, and one word starts every task

As B, and spawn becomes an option of the one word naming the group: async(detached) fn { … } (unbound: nobody waits) and let h = async(detached) fn { … } (bound: a TaskHandle[T], taken as ADR-346 D3 says). ADR-async-1 §6 left “an option on async that puts a child in another group” open; this is that option with one value. The word chosen is the naming round’s.

async let page = http::get(url)
let h = async(detached) fn { refresh(cache) }

Option D - one word, the lifetime inferred from use

async everywhere and no option: a child whose binding leaves its group - returned, stored, passed to a function that keeps it - becomes a free task and takes its captures; one that does not stays a child and borrows. Part I 5.4 infers a lambda’s captures from where it is handed in the same way.

fn start(cache: Shared[Cache]) -> TaskHandle[()] {
    async let h = fn { refresh(cache) }     // free, because `h` is returned
    h
}

Weighed

  D1 the special case pays D2 the compiler works D3 no cost for the common case D4 consistent with itself If it is wrong
A nobody pays more as today none partly: words agree, rules still written five times nothing to undo
B nothing new is written; h.cancel() users write task::cancel(h) as today; one rule set to check, not five none if a child keeps its lowering (polled by its group, no Send, no handle) yes for rules; five spellings for five policies low: it is text and one runtime shape; a rule can be split again
C a free task pays an option, the common child pays nothing as B as B most: one word starts a task, the option names the group moderate: spawn is built in part and used in tools/ and tests; the move is mechanical
D nobody writes anything the most: an escape analysis on the binding a child that turns free pays Send, a heap and the executor, without a word in the source saying so one word, but whether a line borrows depends on a later line high: a return h added at the bottom moves captures at the top and refuses uses (NK2101) far away; an unbound async fn { … } cannot say “detached”, so D still needs an option for that case

4. Decision

D1 - Every task is started in a group and lives at most as long as the group.

A task is whatever runs beside other code: an async form’s, a select arm, an element of a par_iter chain that runs as one, a supervisor’s child, a connection under serve. It is started in a group and ends before the group does. A child task’s group is a block (async { }, the innermost block, select) or a call (par_iter, supervisor::run, serve); a free task’s group is the process, which nobody waits for but main’s drain (Part III A.2). “A child is not a task” is withdrawn: a child task borrows, a free task takes.

D2 - One capture rule: a task borrows what outlives its group and takes what does not.

A child task borrows what was bound outside its group and takes what was bound in a block that ends before its group does (ADR-async-1 D5). For a free task every local ends before the process does, so it takes everything it uses: Part I 8.3’s move, with NK2101, is this rule where the group is the process.

D3 - Every task has a memory block and ends in one of four ways, and its end goes to the group’s receiver.

Each task allocates from a block of its own (ADR-327 D5). It ends with a value, a failure, a crash (task::Crashed) or a full block (task::OverBudget, ADR-async-1 D15), and that end reaches its group’s receiver: the wait point or the join (ADR-async-4 D11), the group’s end, the supervisor’s policy, the protocol, standard error for a free task nobody joins (ADR-346 D6). Only a crash cancels the siblings of a child task (ADR-async-1 D9, D15).

D4 - Each form states only its group’s policy.

Form Its group Policy
async (Part I 8.1.2) the enclosing async { } or block wait for all, keep every failure
par_iter (Part II 12.6) the call per element: results in input order, every failure kept (ADR-async-2 D3)
select (Part II 12.4) the block the first wins, the rest are cancelled
supervisor::run (Part II 12.8) the call restart by strategy and policy
serve (ADR-326) the server one per connection, by admission
async(detached) (Part I 8.2) the process nobody waits but main’s drain; a bound handle is the receiver

D5 - One word, async, starts every task; spawn becomes its option.

A task with a handle, one that waits for a start and one that outlives its group are options of async: async(handle), async(deferred), async(detached), written out in ADR-async-4 D4-D6. spawn fn { … } is async(detached) fn { … }; let h = spawn fn { … } is let h = async(detached) fn { … }. The option names are final (ADR-async-4 D13).

spawn stays a reserved word, and spawn fn { … } stays accepted, until the migration is built (every spawn in the repository rewritten); the commit that removes the form releases the word (ADR-298 D7) and takes it off Part I’s list.

D6 - The rules are written once.

Part I 8.1.2 states the model - group, capture, block, four ends, receiver - and every other section that starts tasks (8.2, Part II 12.4, 12.6, 12.8, the serve text) states its policy and refers to it.

The paper’s rule 4 (task::cancel for every task, h.cancel() removed) was not taken: ADR-async-4 D10 keeps h.cancel() and removes task::cancel, so a task is cancelled only through its handle.

D7 - within: on async(…) is a deadline counted from the start, on every form.

Owner’s decision, 2026-10-30, closing §7’s first point.

async(within: d) … counts d from where the form starts: on the value form, on the handle forms and on async { } alike (ADR-async-1 D6). When d runs out, the form’s tasks still running are cancelled as h.cancel() cancels them (ADR-async-4 D10), and task::TimedOut arrives where the value is first needed, or at the join of a handle; for a statement form and an async { }, at the group’s end; for a detached task nobody joins, at its receiver (D3). A limit on one wait is not the task’s option but join’s (ADR-async-4 D14).

Why. An option of async sits where the task starts and says what holds for the task; a deadline is such a fact. A limit on one wait is written where the wait is, so the two meanings no longer share one place.

The paper’s recommendation, as it was put

Option B now; C as the naming round’s question. B is what the five forms already mean, written once: the capture, failure, memory and admission rules are already one rule each (§1.3), and only the policy differs. It changes no program but h.cancel(). Whether spawn survives as a word or becomes async(detached) (C) is a matter of names, which the owner puts last, and B leaves it open without a cost. D is not recommended: the unbound case cannot be inferred at all (detached and group-bound both have no use), and for the bound case the capture mode and the run-time cost would hang on a line far from the one that starts the task. Part I 5.4’s inference is decided at the call, by the callee’s ledger fact; D’s would be decided by every later use.

The rules B would write, for the owner to accept or change (not yet Dn):

  1. A task is started in a group and does not outlive it. A child task’s group is a block (async, select) or a call (supervisor::run, serve); a free task’s is the process (spawn).
  2. A task borrows what outlives its group and takes what does not; Part I 8.3’s move and ADR-async-1 D5’s borrow are this rule’s two cases.
  3. Every task has a block, ends in one of four ways, and its end reaches the group’s receiver; each form names its receiver.
  4. task::cancel(x) cancels a pending binding or a handle and takes it; h.cancel() is removed.
  5. within: is a limit on the wait, counted from where the wait starts, on join and on an async binding alike. (The alternative, a deadline from the start on both, is what ADR-async-1 D6 says today; it would change ADR-346 D4. Recommended is the wait, because a reader sees within: where the program waits on a handle, and an async form’s options sit at its start only because its wait has no syntax of its own. The owner may prefer to keep the start for async, and then the word should differ: a naming point.)
  6. “A child is not a task” becomes “a child task borrows; a free task takes”.

Rule 5 was not taken: within: on async is a deadline from the start (D7), and join(within:) is a limit on that one wait (ADR-async-4 D14).

5. Risks, named

6. Measurements owed

Nothing below is measured; callgrind counts, release builds on both sides, LTO stated, the same output first:

  1. spawn + join of a trivial body against async let + mention of the same body, at both settings of user_parallelism, once async is built (#984): what a child saves over a free task. Today only the overlap lowering is counted (benches/overlap).
  2. If the runtime is merged (§5): per-task overhead of select, a supervisor child and a serve connection before and after.
  3. A block per task: benches/taskblock counts 10 188 instructions a task (22 allocations) with a block against 17 410 with malloc; whether select arms get blocks too (rule 3) adds that cost per arm, not yet counted.

7. What this does not decide

Answered (owner, 2026-10-30): from where within: counts (D7, ADR-async-4 D14), and the names (ADR-async-4 D13); the naming round is closed.

This site is open source. Improve this page.