How do ScopedValue bindings interact with structured concurrency and forked subtasks?
answer
- subtasks inherit parent's scoped values automatically
- shared by reference (immutable), not copied per child
- vs InheritableThreadLocal which copies + no lifetime bound
- inheritance is downward only; subtask's own binding stays local
- binding lifetime = parent scope; unwinds on close/cancel
basics
~20 sWhen you fork subtasks inside a structured-concurrency scope, those subtasks automatically see the scoped values bound in the parent. You don't pass them in — the bindings are inherited because the subtasks run within the parent's bounded scope, and they're shared, not copied.
solid answer
~50 sStructured concurrency runs forked subtasks within a parent's bounded scope: the parent opens a StructuredTaskScope, forks subtasks (each on its own virtual thread), and joins them before the scope closes, so subtasks never outlive the parent. Because of that containment, scoped values bound in the parent before forking are visible to every forked subtask via KEY.get() — they are inherited automatically. Since the value is immutable, the binding is shared by reference, not copied per subtask, which is cheap even with large fan-out. This is the clean alternative to InheritableThreadLocal, which copies values to each child and has no lifetime guarantees. The inheritance is one-directional: a subtask sees the parent's bindings, but a binding it opens inside itself stays local to that subtask. Together, structured concurrency plus scoped values give you implicit, leak-free context propagation across a tree of virtual threads.
code
java · 14 linesstatic final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
void handle() throws Exception {
ScopedValue.where(REQUEST_ID, "req-42").call(() -> {
try (var scope = StructuredTaskScope.open()) {
var a = scope.fork(() -> { log(REQUEST_ID.get()); return loadProfile(); }); // "req-42"
var b = scope.fork(() -> { log(REQUEST_ID.get()); return loadOrders(); }); // "req-42"
scope.join();
return merge(a.get(), b.get());
}
});
}
// Both subtasks inherit REQUEST_ID by reference (immutable, no copy),
// and the binding unwinds when the call() scope returns.go deeper
Knows forked subtasks can read the scoped values the parent bound, without being passed them.
Explains that inheritance is automatic within a structured scope and that the binding's lifetime is tied to the parent scope.
Contrasts with InheritableThreadLocal (copy vs share-by-reference), explains one-directional inheritance and cancellation-safe unwinding, and the role of immutability.
Reasons about context propagation design across a virtual-thread task tree, performance at large fan-out, and migrating an InheritableThreadLocal-based propagation framework to scoped values.
## Two features that are designed together **Structured concurrency** (JEP-developed alongside virtual threads, finalized Java 25) treats a group of concurrent subtasks as a single unit of work. The pattern: ```java try (var scope = StructuredTaskScope.open()) { // shape varies by Java version Subtask<A> a = scope.fork(() -> fetchA()); Subtask<B> b = scope.fork(() -> fetchB()); scope.join(); // wait for both use(a.get(), b.get()); } // scope closes: all subtasks done ``` Key property: subtasks are **confined** to the scope's dynamic extent. `fork` starts each on its own virtual thread; `join` waits for them; the `try`-with-resources close guarantees none outlive the block. Errors and cancellation propagate as a unit. (Exact API names have shifted across preview rounds; the model is stable.) **ScopedValue** binds immutable context for a dynamic extent. Put them together and you get implicit context that flows correctly across a *tree* of threads. ## Inheritance: how subtasks see parent bindings When you bind a scoped value and then fork subtasks **within** that binding's extent, each subtask runs inside the parent's dynamic scope. Therefore: ```java static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance(); ScopedValue.where(REQUEST_ID, "req-42").run(() -> { try (var scope = StructuredTaskScope.open()) { scope.fork(() -> { // inherited automatically — no parameter passing log(REQUEST_ID.get()); // "req-42" return doWork(); }); scope.fork(() -> { log(REQUEST_ID.get()); return doMore(); }); // also "req-42" scope.join(); } }); ``` Both forked subtasks read `REQUEST_ID` even though nobody passed it in. This is the **inheritance** guarantee: subtasks of a structured scope inherit the scoped-value bindings in effect when they were forked. ## Shared by reference, not copied Because a ScopedValue is **immutable**, the runtime can let every subtask reference the *same* binding rather than copying it. With a large fan-out (say forking thousands of subtasks), that's a major efficiency win and the whole reason this is viable at virtual-thread scale. Contrast **InheritableThreadLocal**, which copies each value into every child thread at creation — O(children × values) work and memory, plus no automatic teardown. ## One-directional and well-scoped Inheritance flows **downward and outward-in**: a subtask sees what the parent bound. The reverse is not true — if a subtask opens its own `where(KEY, x)` binding internally, that binding is local to that subtask's extent and is gone when the subtask finishes; it does not leak to siblings or back to the parent. This matches the immutability/stack-discipline model: context flows down the tree, never up. ## Why this combination matters - **Leak-free propagation:** the binding's lifetime is exactly the parent scope; when the structured scope closes, everything unwinds. No `remove()`, no stale context on reused carrier threads. - **Correctness across cancellation:** if the structured scope cancels/short-circuits (e.g., first failure), subtasks are interrupted and the bindings unwind with them — no orphaned context. - **Replaces InheritableThreadLocal cleanly:** same "children see parent context" capability, but immutable, shared-by-reference, lifetime-bounded, and cheap. ## Caveats - A subtask only inherits bindings that were in effect **at fork time**. Binding a value *after* forking does not retroactively reach already-forked subtasks (and structurally you bind before forking). - If you fork a *bare* virtual thread outside any structured scope, you don't get this inheritance guarantee — the point is the structured containment that ties subtask lifetime to the binding's extent.
- If a subtask binds its own ScopedValue internally, do sibling subtasks or the parent see it?No. A subtask's internal binding is confined to that subtask's dynamic extent and is gone when it finishes. Inheritance flows only downward from parent to forked subtasks, never sideways or up.
- What is the efficiency advantage over InheritableThreadLocal when forking many subtasks?InheritableThreadLocal copies every inheritable value into each child thread (cost scales with children × values). ScopedValue's immutable bindings are shared by reference, so fan-out is cheap and there's no per-child copy or cleanup.
saying these in an interview costs you the question
- Thinking you must manually pass context into each forked subtask
- Believing each subtask gets a private copy of the binding (it's shared by reference)
- Assuming a subtask's own binding propagates to siblings or the parent
- Expecting inheritance from a bare virtual thread forked outside a structured scope