Question 25 · Section 9

What is structured concurrency?

Structured Concurrency is a programming model where a group of related tasks, executing in different threads, is treated as a single unit of work with clear boundaries. Key idea...

Language versions: English Russian Ukrainian

Structured Concurrency is a programming model where a group of related tasks, executing in different threads, is treated as a single unit of work with clear boundaries. Key idea: if a task spawns child tasks, it must wait for their completion. This is the try-with-resources equivalent for multithreading.

Why this matters: in classic multithreading, threads live “on their own” — the parent method can finish while spawned threads continue running (or leak). Structured Concurrency guarantees: by the time you exit the block, all child tasks are finished — either successfully or with an error.

Important caveat: StructuredTaskScope is available from Java 21 as a Preview API. This means the API may change in future versions. For production use on Java 17 — use ExecutorService + CompletableFuture.


Junior Level

Basic Understanding

Structured Concurrency — a programming model where a group of related tasks, executing in different threads, is treated as a single unit of work with clear boundaries.

Why the classic approach is problematic: when you create threads via new Thread().start(), they become “orphans” — the parent method doesn’t know their status, can’t cancel them on error, and doesn’t guarantee their completion. Structured Concurrency solves this via the principle: “parent waits for children”.

The Problem with Classic Multithreading

// "Orphan" threads — fire and forget
public void process() {
    Thread t1 = new Thread(() -> fetchUserData());
    Thread t2 = new Thread(() -> fetchOrderHistory());
    t1.start();
    t2.start();
    // What if t1 throws? t2 will keep running!
    // What if the main method throws? Threads will remain!
}

Solution: StructuredTaskScope (Java 21+ Preview)

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User> user = scope.fork(() -> fetchUser(id));
    Subtask<Order> orders = scope.fork(() -> fetchOrders(id));

    scope.join();            // Wait for all tasks
    scope.throwIfFailed();   // If one failed — others are cancelled

    // Both results available
    return new Response(user.get(), orders.get());
}
// By the time we exit try-with-resources, all threads are finished!

Analogy

Classic threads:                Structured Concurrency:
  Parent                        Parent
    │                              │
    ├→ Thread 1 (orphan)          ├→ Task 1 (child)
    ├→ Thread 2 (orphan)          ├→ Task 2 (child)
    └→ Finishes                   └→ Waits for children
                                     │
                                  All children done → Parent finishes

Middle Level

Shutdown Policies

1. ShutdownOnFailure

If at least one subtask fails — the rest are cancelled:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<Data> data = scope.fork(() -> fetchData());
    Subtask<Config> config = scope.fork(() -> fetchConfig());

    scope.join();
    scope.throwIfFailed();

    // If fetchData() threw — fetchConfig() is automatically cancelled
    process(data.get(), config.get());
}

When: Parallel execution of steps in one business process.

2. ShutdownOnSuccess

As soon as the first task returns a result — the rest are cancelled:

try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    scope.fork(() -> fetchFromServer1(url));
    scope.fork(() -> fetchFromServer2(url));
    scope.fork(() -> fetchFromServer3(url));

    scope.join();
    String fastestResult = scope.result(); // Result of the first to complete
    // Other requests are automatically cancelled!
}

When: Requests to mirrors (whoever responds fastest).

Comparison with ExecutorService

Characteristic ExecutorService StructuredTaskScope
Lifecycle Not bound to scope Strictly inside try-with-resources
Error handling Manual (via Future) Automatic (Policy-based)
Cancellation Manual (future.cancel()) Automatic (cascading)
Context Propagation Complex (needs wrappers) Supports Scoped Values
Thread leaks Possible Impossible

Error Propagation

// ExecutorService — error "dies silently"
Future<?> future = executor.submit(() -> {
    throw new RuntimeException("Error!");
});
// Nobody knows until you call future.get()

// StructuredTaskScope — error bubbles to parent
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    scope.fork(() -> {
        throw new RuntimeException("Error!");
    });
    scope.join();
    scope.throwIfFailed(); // → ExecutionException automatically
}

Why Is This a Debugging Revolution?

Observability

Thread dumps are now hierarchical:

jcmd <pid> Thread.dump_to_file -format=json threads.json
{
  "threads": [
    {
      "name": "main",
      "children": [
        {
          "name": "Scope-1/Worker-1",
          "task": "fetchUser"
        },
        {
          "name": "Scope-1/Worker-2",
          "task": "fetchOrders"
        }
      ]
    }
  ]
}

Senior Level

Under the Hood: How StructuredTaskScope Works

// Simplified
public abstract class StructuredTaskScope<T> implements AutoCloseable {
    private final ThreadFactory factory;
    private final Set<Subtask<T>> subtasks = ConcurrentHashMap.newKeySet();
    private volatile boolean closed = false;

    public <U extends T> Subtask<U> fork(Callable<? extends U> task) {
        if (closed) throw new IllegalStateException("Scope closed");

        Thread thread = factory.newThread(() -> {
            try {
                U result = task.call();
                handleSuccess(result);
            } catch (Throwable t) {
                handleFailure(t);
            }
        });
        thread.start();
        // ...
    }

    @Override
    public void close() {
        // Guarantees: all subtasks finished by the time of exit
        join();
    }
}

Resource Safety

The model guarantees that by the time you exit try, all threads are finished:

// IMPOSSIBLE:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    scope.fork(() -> longRunningTask());
    return; // ← Compiler won't allow! Scope must be closed
}
// By the time we exit } — all tasks are finished

Integration with Virtual Threads

// StructuredTaskScope is designed for Virtual Threads
try (var scope = new StructuredTaskScope.ShutdownOnFailure>(
        Thread.ofVirtual().factory())) {
    // Create a million subtasks — VT scale
    for (int i = 0; i < 1_000_000; i++) {
        scope.fork(() -> processItem(i));
    }
    scope.join();
}
// Creating a scope per request — a cheap operation

Scoped Values Integration

// Context propagation without ThreadLocal overhead
static final ScopedValue<UserContext> CONTEXT = new ScopedValue<>();

try (var scope = new StructuredTaskScope.ShutdownOnFailure>()) {
    ScopedValue.runWhere(CONTEXT, userContext, () -> {
        scope.fork(() -> {
            // CONTEXT.get() is available in all subtasks
            return fetchUser(CONTEXT.get().userId());
        });
        scope.fork(() -> {
            return fetchOrders(CONTEXT.get().userId());
        });
    });
    scope.join();
}

Diagnostics

JSON Thread Dump

# Only this format shows hierarchy!
jcmd <pid> Thread.dump_to_file -format=json threads.json

# Regular text dump does NOT show hierarchy
jstack <pid>

Programmatically

try (var scope = new StructuredTaskScope.ShutdownOnFailure>()) {
    Subtask<?> task = scope.fork(() -> doWork());

    // Check status
    System.out.println(task.state()); // RUNNING, SUCCESS, FAILED

    scope.join();

    if (task.state() == Subtask.State.SUCCESS) {
        System.out.println(task.get());
    }
}

When to Use

Scenario StructuredTaskScope ExecutorService
Business transaction (multiple steps) Yes No
Mirror requests Yes (ShutdownOnSuccess) No
Long-running background service No Yes
Continuous queue processing No Yes
Parallel independent tasks Yes Yes

Best Practices

  1. Use for business transactions — when several steps must complete together
  2. ShutdownOnFailure — when all steps are mandatory
  3. ShutdownOnSuccess — for mirror requests
  4. Combine with VT — for scalability
  5. Scoped Values — for context propagation instead of ThreadLocal
  6. try-with-resources — guarantees all threads finish
  7. JSON thread dump — for debugging hierarchy
  8. Not for long-running services — use ExecutorService

When NOT to Use Structured Concurrency

  • Long-running background services (daemons, queue processors) — StructuredTaskScope requires all tasks to finish when exiting the block. For “eternal” tasks, use ExecutorService
  • Below Java 21 — API is in preview, may change. Use CompletableFuture + ExecutorService
  • Independent parallel tasks without a shared transaction — if tasks are truly independent, it’s simpler to use executor.submit() without scope overhead
  • Need full lifecycle control — if you need to manually cancel, suspend, resume tasks, ExecutorService is more flexible

StructuredTaskScope vs ExecutorService vs CompletableFuture: What to Choose?

Situation Choice Why
Business transaction (fetchUser + fetchOrders together) StructuredTaskScope Auto-cancel on error, completion guarantee
Mirror requests (whoever is faster) StructuredTaskScope.ShutdownOnSuccess Auto-cancel slow ones
Background queue processing ExecutorService Infinite loop, scope not suitable
Callback chains (thenApply, thenCompose) CompletableFuture Async pipeline, doesn’t block
Parallel independent tasks Any But StructuredTaskScope is simpler for debugging

Interview Cheat Sheet

Must know:

  • Structured Concurrency: group of tasks = single unit; parent waits for all child tasks to complete
  • try-with-resources equivalent for multithreading: by block exit, all threads are guaranteed finished
  • ShutdownOnFailure: one failed — others cancelled (business transactions)
  • ShutdownOnSuccess: first completed — others cancelled (mirror requests)
  • StructuredTaskScope — Preview API in Java 21, may change
  • Integration with Virtual Threads and Scoped Values for scalability and context propagation
  • Difference from ExecutorService: no thread leaks, automatic cancellation, hierarchical thread dump

Frequent follow-up questions:

  • Why is this better than ExecutorService + CompletableFuture? — ExecutorService: orphan threads, manual cancellation, possible leaks. SC: automatic lifecycle management, cascading cancellation, impossible to leak
  • How does SC work with Virtual Threads? — SC is created with Thread.ofVirtual().factory() — all fork tasks run on VT, scale to millions
  • When NOT to use SC? — For long-running background services (daemons, queue processors) — SC requires completion on block exit
  • How to debug SC? — JSON thread dump (jcmd Thread.dump_to_file -format=json) shows parent-child hierarchy

Red flags (DO NOT say):

  • “Structured Concurrency is a stable API in Java 21” — StructuredTaskScope is still in Preview
  • “SC replaces ExecutorService completely” — SC for business transactions, ExecutorService for long-running services
  • “SC automatically handles all exceptions” — you need to call throwIfFailed() after join()
  • “You can return a result from an SC block before it completes” — SC block must be closed (try-with-resources), all tasks must finish before exit

Related topics:

  • [[23. What are Virtual Threads in Java 21]]
  • [[24. What are the advantages of Virtual Threads over regular threads]]
  • [[25. When should you use Virtual Threads]]
  • [[28. What are Callable and Future]]