Structured Concurrency

For decades, concurrent programming in Java was unstructured. When you spawned a thread (or submitted a task to an ExecutorService), it was “fire and forget.” The new thread had no link to its parent.

If the parent thread crashed, the child threads kept running (orphans). If a child thread failed, the exception was often swallowed or lost in a log file, never reaching the parent. This led to resource leaks and debugging nightmares.

Structured Concurrency changes the game. Its core principle is derived from structured programming:

“If a task splits into concurrent subtasks, they must all complete before the task itself can complete.”

Just as a method doesn’t return until its if/else blocks and for loops are done, a structured concurrent task doesn’t return until its sub-threads are done.

1. The Philosophy: Code Structure = Runtime Structure

In structured programming:

void main() {
    blockA();
    blockB();
}
// main() cannot exit until blockA and blockB are done.

In structured concurrency:

void main() {
    try (var scope = new StructuredTaskScope<>()) {
        scope.fork(() -> taskA());
        scope.fork(() -> taskB());
        scope.join();
    }
}
// main() cannot exit until taskA and taskB are done (or cancelled).

The syntactic structure of your code (the try-with-resources block) limits the lifetime of the threads. This guarantees no orphaned threads.

2. The StructuredTaskScope API

Java 21 introduces StructuredTaskScope (Preview) as the main API. It is designed to replace ExecutorService for request-scoped work.

Why not CompletableFuture?

CompletableFuture is powerful but unstructured.

  • It allows “dangling” futures that run long after the request that spawned them has finished.
  • Error handling is complex (exceptionally, handle).
  • Cancellation is manual and often missed.

StructuredTaskScope handles all of this automatically.

Policy 1: ShutdownOnFailure (“All for One”)

If any subtask fails, the scope shuts down, cancelling all other running subtasks. This is perfect for operations where you need all results to proceed (e.g., “Get User” AND “Get Orders”).

Policy 2: ShutdownOnSuccess (“First One Wins”)

The scope shuts down as soon as the first subtask succeeds. This is ideal for redundant service calls (e.g., “Read from Cache A” OR “Read from Cache B”).

3. Interactive: Scope Simulator

Visualize how ShutdownOnFailure propagates cancellation.

  1. Fork multiple subtasks.
  2. Fail one subtask to see how the Scope reacts.
  3. Notice how other running tasks are immediately cancelled.
Click "Start New Scope" to begin

4. Java Example: ShutdownOnFailure

Use this pattern when you need multiple independent results (e.g., User + Orders + Preferences) to construct a response. If any fail, the response cannot be constructed.

import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;

Response handleRequest(long userId) throws ExecutionException, InterruptedException {
    // 1. Create the Scope (try-with-resources ensures it closes)
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

        // 2. Fork subtasks (returns a Future-like Subtask handle)
        Subtask<User> userTask = scope.fork(() -> userService.findUser(userId));
        Subtask<List<Order>> ordersTask = scope.fork(() -> orderService.findOrders(userId));

        // 3. Wait for all threads to finish (or one to fail)
        scope.join();

        // 4. Check for errors (throws ExecutionException if any failed)
        scope.throwIfFailed();

        // 5. Construct result (get() is now safe, no blocking)
        return new Response(userTask.get(), ordersTask.get());
    }
    // scope.close() happens here, ensuring all threads are terminated
}

5. Comparison: Java vs Go

Go handles structured concurrency via libraries like errgroup. Java 21 builds it into the language runtime via StructuredTaskScope.

Java (StructuredTaskScope)

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    scope.fork(() -> task1());
    scope.fork(() -> task2());
    scope.join();
    scope.throwIfFailed();
}

Go (errgroup)

import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(context.Background())

g.Go(func() error {
    return task1(ctx)
})
g.Go(func() error {
    return task2(ctx)
})

if err := g.Wait(); err != nil {
    return err // First error propagates, context is cancelled
}

Both patterns achieve the same goal: automatic cancellation propagation and error aggregation.

6. Key Takeaways

  1. Code Structure: The lexical scope (try-with-resources) defines the lifetime of the concurrency.
  2. No Leaks: Threads cannot outlive their scope.
  3. Automatic Cancellation: ShutdownOnFailure cancels siblings automatically, saving resources.
  4. Observability: Tools like Flight Recorder can reconstruct the task tree because the parent-child relationship is explicit.