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
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.
handle() opens a StructuredTaskScope and forks three subtasks, each in its own virtual thread. then it calls scope.join().
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):
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());
}
}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:
open()a scope in a try-with-resources.fork()each subtask. each one runs in its own new virtual thread, and you get aSubtaskhandle back.join()once. with the default policy, it waits for all subtasks to succeed, or throws as soon as one fails (after cancelling the rest).- read results with
subtask.get(), which is only allowed afterjoin(). - 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:
a pool thread, and a ThreadLocal holding the current user. the pool reuses the thread for request after request.
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:
- 1static final ScopedValue<String> USER = ScopedValue.newInstance();
- 2
- 3void audit(String what) {
- 4 IO.println(what + " by " + USER.get());
- 5}
- 6
- 7void main() {
- 8 ScopedValue.where(USER, "ada").run(() -> {
- 9 audit("read");
- 10 ScopedValue.where(USER, "admin").run(() -> audit("delete")); // rebound, for this call only
- 11 audit("write"); // back to ada
- 12 });
- 13 IO.println("bound out here? " + USER.isBound());
- 14}
·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.
run(...) is executing. nested bindings shadow the outer one and disappear on return, so there's never anything to clean up.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());
}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:
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();
}
});
}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.
ThreadLocal | ScopedValue | |
|---|---|---|
| lifetime | as long as the thread (forever, in a pool) | exactly the run(...) / call(...) |
| mutable | yes, set() anywhere, by anyone | no, rebind for a nested call instead |
| cleanup | your job: remove(), every path | none needed |
| child threads | InheritableThreadLocal copies the value per thread | shared with structured subtasks for free |
| cost with a million virtual threads | a map entry per thread | a 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.
- 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.