Skip to article
ALGORITHMICSConcurrency
Concurrency6 min read

Structured Concurrency

Tasks that cannot outlive the block that started them — goto, but for threads.


goto let control jump anywhere, and structured programming replaced it with blocks: control enters at the top and leaves at the bottom, so you can reason about a function by reading it.

Starting a task is goto all over again. Control leaves and never comes back to a place you can see.

fetchUser(); // when does this finish? who is listening?
fetchOrders(); // what if it throws?
return; // …both are still running

Three specific failures

Errors vanish. Nobody awaited either call, so a rejection has no handler. Node logs an unhandled rejection and, since v15, exits the process.

Cancellation does not propagate. The caller gives up, and the tasks keep running — holding connections, writing to a closed response, spending money.

Lifetime is invisible. Reading the function tells you nothing about what is still alive when it returns.

The rule

A task may not outlive the block that started it.

Launching two tasks
fetchUser();      // no handle, no await
fetchOrders();    // no handle, no await
return;           // …and these are still running
  • swallowed — an unhandled rejection at best
  • orphans keep running after you return
  • you cannot tell from reading it

Two tasks with no owner. If one throws, nobody hears; if the caller returns, they carry on using resources it thinks it released. This is the concurrency equivalent of goto.

Control cannot pass the closing brace while a child is running. If one child fails, the others are cancelled. Concurrency now nests like every other control structure.

What it looks like

// Kotlin
coroutineScope {
val user = async { fetchUser() }
val orders = async { fetchOrders() }
render(user.await(), orders.await())
} // ← nothing escapes this brace
// Java 21+
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser());
var orders = scope.fork(() -> fetchOrders());
scope.join().throwIfFailed();
render(user.get(), orders.get());
}
// Python 3.11+
async with asyncio.TaskGroup() as tg:
user = tg.create_task(fetch_user())
orders = tg.create_task(fetch_orders())

Also Swift’s withTaskGroup, Trio’s nurseries (which originated the idea), and Go’s errgroup — the closest Go gets, since bare go is unstructured by design.

Cancellation becomes tractable

Because the scope owns its children, cancelling is well-defined: cancel the scope, and every descendant is cancelled with it. Cancellation follows the tree.

That is also what makes timeouts composable — a timeout is a scope that cancels itself, and everything beneath it goes too, however deep.

In plain JavaScript

There is no TaskGroup yet, but Promise.all is structured concurrency for the happy path, and AbortController supplies the cancellation:

const controller = new AbortController();
try {
const [user, orders] = await Promise.all([
fetchUser({signal: controller.signal}),
fetchOrders({signal: controller.signal}),
]);
} finally {
controller.abort(); // whatever is still running, stop it
}

Note the gap Promise.all leaves: it rejects on the first failure but does not cancel the others. The finally is what closes it, and forgetting it is the most common way JavaScript code leaks orphaned requests.