Structured Concurrency Is Functional Programming for Threads
How structured concurrency applies the same insight Dijkstra used to abolish goto — and why the result looks a lot like the composable, effect-explicit model functional programmers have been building toward for decades.
The goto Problem, Again
In 1968, Dijkstra published a letter that changed how we think about code. The subject was goto. His argument wasn’t that goto produced wrong programs — it produced programs you couldn’t reason about. A goto jumps to an arbitrary label somewhere in the file. Reading the code at a call site tells you almost nothing about where control goes next. You have to hold the entire program in your head.
Structured programming replaced goto with constructs that have a defined shape: a block has one entry and one exit. An if branches and rejoins. A while loops and exits. At any point in the code, you can look at the surrounding structure and know exactly what will happen when this block finishes.
We have the same problem today, with threads.
Threads Are goto
Consider a straightforward operation: fetch a user’s profile and their recent orders in parallel, then build a dashboard.
ExecutorService executor = Executors.newCachedThreadPool();
CompletableFuture<UserProfile> profileFuture =
CompletableFuture.supplyAsync(() -> fetchProfile(userId), executor);
CompletableFuture<OrderList> ordersFuture =
CompletableFuture.supplyAsync(() -> fetchOrders(userId), executor);
CompletableFuture.allOf(profileFuture, ordersFuture).join();
return new Dashboard(profileFuture.get(), ordersFuture.get());
This looks clean. It isn’t.
If fetchOrders throws, profileFuture keeps running. There is nothing in this code that tells the profile fetch to stop when the orders fetch fails — you would have to write that yourself, carefully, every time. If the calling thread is interrupted while waiting, neither CompletableFuture is cancelled; both tasks run to completion in the background, consuming resources for a result no one will ever read.
The executor is the deepest problem. It is a global, shared pool. The tasks you submit to it can outlive any calling scope, any try/catch, any request boundary. When this method returns, you have no guarantee the tasks it spawned are finished. A task that started here can fail five seconds later and report its error to no one.
This is goto in a different costume. Control flow leaves the current scope and reappears somewhere unpredictable. You cannot read the code locally and reason about when it finishes.
The Structural Fix
Java 21 introduced StructuredTaskScope to solve exactly this. The same dashboard, rewritten:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<UserProfile> profile = scope.fork(() -> fetchProfile(userId));
Subtask<OrderList> orders = scope.fork(() -> fetchOrders(userId));
scope.join().throwIfFailed();
return new Dashboard(profile.get(), orders.get());
}
The guarantee is structural, enforced by the try-with-resources block: the scope cannot exit until all forked tasks have either completed or been cancelled. The moment scope.join() returns, you know whether both tasks succeeded. If either throws, ShutdownOnFailure cancels the remaining task before rethrowing. Neither task outlives the scope that created it.
You can also race two redundant services and take the first successful response:
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<UserProfile>()) {
scope.fork(() -> fetchProfileFromPrimary(userId));
scope.fork(() -> fetchProfileFromReplica(userId));
return scope.join().result();
}
ShutdownOnSuccess cancels the remaining task the moment either one succeeds. No shared cancellation flag, no extra logic, no race condition.
The scope is the unit of structure. Tasks start inside it, complete inside it, and cannot escape it. Every question about lifetime, cancellation, and error propagation has a structural answer you can read directly from the code.
What Functional Programmers Recognise
The parallel to functional programming is not superficial.
Pure functions don’t leak. A pure function takes inputs, produces an output, and does nothing else observable. There is no shared mutable state touched on the side, no background thread started that outlives the call. A structured scope makes the same promise about concurrent work: everything that starts inside it finishes inside it. The scope is the boundary, and nothing crosses it invisibly.
Lexical scope is the unit of composition. In FP, functions compose because each function is a self-contained transformation with clear inputs and outputs. You can pass one to another, nest them, and reason about the result by reading the structure alone. Structured scopes compose the same way. A method that uses a StructuredTaskScope internally presents a clean interface to its callers: call it, get a result, done.
// Callers see this signature. The concurrency inside is invisible to them.
Dashboard buildDashboard(String userId) throws InterruptedException, ExecutionException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<UserProfile> profile = scope.fork(() -> fetchProfile(userId));
Subtask<OrderList> orders = scope.fork(() -> fetchOrders(userId));
scope.join().throwIfFailed();
return new Dashboard(profile.get(), orders.get());
}
}
// Composes cleanly with another structured scope upstream
Page buildPage(String userId) throws InterruptedException, ExecutionException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Dashboard> dashboard = scope.fork(() -> buildDashboard(userId));
Subtask<Nav> nav = scope.fork(() -> buildNav(userId));
scope.join().throwIfFailed();
return new Page(dashboard.get(), nav.get());
}
}
Nested scopes compose like nested functions. Each level handles its own concurrent work and presents a sequential interface to the level above.
Errors propagate through the structure. Functional programmers use Result and Option types precisely because they make failure propagation explicit and compositional. A failure in one step threads through the rest without requiring every intermediate function to know about it. ShutdownOnFailure does the same thing for concurrent tasks. A failure in one task propagates to all siblings automatically. There is no error-handling logic scattered across callbacks.
Compare the two approaches:
// With CompletableFuture: you must wire error propagation manually
profileFuture.exceptionally(ex -> {
ordersFuture.cancel(true);
throw new RuntimeException(ex);
});
// With StructuredTaskScope: the policy handles it structurally
scope.join().throwIfFailed(); // that's it
Effects are declared, not hidden. In languages with effect systems, a function’s type says what effects it can have. You don’t discover them by reading the body. A structured scope makes the concurrency policy visible in the same way: the scope’s type — ShutdownOnFailure, ShutdownOnSuccess, or a custom policy — declares the failure and cancellation semantics up front. The shape of the concurrent computation is readable at the call site.
Cancellation as a First-Class Effect
Cancellation is where the analogy runs deepest, and where most concurrent code goes wrong.
In unstructured concurrency, cancellation is a polite request. You set a flag, you interrupt a thread, you call cancel() on a future — and then you hope the code on the other end is checking for it. If fetchOrders internally spawns its own background work, cancelling the outer task does not automatically reach the inner one. You write this coordination logic by hand, every time.
Structured concurrency makes cancellation a structural guarantee. When a scope is cancelled — because a task failed, because the enclosing scope was cancelled, because the caller was interrupted — the cancellation propagates inward automatically. Every task the scope owns is interrupted. Every nested scope those tasks might have opened is interrupted in turn. The cancellation follows the same tree the code follows.
Here is a concrete case: a request comes in, the user cancels it mid-flight. With a virtual-thread-per-request model and structured scopes, the interrupt propagates through every scope in the call tree automatically:
// Thread interrupt from the HTTP layer reaches here
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Inventory> inventory = scope.fork(() -> checkInventory(item));
Subtask<Price> price = scope.fork(() -> fetchPrice(item));
scope.join().throwIfFailed(); // InterruptedException surfaces here cleanly
return new ProductPage(inventory.get(), price.get());
}
// When this scope closes, both tasks are guaranteed to have stopped.
// No leaked threads, no partial state left behind.
This is the same model Elixir’s OTP has enforced at the process level for decades. A supervisor owns its children; when it terminates, its children terminate with it. The tree structure is the cancellation policy:
# Elixir: Task.async/await_many mirrors the same structured guarantee
tasks = [
Task.async(fn -> fetch_profile(user_id) end),
Task.async(fn -> fetch_orders(user_id) end)
]
# await_many/2 waits for all tasks; if the caller exits, tasks are cancelled.
[profile, orders] = Task.await_many(tasks, 5_000)
Dashboard.new(profile, orders)
Elixir arrives at the same shape from the actor model direction: process links and supervision trees mean a task’s lifetime is always bounded by something that owns it. Java’s structured scopes arrive at the same shape from the threading model direction. The underlying idea is identical — a computation’s lifetime must be visible in the structure of the code that creates it.
The Unifying Insight
Structured programming’s insight was that control flow should be visible in the structure of the code. The while loop tells you: execution enters here, iterates, and exits here. You can reason locally.
Structured concurrency applies the same insight to concurrent lifetimes. A StructuredTaskScope tells you: concurrent work starts here, and the scope does not exit until all of it has finished or been cancelled. You can reason locally.
Functional programming’s central concern has always been the same thing approached from a different direction: make effects explicit, make composition safe, eliminate hidden state. Pure functions, monadic error propagation, algebraic effects — all of them are strategies for making the full behaviour of a computation visible in its type or structure.
Structured concurrency is not a functional programming concept by origin. Nathaniel Smith’s 2018 essay that popularised the idea barely mentions FP. But the idea it encodes — concurrent work should be lexically scoped, compositional, and effect-explicit — is exactly what functional programmers have been building toward for decades, with different tools and different vocabulary.
When you write a StructuredTaskScope in Java, you are not just preventing resource leaks. You are making concurrent code that composes the way functions compose. That is not a coincidence. It is what structure, applied consistently, always produces.
Takeaway
Unstructured threads are goto. They escape their calling scope, make failure propagation someone else’s problem, and turn cancellation into an exercise in manual coordination. Structured concurrency closes those gaps with a single insight borrowed from 1968: give concurrent work a lexical boundary, enforce it, and let the structure carry the semantics.
The result is concurrent code that looks like functional code: self-contained, composable, with effects visible in the shape of the program rather than hidden in implementation details. If you have ever reached for a Result type to make failure explicit, or used a pure function to keep side effects out of your logic, then you already understand why structured concurrency is the right model. It is the same discipline, applied to time instead of values.
Java 21’s StructuredTaskScope is the practical starting point. The ideas behind it go much further.