How does cancellation and error propagation work across the parent/child boundary in a StructuredTaskScope, and what must a subtask do to be cancellable?
answer
- errors UP (join/throwIfFailed), cancellation DOWN (interrupt children)
- interrupt = sets a flag, NOT a forcible kill (cooperative)
- blocking JDK calls throw InterruptedException promptly
- CPU loops must poll isInterrupted()
- never swallow InterruptedException — restore flag or rethrow
basics
~20 sErrors flow up: a failing subtask can shut the scope down and surface to the parent. Cancellation flows down: shutting down interrupts the children. But interruption is cooperative — a subtask only stops if it responds to interruption, e.g. by being in a blocking call or checking the interrupt flag.
solid answer
~50 sPropagation is bidirectional across the scope's parent/child boundary. Upward (errors): when a subtask fails, the policy may shut the scope down and the failure is delivered to the owner thread at join()/throwIfFailed(), so the parent sees it instead of it being swallowed in a Future. Downward (cancellation): shutdown — triggered by a policy, an explicit shutdown(), or the owner thread itself being interrupted — interrupts every still-running subtask. The crucial caveat is that Java interruption is cooperative, not preemptive: setting a thread's interrupt status does nothing on its own. A subtask becomes cancellable by responding to interruption — most blocking JDK calls (I/O via interruptible channels, sleep, lock acquisition, queue waits) throw InterruptedException promptly, and CPU-bound loops must poll Thread.currentThread().isInterrupted() and bail out. Code that ignores interruption, swallows InterruptedException, or runs an uninterruptible native/blocking call won't stop promptly, delaying close() and undermining the no-orphans guarantee. So the scope provides the propagation plumbing, but subtasks must be written to honor interruption for it to be effective.
code
java · 13 lines// A cancellable subtask: cooperate with interruption
scope.fork(() -> {
while (hasWork()) {
if (Thread.currentThread().isInterrupted()) return null; // poll: bail on cancel
try {
return blockingFetch(); // interruptible: throws InterruptedException on cancel
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore status
return null; // stop promptly
}
}
return null;
});go deeper
Knows errors go up to the parent and cancellation goes down to children, and that interruption isn't a hard kill.
Explains cooperative interruption: blocking calls throw InterruptedException, loops must poll, don't swallow the exception.
Details the triggers of shutdown, how throwIfFailed surfaces errors, and which operations are/aren't interruptible, plus how swallowing delays close().
Reasons about cancellation latency, uninterruptible native/blocking calls, writing robust cancellable subtasks, and how this cooperative model is consistent across Java's concurrency APIs.
## The parent/child boundary A scope creates a **parent** (the owner thread running the block) and **children** (the forked subtasks). Two kinds of signal cross this boundary, in opposite directions. ### Upward: error propagation Without structured concurrency, a failed `Future` sits silently until someone calls `get()`. With a scope, a subtask failure is **delivered to the owner**: - The completion policy decides what a failure means (e.g. shutdown-on-failure stops everything). - The owner learns about it when `join()` returns and, for shutdown-on-failure, when **`throwIfFailed()`** re-throws the first failure as a cause. - Net effect: the exception surfaces in the **enclosing block**, where ordinary `try/catch` handles it — exactly like a sequential call. Errors don't get lost. ### Downward: cancellation propagation **Cancellation** travels from parent to children. It is triggered by: 1. a **policy** deciding to stop early (first failure / first success), 2. an explicit **`scope.shutdown()`** call, or 3. the **owner thread itself being interrupted** (the parent is cancelled, so its whole subtree is). Shutdown **interrupts every still-running subtask**. That brings us to the key concept. ## Java interruption is cooperative, not preemptive This is the heart of the question. **Interrupting** a thread in Java does **not** forcibly stop it. `Thread.interrupt()` merely sets a boolean **interrupt status** flag on the target thread. Nothing happens unless the target thread **chooses to notice**. (The old forcible `Thread.stop()` was deprecated/removed precisely because killing a thread mid-operation corrupts state.) So "cancellation propagates to children" really means "children's interrupt flags get set." Whether they *actually stop* depends on how they're written. ## How a subtask becomes cancellable A subtask is **cancellable** only if it **responds to interruption**: 1. **Blocking JDK calls react automatically.** Most blocking operations check the interrupt status and throw **`InterruptedException`** promptly when interrupted: `Thread.sleep`, `Object.wait`, `BlockingQueue.take/put`, `lock.lockInterruptibly`, `Future.get`, blocking I/O on **interruptible channels** (NIO). If your subtask spends its time in such calls, it will unwind quickly when the scope shuts down. 2. **CPU-bound loops must poll.** A tight computation that never blocks won't see an interrupt unless it checks: ```java while (moreWork()) { if (Thread.currentThread().isInterrupted()) return; // cooperate crunch(); } ``` 3. **Don't swallow `InterruptedException`.** Catching it and doing nothing erases the signal. The correct responses are to **propagate** it, or to **restore the flag** with `Thread.currentThread().interrupt()` and then stop: ```java try { queue.take(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // restore status return; // stop promptly } ``` ## What breaks cancellation - **Swallowed `InterruptedException`** (catch-and-ignore) — the classic bug. - **Uninterruptible blocking** — e.g. a legacy `InputStream.read` on a socket, a native call, or a `synchronized` lock acquisition (not interruptible). These won't stop on interrupt and will **delay `close()`**. - **No interrupt checks** in a long CPU loop. When subtasks ignore interruption, `close()` (which waits for all children to terminate) **blocks longer than expected** — the no-orphans guarantee still holds (close *waits*), but cancellation becomes sluggish, which is its own problem. ## Putting it together - The scope is the **plumbing**: it routes failures up and cancellation down. - **Effectiveness depends on the subtasks**: they must honor interruption to be cancelled promptly. - Good subtask code: use interruptible blocking calls, poll the interrupt flag in loops, never silently swallow `InterruptedException`, and restore the flag when you can't re-throw. This cooperative model is the same one used throughout Java concurrency (ExecutorService cancellation, `Future.cancel(true)`); structured concurrency inherits it.
- Why does interrupting a subtask not always stop it immediately?Because Java interruption is cooperative: interrupt() only sets a status flag. The thread stops only if it runs an interruptible blocking call (which throws InterruptedException) or explicitly checks the flag. Uninterruptible work keeps going.
- What's the correct way to handle InterruptedException in a subtask you can't propagate it from?Restore the interrupt status with Thread.currentThread().interrupt() and then stop the work (return/break), so callers can still observe that interruption occurred.
saying these in an interview costs you the question
- Saying shutdown forcibly kills subtask threads (preemptive) — interruption is cooperative.
- Catching InterruptedException and ignoring it (loses the cancellation signal).
- Assuming a tight CPU loop will be cancelled without checking the interrupt flag.
- Believing failures are swallowed like with raw Futures — the scope surfaces them to the parent.
- Thinking synchronized lock acquisition or legacy socket reads are interruptible (they aren't).