Programming Languages · Concurrency Models

Structured Concurrency and the Task Scope

Why a spawned task should not outlive its parent, how a scope joins on exit and cancels siblings on failure, and what the nesting rule buys over detached threads.

The standard spawn primitive is a statement, not an expression: it starts a task and returns immediately, discarding the handle. The parent’s remaining code has no record that the task exists, so nothing forces the parent to wait for it, observe its failure, or stop it. Every resulting problem, from leaked tasks to errors that vanish to cancellation arriving after the work was abandoned, traces to that one design choice.

Structured concurrency is the decision to make the lifetime of a task a lexical property. A task is created inside a scope, the scope cannot exit until its tasks finish, and the parent’s control flow surrounds the child’s. The constraint is the same one that already governs functions and blocks: a construct that begins must end before its enclosing construct does.

The scope as a region

A scope has three obligations on exit: wait for its tasks, propagate their failures, and cancel whatever is still running if it is leaving early. These are not independent features; they are one invariant seen from three angles.

Waiting is the join. Since the scope’s exit is a synchronisation point, all writes performed by the tasks are visible to the code after the scope, a happens-before edge generated by the join itself, as described in Happens-Before and the Memory Model Contract. The parent does not need to reason about what the children might still be doing, because by construction they are not doing anything.

Failure propagation means a task’s exception is not swallowed. In the simplest form, the first failure cancels the rest and is re-raised from the scope’s exit, so the caller sees one exception rather than a set of partial results. This is deliberate: if one of three parallel fetches failed, returning the other two with a warning invites the caller to proceed on incomplete data. The alternative policy of collecting all outcomes and letting the caller decide is available in the structured APIs as a variant, because sometimes partial success is exactly what is wanted.

Cancellation on exit is the part that makes the invariant enforceable. If the parent returns early, or throws, or the scope is exited for any reason, the remaining tasks must be stopped. Without it, “the scope waits for its children” would mean a scope could hang forever on a task whose result nobody needs.

import anyio
from pathlib import Path

# AnyIO's task group: exit blocks until children finish or are cancelled.
async def collect(path, out):
    data = await anyio.to_thread.run_sync(Path(path).read_bytes)
    out.append(data)

async def read_all(paths):
    results = []
    async with anyio.create_task_group() as tg:
        for p in paths:
            tg.start_soon(collect, p, results)
    return results  # only reachable after every child has finished

The async with is doing the work: it is the syntactic marker of the region, and the runtime’s exit handler is what joins and cancels. A language without such a construct can still implement the discipline by hand, but the compiler cannot check that the discipline was followed.

Why nesting is the whole point

The interesting property is not that a parent waits for a child. Threads can do that with a join. The property is that the graph of task lifetimes is a tree that mirrors the call graph. A task started by a function is a descendant of that function’s caller, all the way up to main. Consequently:

  • Every task has a single, statically identifiable owner, so there is no ambiguity about who is responsible for a failure.
  • Cancellation flows down the tree and never up. A parent can cancel its children; a child cannot cancel its parent, and a child’s failure does not silently abort unrelated work in a sibling branch.
  • Every live task is reachable from the root through the chain of enclosing scopes, so a task that fails to terminate is attributable to the function that opened its scope rather than an emergent property of the whole system.

By contrast, a detached thread is a root of its own tree. Its parent may be gone, its failure has no destination, and its cancellation must be delivered through a channel someone else had to remember to create. A runtime that mixes both styles gets the tree property only for the parts that use scopes, and a single detached task can hold a resource far past the point where the code that created it believed it was finished.

Timeouts and the shape of partial results

A scope with a deadline is the natural encoding of “do these in parallel, but not for longer than t”. The timeout is applied to the scope, not to each task, so the deadline covers the whole region including the join. When it fires, the scope cancels the tasks and exits with a timeout error, and any result that a completed task had produced is discarded unless the scope’s policy says otherwise.

This is a policy that has to be chosen explicitly, and the choice determines the API. Java’s StructuredTaskScope, a preview API in Java 21, makes it a subclass decision: the shutdown policy decides whether the scope waits for all subtasks, waits for the first to succeed, or waits for the first to fail. The first-success policy is the one that implements “race these mirrors and take the fastest”, and it necessarily cancels the losers, which means the tasks in such a scope must be cancel-safe, since they will be interrupted at an arbitrary point. That constraint is the subject of Cooperative Cancellation and the Cancel-Safety Contract.

A subtler problem is that partial results must not escape the scope. If a task appends to a list captured from the parent, and the scope exits by timeout, the list holds whatever was appended before cancellation, and its length depends on scheduling. A scope that returns values rather than mutating captured state forces the policy decision to be visible: either the result is complete or the scope failed.

What structured concurrency does not give

It does not give parallelism by itself. A scope whose tasks are all CPU-bound and whose runtime has one worker thread will run them one after another; the construct orders lifetimes, it does not schedule. Concurrency and parallelism remain separate concerns, and the scheduler behind them is described in Multiplexing Green Threads onto Kernel Threads.

It does not give fairness or priority. A scope’s children are equal from the runtime’s point of view, and a scope that starts a thousand tasks will still queue them.

It does not prevent deadlock between scopes. Two scopes that each wait for a resource held by the other will deadlock exactly as two threads would, because the wait-for graph is over resources, not over lexical structure. What the tree buys is that the deadlock is bounded: the cycle must involve a finite set of scopes whose nesting is visible in the source, rather than an unbounded set of detached threads.

And it does not solve the problem of a task that must outlive its parent, such as a background index rebuilder or a connection pool’s idle reaper. Those are genuinely long-lived, and a language with structured concurrency must provide an explicit escape hatch for them, usually by attaching them to a scope that lives as long as the process. The discipline is not “never detach” but “detach only where the lifetime is stated in the type or the API”, which is a much narrower permission than a bare spawn statement grants.

The net effect is that a whole class of failure becomes unrepresentable rather than merely discouraged. A task cannot outlive its parent because the scope’s exit is a join; an error cannot vanish because the join propagates it; a resource cannot be held past the region because the region’s exit cancels. The runtime does not need to detect these conditions because the program cannot express them.

References

  1. Structured Concurrency — Martin Sústrik, 2016
  2. Notes on structured concurrency, or: Go statement considered harmful — Nathaniel J. Smith, 2018
  3. Java 21 API Documentation: StructuredTaskScope — Oracle