Contents
- The Problem with Unstructured Concurrency
- ShutdownOnFailure — All Must Succeed
- ShutdownOnSuccess — First to Succeed Wins
- Error Propagation and Cancellation
- Comparison with CompletableFuture
With CompletableFuture or ExecutorService, it is easy to launch subtasks that outlive the code that launched them — leading to thread leaks, difficult error handling, and partial results.
// Unstructured — what happens if fetchUser fails?
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture<Order> orderFuture = CompletableFuture.supplyAsync(() -> fetchOrder(id));
// If fetchUser throws, fetchOrder keeps running — wasting resources
// If we cancel one, the other is not automatically cancelled
// Error handling requires careful chaining — easy to miss exceptions
User user = userFuture.get();
Order order = orderFuture.get();
Structured concurrency solves this: the scope owns all subtasks. When the scope closes (by either success, failure, or explicit shutdown), all outstanding subtasks are cancelled automatically.
ShutdownOnFailure cancels all remaining tasks as soon as one fails. When join() returns, either all tasks succeeded or an exception is propagated.
record UserProfile(User user, List<Order> orders, List<Address> addresses) {}
UserProfile fetchProfile(long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork subtasks — each runs on a virtual thread
StructuredTaskScope.Subtask<User> userTask = scope.fork(() -> fetchUser(userId));
StructuredTaskScope.Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));
StructuredTaskScope.Subtask<List<Address>> addressTask = scope.fork(() -> fetchAddresses(userId));
// Wait for all subtasks — or for any to fail
scope.join()
.throwIfFailed(); // re-throws the first failure as an exception
// All succeeded — safe to call result()
return new UserProfile(
userTask.get(),
ordersTask.get(),
addressTask.get()
);
}
// On scope close: any still-running subtasks are cancelled automatically
}
All three subtasks run concurrently on virtual threads. If fetchOrders throws, the scope immediately cancels fetchUser and fetchAddresses, then throwIfFailed() re-throws the exception.
ShutdownOnSuccess cancels all remaining tasks as soon as one succeeds. This is ideal for racing multiple equivalent sources and using whichever responds first.
String fetchFromFastest(String key) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
// Race primary vs replica database
scope.fork(() -> primaryDb.get(key));
scope.fork(() -> replicaDb.get(key));
scope.fork(() -> cache.get(key)); // also try cache
scope.join(); // waits until first succeeds (or all fail)
return scope.result(); // returns the first successful result
}
}
// Another example — try multiple geocoding services
Coordinate geocode(String address) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Coordinate>()) {
scope.fork(() -> googleMaps.geocode(address));
scope.fork(() -> openStreetMap.geocode(address));
scope.fork(() -> hereApi.geocode(address));
scope.join();
return scope.result();
}
}
StructuredTaskScope.ShutdownOnFailure cancels all remaining subtasks as soon as any one of them throws an exception. Calling scope.throwIfFailed() after join() re-throws that first exception, making error propagation explicit and predictable. ShutdownOnSuccess does the opposite: it cancels remaining tasks as soon as the first subtask completes successfully. Both policies eliminate the "fire and forget" problem common with CompletableFuture, where a failing subtask can go undetected if its exception is not explicitly chained. The scope's lifetime guarantees that by the time the try-with-resources block exits, all subtasks have either completed or been interrupted.
// Subtask states after scope.join():
// UNAVAILABLE — not started
// SUCCESS — completed successfully (call .get() to retrieve result)
// FAILED — threw an exception (call .exception() to get it)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var task1 = scope.fork(() -> step1());
var task2 = scope.fork(() -> step2());
var task3 = scope.fork(() -> step3());
scope.join().throwIfFailed(e -> new RuntimeException("Pipeline failed", e));
// Inspect individual results
System.out.println("Step1: " + task1.get());
System.out.println("Step2: " + task2.get());
System.out.println("Step3: " + task3.get());
}
// Manual cancellation — scope.shutdown() can be called early
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var task = scope.fork(() -> longRunningOp());
// Timeout: cancel after 2 seconds
scope.joinUntil(Instant.now().plusSeconds(2));
if (task.state() != StructuredTaskScope.Subtask.State.SUCCESS) {
throw new TimeoutException("Operation timed out");
}
return task.get();
}
Structured concurrency requires enabling preview features in Java 21 (--enable-preview). It is expected to be finalized in a later Java release. Check the current Java version's status before using in production.
- Lifecycle: StructuredTaskScope scope is bounded — all tasks complete or are cancelled by the time the try-with-resources block exits. CompletableFuture tasks can outlive the code that created them.
- Cancellation: Structured concurrency cancels remaining subtasks automatically. With CompletableFuture you must manually cancel each.
- Error handling: throwIfFailed() is simple and predictable. CompletableFuture error chaining (exceptionally, handle) is complex.
- Observability: Because structured tasks are scoped, debuggers and profilers can show the complete subtask tree.
- Use CompletableFuture when: you need complex async pipelines across method boundaries, or you cannot use preview features.
- Use StructuredTaskScope when: you fork-and-join within a single method and want clean lifecycle management.
// Clean pattern: parallel fetch with structured concurrency
record PageData(String content, List<String> links, Map<String, String> metadata) {}
PageData scrapePage(URL url) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var content = scope.fork(() -> fetchContent(url));
var links = scope.fork(() -> extractLinks(url));
var metadata = scope.fork(() -> fetchMetadata(url));
scope.join().throwIfFailed();
return new PageData(content.get(), links.get(), metadata.get());
}
}