Structured Concurrency and Task Lifecycle

Unstructured concurrency is like a manager who hands out tasks and walks away: nobody knows who is still working, failures go unnoticed, and when the project is cancelled, some people keep working for weeks.

Learning Objectives

Define structured concurrency; model parent-child task relationships, task scopes and joining.

Propagate failure and cancellation; use task groups and supervisor scopes; choose fail-fast or collect-all behavior; nest scopes; apply scope exit rules.

Prevent orphaned tasks; align resource lifetime with task lifetime; write cancellation-safe code; shield non-cancellable sections; use timeouts as scopes; shut down gracefully.

Apply the idea in Kotlin, Python (TaskGroup), Java (StructuredTaskScope, preview), .NET, Go and Rust, and review designs.

Plain-Language Explanation

Unstructured concurrency is like a manager who hands out tasks and walks away: nobody knows who is still working, failures go unnoticed, and when the project is cancelled, some people keep working for weeks. Structured concurrency says: every task belongs to a scope, and a scope does not end until all its tasks have ended. If one task fails, the scope cancels its siblings and reports the failure. Concurrency then nests like ordinary code blocks — what starts inside a block finishes inside it.

Precise Technical Explanation

What structured concurrency means The lifetime of every concurrent task is bound to a lexical scope: child tasks are started inside a scope, and the scope's exit waits for all of them. Consequences: no orphaned tasks, errors always have a place to propagate to, cancellation flows down the tree, and stack traces/debuggers show a task tree that mirrors the code.

Parent-child task relationships Tasks form a tree. A parent cannot complete before its children; cancelling a parent cancels its children; a child's failure is reported to its parent.

asyncio.TaskGroup, Trio nurseries, Kotlin coroutineScope, Java StructuredTaskScope, Swift withTaskGroup, std::thread::scope (Rust) and errgroup.Group (Go, conventionally).

Joining child tasks The scope's exit point joins all children (async with exit, scope.join(), end of coroutineScope).

By default the first failure cancels the remaining children and is rethrown from the scope — several failures are combined (ExceptionGroup in Python 3.11+, suppressed exceptions in Java).

Cancelling the parent scope cancels every child (and grandchild). Children observe it cooperatively at their next cancellation point.

A dynamic set of children within one scope (spawn as many as needed, all joined at the end).

Supervisor scopes and supervision A supervisor scope does *not* cancel siblings when one child fails; it lets the parent decide (restart, ignore, escalate) — Kotlin supervisorScope/SupervisorJob, Erlang/Akka supervisors (Module 26).

Fail-fast versus collect-all behavior Fail-fast: cancel everything on first error (good when the combined result is useless without all parts). Collect-all: let every child finish and gather successes and failures (good for batch jobs) — gather(return_exceptions=True), Promise.allSettled, supervisor scopes.

Scopes nest: a child can open its own scope; failures and cancellations propagate up and down the nesting exactly like exceptions through nested blocks.

Scope exit rules On exit a scope (1) waits for all children; (2) if any failed, cancels the others and raises the failure(s); (3) if the parent was cancelled, cancels children and propagates cancellation.

Preventing orphaned tasks Never start tasks that outlive their scope (asyncio.create_task without a group, go without a WaitGroup/errgroup, Task.Run fire-and-forget, GlobalScope.launch). If something must outlive a request, hand it to an explicit long-lived service scope.

Resource lifetime and task lifetime Because children finish before the scope ends, resources opened in the scope (connections, files) can be closed safely at scope exit — unstructured tasks would still be using them.

Code that leaves invariants intact if cancelled at any suspension point: acquire/release in try/finally, avoid splitting an atomic update across an await, re-raise cancellation.

Shielding and non-cancellable sections Some cleanup must complete even when cancelled (committing or rolling back a transaction): asyncio.shield, Kotlin withContext(NonCancellable), Trio CancelScope(shield=True). Use sparingly and with timeouts.

Timeouts as scopes asyncio.timeout, Trio move_on_after, Kotlin withTimeout: a deadline is a cancel scope that fires automatically.

Cancel the root scope, let children run their cleanup, wait (with a deadline) for the tree to drain, then exit.

Structured concurrency in Kotlin The default design: coroutineScope { launch {…}; async {…} } waits for children; failure cancels siblings and the scope; supervisorScope isolates failures; viewModelScope/lifecycleScope tie coroutines to Android component lifetimes.

Python TaskGroup and version caveats asyncio.TaskGroup (Python 3.11+) is fail-fast and raises ExceptionGroup/BaseExceptionGroup; handle with except*. Earlier versions need Trio/anyio nurseries or manual gather + cancellation.

Java structured concurrency status/version caveats StructuredTaskScope is a preview API in Java 21 (JEP 453), revised in later releases (its API changed again in Java 25) — it needs --enable-preview and its shape may change. Virtual threads (final in Java 21) make it cheap to fork one thread per subtask. Example below runs on JDK 21 with --enable-preview..NET task ownership patterns

No built-in structured scope: pass a linked CancellationTokenSource to children, await Task.WhenAll(children) before leaving the method, cancel the source on first failure; IHostedService for long-lived work.

Go goroutine lifecycle patterns context.Context carries cancellation and deadlines down the call tree; sync.WaitGroup or errgroup.Group (golang.org/x/sync) joins goroutines; the function that starts a goroutine should ensure it ends (demonstrated below without external packages).

Rust async task ownership patterns std::thread::scope (scoped threads, Rust 1.63) for threads; for async, tokio::task::JoinSet (aborts tasks when dropped), tokio::select!, and dropping a future cancels it; tokio::spawn is unstructured ('static tasks).

Structured concurrency design review Ask: who owns each task? Where is it joined? What happens to siblings on failure? How does cancellation reach it? Are resources closed only after all users finished? Is any task allowed to outlive its creator — and why?

Why It Matters in Real Systems

Leaked background tasks, swallowed errors, and requests that keep running after the client disconnected are the everyday bugs of unstructured concurrency. Structured concurrency removes them by construction and makes cancellation and timeouts compose.

Key Terms

Task lifetimes bounded by lexical scopes Task scope / group / nursery

Container that owns and joins child tasks First failure cancels siblings

Scope where failures do not cancel siblings Task with no owner that will join it Protecting a section from cancellation

Several exceptions raised together (Python 3.11+)

Diagram or Execution Timeline

structured

Syntax and API Overview

async with asyncio.TaskGroup() as tg:      # Python 3.11+
    tg.create_task(child_a())
    tg.create_task(child_b())
# here both children have finished (or the group raised)

Complete Code Example

Fail-fast propagation, sibling cancellation with cleanup, ExceptionGroup and except*, nested scopes, shielding and a timeout scope (CPython 3.13.1):

Java 21's StructuredTaskScope (preview API, run with --enable-preview --source 21) forking subtasks onto virtual threads:

A structured shape in Go without external packages — context cancellation plus a WaitGroup that the starting function always waits for (Go 1.23.4):

import asyncio

log = []


async def child(name, seconds, fail=False):
    try:
        await asyncio.sleep(seconds)
        if fail:
            raise ValueError(f"{name} failed")
        log.append(f"{name} finished")
        return name
    except asyncio.CancelledError:
        log.append(f"{name} cancelled")
        raise
    finally:
        log.append(f"{name} cleanup")


async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(child("fetch-a", 0.05))
            tg.create_task(child("fetch-b", 0.10, fail=True))
            tg.create_task(child("fetch-c", 1.00))
    except* ValueError as group:
        log.append(f"scope raised ExceptionGroup with {[str(e) for e in group.exceptions]}")
    print("fail-fast:", log)

    log.clear()
    try:
        async with asyncio.TaskGroup() as outer:
            async def subtree():
                async with asyncio.TaskGroup() as inner:          # nested scope
                    inner.create_task(child("inner-1", 1.0))
                    inner.create_task(child("inner-2", 1.0))
            outer.create_task(subtree())
            outer.create_task(child("outer-x", 0.05, fail=True))
    excep
…
fail-fast: ['fetch-a finished', 'fetch-a cleanup', 'fetch-b cleanup', 'fetch-c cancelled', 'fetch-c cleanup', "scope raised ExceptionGroup with ['fetch-b failed']"]
nested cancellation: ['inner-1 cancelled', 'inner-1 cleanup', 'inner-2 cancelled', 'inner-2 cleanup', 'outer-x cleanup']
shield: ['caller timed out while waiting', 'commit completed despite the timeout']
import java.util.concurrent.StructuredTaskScope;
import java.util.function.Supplier;

public class Scope {
    static String fetch(String what, long ms, boolean fail) throws Exception {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            System.out.println("  " + what + " interrupted (cancelled by the scope)");
            throw e;
        }
        if (fail) throw new IllegalStateException(what + " failed");
        return what;
    }

    public static void main(String[] args) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {      // fail-fast policy
            Supplier<String> user = scope.fork(() -> fetch("user", 50, false));
            Supplier<String> orders = scope.fork(() -> fetch("orders", 60, false));
            scope.join().throwIfFailed();                                    // wait for both
            System.out.println("both succeeded: " + user.get() + " + " + orders.get());
        }
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            scope.fork(() -> fetch("profile", 2000, false));
            scope.fork(() -> fetch("payments", 50, true));
     
…
both succeeded: user + orders
  profile interrupted (cancelled by the scope)
scope failed fast: payments failed
after the scope: no subtask is still running
Note: Scope.java uses preview features of Java SE 21.
Note: Recompile with -Xlint:preview for details.
package main

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"sync"
	"time"
)

func fetch(ctx context.Context, name string, d time.Duration, fail bool) error {
	select {
	case <-time.After(d):
		if fail {
			return errors.New(name + " failed")
		}
		return nil
	case <-ctx.Done():
		return fmt.Errorf("%s cancelled: %w", name, ctx.Err())
	}
}

// group runs tasks; the first error cancels the others; it returns only when all have ended.
func group(parent context.Context, tasks map[string]func(context.Context) error) (first error, all []string) {
	ctx, cancel := context.WithCancel(parent)
	defer cancel()
	var wg sync.WaitGroup
	var mu sync.Mutex
	for name, t := range tasks {
		wg.Add(1)
		go func(name string, t func(context.Context) error) {
			defer wg.Done()
			err := t(ctx)
			mu.Lock()
			defer mu.Unlock()
			if err != nil {
				all = append(all, err.Error())
				if first == nil {
					first = err
					cancel() // fail fast: cancel siblings
				}
			} else {
				all = append(all, name+" ok")
			}
		}(name, t)
	}
	wg.Wait() // no goroutine outlives the group
	sort.Strings(all)
	return first, all
}

func main() {
	first, all := group(context.Background(), map[string]func(context.Conte
…
first error: b failed
all outcomes: [a ok b failed c cancelled: context canceled]

Line-by-Line Explanation

Python fail-fast: fetch-a finishes, fetch-b raises; the group cancels fetch-c (its except CancelledError and finally run), waits for it, then raises an ExceptionGroup containing the ValueError, matched by except*.

Nested: outer-x fails, so the outer group cancels subtree, whose inner group cancels inner-1 and inner-2 — cancellation flows down the whole tree.

Shield: the timeout cancels the *caller's* wait, but the shielded commit keeps running and completes.

Java: ShutdownOnFailure forks subtasks on virtual threads; when payments fails, the scope shuts down — profile is interrupted — and throwIfFailed rethrows the failure wrapped in ExecutionException. Leaving the try block guarantees all subtasks have finished.

Go: each task receives the group's context; the first error cancels it; wg.Wait() guarantees nothing outlives the group.

Output

Shown under each program. The Python logs are in a deterministic order except where sorted; the Java interruption message and the Go outcomes are deterministic given the chosen delays.

Interleaving or State Trace

Fail-fast group timeline (Python):

CancelledError at its await → cleanup

Correctness and Safety Analysis

With scopes, every task has an owner that joins it, every failure surfaces at the owner, and resources opened in the scope outlive every task that uses them. The remaining responsibility is cancellation safety inside each task: release resources in finally and do not swallow cancellation.

Common Mistakes and Misconceptions

Catching CancelledError/InterruptedException and continuing (breaks scope cancellation).

Starting tasks outside the group "just this once" (orphans).

Using shield broadly, making shutdown hang.

Expecting Java's StructuredTaskScope API to be stable in Java 21 (it is preview).

Thinking gather is structured: by default an error in one awaitable does *not* cancel the others.

Failure Modes and Debugging Steps

Scope never exits: a child ignores cancellation (CPU loop without suspension, swallowed cancellation) — add cancellation points, inspect with task dumps.

Errors appear as ExceptionGroup unexpectedly: handle with except* or unwrap .exceptions.

Leaked goroutines: a function returned without waiting — every go needs a matching join.

Performance and Scalability Implications

Structured scopes add negligible overhead; their benefit is fewer leaked tasks (memory) and faster cancellation (less wasted work after failures or timeouts).

Security Considerations

Requests that keep running after their client left consume resources an attacker can multiply; scoped cancellation tied to the request lifetime prevents this "abandoned work" amplification.

Platform and Runtime Caveats

Python TaskGroup/except*: 3.11+. Trio and anyio offer nurseries for older versions.

Java StructuredTaskScope: preview in 21–24, API redesigned in 25 (StructuredTaskScope.open(...) with Joiners); check the exact JDK version.

Kotlin structured concurrency is stable; Swift task groups need Swift 5.5+.

Go's errgroup lives in golang.org/x/sync, not the standard library.

Real-World Applications

Fan-out request handlers (fetch user, orders and recommendations concurrently; cancel the rest if one fails), scatter-gather search, Android screens whose coroutines end with the screen, batch jobs with collect-all reporting.

Best Practices

Start every concurrent task inside a scope that joins it.

Choose fail-fast or collect-all explicitly.

Propagate cancellation; never swallow it; clean up in finally.

Use timeouts as scopes around external calls.

Shield only short, essential cleanup — with its own deadline.

Exercises

E1 · Beginner. In the Python fail-fast example, why does fetch-c print "cancelled" and "cleanup" even though it never finished sleeping?

Answer: The group cancelled it when fetch-b failed; cancellation raised CancelledError at its await, so its except and finally blocks ran before it ended.

E2 · Intermediate. Rewrite await asyncio.gather(a(), b(), c()) so that a failure in any one cancels the others.

Answer: Use async with asyncio.TaskGroup() as tg: and create the three tasks in it (Python 3.11+); or, on older versions, catch the first exception from asyncio.wait(..., return_when=FIRST_EXCEPTION) and cancel the pending tasks.

E3 · Advanced challenge. Design a "collect-all with deadline" scope: run N tasks, return all results available after 2 s, cancel the rest, and report which timed out.

Answer: Start the tasks in a group-like structure; done, pending = await asyncio.wait(tasks, timeout=2); cancel each pending task and await asyncio.gather(*pending, return_exceptions=True) so their cleanup completes; return results of done (inspecting exceptions) and the names in pending. The function still joins every task before returning — structured.

Quiz

? What does a structured scope guarantee when it exits normally?
+ All child tasks it started have finished. :: Children never outlive the scope.
- All child tasks succeeded. :: A failure would make it raise instead.
- No task anywhere in the program is running. :: Only its own children.
- Child tasks ran in parallel. :: Parallelism is not implied.

? What is a supervisor scope?
+ A scope where one child's failure does not cancel its siblings. :: The parent decides what to do.
- A scope that restarts the JVM. :: Unrelated.
- A scope with higher thread priority. :: Unrelated.
- A scope that ignores cancellation. :: That is shielding.

? Which create orphaned tasks? (select all that apply)
+ asyncio.create_task without awaiting or grouping it :: Nobody owns it.
+ Go's `go f()` with no WaitGroup or errgroup :: Nothing waits for it.
- Tasks created inside an asyncio.TaskGroup :: The group joins them.
+ Kotlin GlobalScope.launch :: Not tied to any lifecycle.

Summary

Structured concurrency binds task lifetimes to scopes: children finish before their scope, failures propagate to the parent (fail-fast or collect-all), cancellation flows down the tree, and resources outlive their users. Python TaskGroup, Kotlin scopes, Java's preview StructuredTaskScope, Go context + WaitGroup, and Rust scoped threads express the same idea with different maturity.

Further Reading

Nathaniel J. Smith, "Notes on structured concurrency, or: Go statement considered harmful" (2018).

JEP 453 (Structured Concurrency, Preview) and its successors.

Python docs: "Task Groups", "ExceptionGroup".

Kotlin docs: "Coroutine context and dispatchers", "Exceptions and cancellation".