skip to content
everything.concurrency.structuredConcurrency

structured concurrency and scoped values

task trees that cancel together, and ThreadLocal's replacement.

chapter
chapter 09.15
reading time
7 min read
java 25+

structured concurrency treats a group of concurrent subtasks as one unit: they're forked inside a StructuredTaskScope, and they all finish (succeed, fail, or get cancelled) before the scope's block ends. a failure cancels the siblings, cancelling the parent cancels the children, and no thread outlives the code that started it. ScopedValue is the matching way to share context with that tree: an immutable value bound for the length of a call, replacing most ThreadLocals. scoped values are final in java 25 (JEP 506). the scope API is still a preview preview in 27.

the picture

ExecutorService.submit is like sending three interns out on errands and going back to your desk. if one of them gets hit by a bus, you find out whenever you next check your messages, and the other two keep running their errands for a project that's already cancelled.

a structured scope is a meeting. you all go into one room, everybody works on their part, and nobody leaves the room until the meeting is over. if one part fails and the meeting is called off, everyone stops. when you walk out the door, there's nobody left inside.

that's the "structure": the lifetime of the subtasks is nested inside a block of code, exactly like a method call is nested inside its caller. the same rule that makes try/finally reliable, applied to threads.

the problem with plain futures

Unstructured.java
Response handle() throws Exception {
    Future<String> user  = executor.submit(() -> findUser());
    Future<Integer> order = executor.submit(() -> fetchOrder());
    return new Response(user.get(), order.get());   // if fetchOrder fails first, we don't notice
}                                                    // until findUser is done. and if handle() is
                                                     // interrupted, both tasks keep running.

nothing ties those tasks to handle(). a failure in one doesn't stop the other, the parent learns about failures in whatever order it happens to call get(), and cancelling the parent doesn't cancel the children. in a thread dump, they're just pool threads with no record of who they're working for.

run them with =
fetchOrder =
03006009001200
handle()scope.join()
├ findUser
├ fetchOrder
└ fetchWeather

handle() opens a StructuredTaskScope and forks three subtasks, each in its own virtual thread. then it calls scope.join().

moment 1/2
the same three subtasks, fetchOrder failing at 300 ms. with an ExecutorService, handle() only finds out at 800 ms, and fetchWeather keeps running for nobody until 1200. with a StructuredTaskScope, the failure cancels the siblings and handle() fails at 300 ms with nothing left running.

StructuredTaskScope

preview in 27 here's the jdk 25 version of the API, which is what this site runs (compile and run with --enable-preview --source 25):

Main.java
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
 
String findUser() throws InterruptedException {
    try {
        Thread.sleep(1000);
        return "ada";
    } catch (InterruptedException e) {
        IO.println("findUser: cancelled");
        throw e;
    }
}
 
int fetchOrder() throws InterruptedException {
    Thread.sleep(100);
    throw new IllegalStateException("order service down");
}
 
void main() throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        Subtask<String> user = scope.fork(this::findUser);
        Subtask<Integer> order = scope.fork(this::fetchOrder);
        scope.join();                          // waits for both, or for the first failure
        IO.println(user.get() + " ordered " + order.get());
    } catch (StructuredTaskScope.FailedException e) {
        IO.println("scope failed: " + e.getCause().getMessage());
    }
}
output, from jdk 25
findUser: cancelled
scope failed: order service down

this one needs a real JVM (executors and futures), so it doesn't run in the browser. copy it into java Main.java to try it.

the shape is always the same:

  1. open() a scope in a try-with-resources.
  2. fork() each subtask. each one runs in its own new virtual thread, and you get a Subtask handle back.
  3. join() once. with the default policy, it waits for all subtasks to succeed, or throws as soon as one fails (after cancelling the rest).
  4. read results with subtask.get(), which is only allowed after join().
  5. leaving the block (close()) waits until every subtask's thread has actually finished. there's no way to leak one.

and if the thread running handle() is interrupted while in join(), the scope is cancelled and every subtask is interrupted too. cancellation flows down the tree, failures flow up.

other completion policies come from StructuredTaskScope.Joiner, passed to open(joiner): wait for all and collect every result, return the first subtask to succeed and cancel the rest (racing two replicas, say), or write your own.

ScopedValue: context for the tree

a request comes in, and a dozen methods deep, something needs to know the current user, the transaction, the request id. passing it as a parameter through every method is tedious. a ThreadLocal has been the standard answer since java 1.2, but it's a mutable, per-thread global with a lifetime problem:

pool-1-thread-1
sharedCURRENT_USER on thread-1
·
null

a pool thread, and a ThreadLocal holding the current user. the pool reuses the thread for request after request.

step 1/5
a ThreadLocal value lives as long as the thread, and pool threads live forever. one missing remove() and the next request on that thread inherits the previous user.

a ScopedValue is bound for the duration of a call instead, and it can't be changed, only rebound for a nested call:

Main.java next to run
  1. 1static final ScopedValue<String> USER = ScopedValue.newInstance();
  2. 2
  3. 3void audit(String what) {
  4. 4 IO.println(what + " by " + USER.get());
  5. 5}
  6. 6
  7. 7void main() {
  8. 8 ScopedValue.where(USER, "ada").run(() -> {
  9. 9 audit("read");
  10. 10 ScopedValue.where(USER, "admin").run(() -> audit("delete")); // rebound, for this call only
  11. 11 audit("write"); // back to ada
  12. 12 });
  13. 13 IO.println("bound out here? " + USER.isBound());
  14. 14}
stdout
·

USER isn't bound to anything yet. where(USER, "ada") makes a binding, and run(...) makes it visible for exactly as long as the lambda runs.

step 1/7
step through it. the binding exists only while run(...) is executing. nested bindings shadow the outer one and disappear on return, so there's never anything to clean up.
Main.java
static final ScopedValue<String> USER = ScopedValue.newInstance();
 
void audit(String what) {
    IO.println(what + " by " + USER.get());
}
 
void main() {
    ScopedValue.where(USER, "ada").run(() -> {
        audit("read");
        ScopedValue.where(USER, "admin").run(() -> audit("delete"));   // rebound, for this call only
        audit("write");                                                // back to ada
    });
    IO.println("bound out here? " + USER.isBound());
}
output
read by ada
delete by admin
write by ada
bound out here? false

and the two features are designed together: subtasks forked in a StructuredTaskScope automatically see the scoped values bound around the scope. no copying into each thread, no inheritance cost, because the values are immutable and the subtasks can't outlive the binding:

Main.java
import java.util.concurrent.StructuredTaskScope;
 
static final ScopedValue<String> USER = ScopedValue.newInstance();
 
void main() throws InterruptedException {
    ScopedValue.where(USER, "ada").run(() -> {
        try (var scope = StructuredTaskScope.open()) {
            var a = scope.fork(() -> "subtask 1 sees " + USER.get());   // inherited by forked subtasks
            var b = scope.fork(() -> "subtask 2 sees " + USER.get());
            scope.join();
            IO.println(a.get());
            IO.println(b.get());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}
output, from jdk 25
subtask 1 sees ada
subtask 2 sees ada

this one needs a real JVM (executors and futures), so it doesn't run in the browser. copy it into java Main.java to try it.

ThreadLocalScopedValue
lifetimeas long as the thread (forever, in a pool)exactly the run(...) / call(...)
mutableyes, set() anywhere, by anyoneno, rebind for a nested call instead
cleanupyour job: remove(), every pathnone needed
child threadsInheritableThreadLocal copies the value per threadshared with structured subtasks for free
cost with a million virtual threadsa map entry per threada lookup in the current binding

ThreadLocal is still the right tool for genuinely per-thread state that's expensive to build and safe to reuse (a per-thread SimpleDateFormat, say), on platform threads. for passing context down a call tree, use a ScopedValue. more on how ThreadLocal works (and leaks) in ThreadLocal internals.

keylines
  • unstructured futures leak work: failures are noticed late, and nothing cancels the siblings.
  • a StructuredTaskScope keeps subtasks inside a block: fork, join, and everything's done when it closes.
  • failure flows up and cancels siblings. cancellation flows down to children.
  • still a preview in java 27; proposed final for java 28 (JEP 543). the shape changed between versions.
  • ScopedValue (final in java 25): immutable context for the length of a call. nothing to remove, nothing to leak.