Structured Concurrency
Manage groups of concurrent tasks with StructuredTaskScope for reliable cancellation and error propagation in Java 21+.
Overview
Key Points
- •StructuredTaskScope<T> manages a group of forked subtasks
- •scope.fork(callable) submits a subtask and returns a Subtask<T>
- •scope.join() waits for all subtasks or until a shutdown condition is met
- •ShutdownOnFailure shuts down the scope if any subtask throws, then throwIfFailed() re-throws
- •ShutdownOnSuccess shuts down as soon as any subtask returns successfully; result() retrieves the winner
- •All forked subtasks are guaranteed to finish before the scope exits (even if cancelled)
- •Subtask.get() retrieves the result; throws if the subtask failed or was cancelled
- •Structured concurrency composes naturally with virtual threads
- •Replaces error-prone CompletableFuture.allOf / anyOf patterns with explicit lifecycle management
Examples
ShutdownOnFailure: all tasks must succeed
import java.util.concurrent.*;
public class Main {
static String fetchUser(int id) throws InterruptedException {
Thread.sleep(50);
return "User#" + id;
}
static String fetchOrders(int userId) throws InterruptedException {
Thread.sleep(80);
return "Orders for " + userId;
}
public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
StructuredTaskScope.Subtask<String> user = scope.fork(() -> fetchUser(42));
StructuredTaskScope.Subtask<String> orders = scope.fork(() -> fetchOrders(42));
scope.join(); // wait for both
scope.throwIfFailed(); // re-throw if any failed
System.out.println(user.get());
System.out.println(orders.get());
}
}
}Both tasks run in parallel. If either throws, throwIfFailed() cancels the other and propagates the exception. Without structured concurrency, you would need CompletableFuture.allOf() with manual exception unwrapping.
ShutdownOnSuccess: first successful result wins
import java.util.concurrent.*;
public class Main {
static String fetchFromPrimary() throws InterruptedException {
Thread.sleep(200); // slower
return "result from primary";
}
static String fetchFromFallback() throws InterruptedException {
Thread.sleep(50); // faster
return "result from fallback";
}
public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(Main::fetchFromPrimary);
scope.fork(Main::fetchFromFallback);
scope.join(); // returns when first succeeds or both fail
String result = scope.result(); // the winner's result
System.out.println("Got: " + result); // result from fallback
}
}
}ShutdownOnSuccess is perfect for hedged requests: send the same request to multiple servers and use the first successful response. The slower task is cancelled automatically when the faster one returns.
Nested scopes and composition
import java.util.concurrent.*;
public class Main {
record UserProfile(String name, String preferences) {}
static String fetchName(int id) throws InterruptedException {
Thread.sleep(40);
return "Alice";
}
static String fetchPreferences(int id) throws InterruptedException {
Thread.sleep(60);
return "dark-mode, notifications-on";
}
static UserProfile fetchProfile(int id) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var name = scope.fork(() -> fetchName(id));
var prefs = scope.fork(() -> fetchPreferences(id));
scope.join().throwIfFailed();
return new UserProfile(name.get(), prefs.get());
}
}
public static void main(String[] args) throws Exception {
UserProfile profile = fetchProfile(1);
System.out.println("Name: " + profile.name());
System.out.println("Prefs: " + profile.preferences());
}
}Nested scopes compose cleanly. fetchProfile opens its own scope, forking two tasks. If the caller also runs in a scope, failures still propagate correctly through the hierarchy. Each scope is a self-contained unit of work.
Common Mistakes
- Calling subtask.get() before scope.join(). Subtask.get() throws IllegalStateException if the task has not yet completed. Always call scope.join() (and throwIfFailed() if using ShutdownOnFailure) before accessing results.
- Not using throwIfFailed() with ShutdownOnFailure. join() returns even if tasks failed. Without throwIfFailed(), you silently get cancelled subtasks and calling get() on them throws.
- Treating StructuredTaskScope like an ExecutorService and submitting unbounded work without join(). The scope is designed for bounded task groups. For unbounded streaming work, use ExecutorService.
Note
Structured concurrency pairs perfectly with virtual threads (Executors.newVirtualThreadPerTaskExecutor is the default executor for StructuredTaskScope). You get both the high concurrency of virtual threads and the structured lifetime management of scoped tasks.
Exercises
Write Java code and click Run to execute it instantly. When your output looks correct, mark as done.
Completed: 0 / 2 exercises
Knowledge Check
Question 1 of 4