Structure Your Concurrent Tasks
class LaunchCheck {
private final ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor();
LaunchReport check()
throws InterruptedException, ExecutionException {
Future<Weather> weather = executor.submit(this::fetchWeather);
Future<CrewStatus> crew = executor.submit(this::fetchCrew);
try {
return new LaunchReport(weather.get(), crew.get());
} catch (InterruptedException | ExecutionException e) {
weather.cancel(true);
crew.cancel(true);
throw e;
}
}
Weather fetchWeather() {
return new Weather(true);
}
CrewStatus fetchCrew() {
return new CrewStatus(true);
}
}
The launch check needs two things, the weather and the crew status, so it fetches both in parallel with an executor and combines the results.
It looks harmless, but the two tasks have no relationship to each other or to the method that started them. If fetching the crew status fails right away, weather.get() doesn't notice: it keeps waiting until the weather arrives, and only then does crew.get() report the failure. The catch block has to cancel both tasks by hand, and forgetting it leaves threads running in the background, doing work that nobody needs anymore. If the thread calling check() gets interrupted, the subtasks keep running unless the code cancels them as well. And in a thread dump, nothing shows which method these tasks belong to.
class LaunchCheck {
LaunchReport check() throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<Weather> weather = scope.fork(this::fetchWeather);
Subtask<CrewStatus> crew = scope.fork(this::fetchCrew);
scope.join();
return new LaunchReport(weather.get(), crew.get());
}
}
Weather fetchWeather() {
return new Weather(true);
}
CrewStatus fetchCrew() {
return new CrewStatus(true);
}
}
Structured concurrency treats a group of related tasks as a single unit of work. StructuredTaskScope.open() creates a scope, fork() starts each subtask in its own virtual thread, and join() waits for all of them. The try-with-resources block guarantees that no subtask outlives the scope.
By default, the scope fails as soon as one subtask fails: it cancels the other subtasks and join() throws a FailedException with the original cause. So if fetching the crew status fails, the weather request is cancelled right away. If the thread running check() is interrupted, the subtasks are cancelled too. The manual cancellation code is gone, and thread dumps show the subtasks as children of the scope.
Different policies are available as so-called joiners, for example to return the first successful result and cancel the rest. Note that the structured concurrency API is still a preview in Java 25 (JEP 505) and has changed between releases. To use it, compile and run with --enable-preview.