Классическая боль параллельного кода: метод запустил три подзадачи, одна упала — а две другие продолжают жечь ресурсы, и отменить их некому. Или наоборот: метод вернулся, а «осиротевшая» подзадача всё ещё жива и пишет в лог через минуту после ответа клиенту. Структурированная конкурентность решает это одним принципом: время жизни подзадач ограничено блоком кода, который их породил. В Java этот принцип воплощает StructuredTaskScope.
Проблема: неструктурированный fan-out
Соберём профиль пользователя из двух источников на ExecutorService:
Future<User> userF = executor.submit(() -> fetchUser(id));
Future<List<Order>> ordersF = executor.submit(() -> fetchOrders(id));
User user = userF.get(); // а если fetchOrders уже упал?
List<Order> orders = ordersF.get(); // узнаем только здесь
Здесь три хронические болезни. Утечка работы: если fetchUser бросил исключение, get() пробрасывает его наверх — но fetchOrders никто не отменил, он доработает впустую. Потерянные ошибки: упади fetchOrders первым — мы не узнаем об этом, пока не дойдём до его get(). Оборванная связь: по стеку вызовов не видно, что эти задачи — дети нашего метода; поток-родитель и подзадачи живут каждый своей жизнью, и отмена родителя детей не касается.
CompletableFuture с allOf часть проблем сглаживает, но отмену «братьев» при первой ошибке и жёсткую привязку к породившему блоку всё равно приходится собирать руками.
Идея: конкурентность с блочной структурой
Аналогия, которая всё объясняет: когда-то программировали через goto, и поток управления прыгал куда угодно; структурное программирование ввело блоки — вход сверху, выход снизу, и код стало возможно читать. Структурированная конкурентность делает то же с потоками: подзадачи живут строго внутри области (scope), и выйти из области нельзя, пока все подзадачи не завершены — успехом, ошибкой или отменой.
Из этого автоматически следуют гарантии: ни одна подзадача не переживёт родителя; ошибки собираются в одном месте; отмена распространяется сверху вниз.
StructuredTaskScope: API Java 25
Response handle(long id) throws InterruptedException {
try (var scope = StructuredTaskScope.<Object>open()) {
Subtask<User> user = scope.fork(() -> fetchUser(id));
Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(id));
scope.join(); // ждём всех; при ошибке любой — отмена остальных и исключение
return new Response(user.get(), orders.get());
} // выход из try гарантирует: живых подзадач не осталось
}
Разбор:
open()создаёт область;try-with-resourcesгарантирует уборку — как бы блок ни завершился, все подзадачи будут дождаты или отменены.fork(...)запускает подзадачу в собственном виртуальном потоке и возвращаетSubtask— «билет» за результатом.join()— единственная точка ожидания. Политика по умолчанию: все успешны — едем дальше; любая упала — остальные отменяются, иjoin()бросаетFailedExceptionс причиной внутри.subtask.get()после join — просто забрать готовое значение, никаких блокировок.
Утечка работы, потерянные ошибки и осиротевшие задачи исчезают не дисциплиной, а конструкцией: их больше негде оформить.
Joiner: политики ожидания
Что считать успехом области — настраивается через Joiner:
// Первый успешный результат — остальных отменить (гонка зеркал/реплик):
try (var scope = StructuredTaskScope.open(
Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> fetchFrom(mirrorA));
scope.fork(() -> fetchFrom(mirrorB));
return scope.join(); // сам возвращает результат победителя
}
// Дождаться ВСЕХ независимо от исхода и разобрать вручную (частичные результаты):
try (var scope = StructuredTaskScope.open(Joiner.<Widget>awaitAll())) { ... }
Плюс конфигурация области — например, общий дедлайн:
StructuredTaskScope.open(Joiner.allSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofSeconds(2)));
// не успели — все подзадачи отменяются, join() бросает TimeoutException
Таймаут на группу целиком — то, что с фьючерами собиралось из костылей, здесь одна строка.
Ещё одна деталь ансамбля: значения ScopedValue автоматически наследуются подзадачами — контекст запроса (пользователь, trace id) течёт в fan-out без ручного проброса.
Статус и практика
StructuredTaskScope — preview-возможность (JEP 505, пятое preview в Java 25): включается флагом --enable-preview, и API уже менялся — в Java 21–24 вместо open() были классы ShutdownOnFailure/ShutdownOnSuccess (их вы встретите в статьях и кодовых базах тех лет). Идея стабильна, сигнатуры — сверяйте со своим JDK.
Практическая рамка на сегодня: в библиотеках и долгоживущем продакшн-коде — осторожно (preview есть preview); в сервисах на свежем JDK, утилитах и новых модулях — уже реальный кандидат везде, где встречается паттерн «разослать N запросов — собрать ответы». Ориентир на будущее простой: там, где сегодня пишете CompletableFuture.allOf или пачку submit/get, завтра будет scope.
Коротко
- Проблема неструктурированного fan-out: утечки работы, потерянные ошибки, осиротевшие подзадачи, отмена руками.
- Принцип: подзадачи живут внутри области; выход из области = все подзадачи завершены. Это структурное программирование, применённое к потокам.
- API:
open()→fork()→join()→get(); try-with-resources гарантирует уборку; ошибка одной подзадачи отменяет остальных. - Joiner задаёт политику: все успешны / первый успешный / дождаться всех; конфигурация добавляет дедлайн на группу.
- ScopedValue наследуется подзадачами — контекст течёт сам. Статус: preview (JEP 505), API в 21–24 отличался.
Что почитать дальше
- Виртуальные потоки — на чём работает fork и почему «поток на подзадачу» снова дёшев.
- CompletableFuture — асинхронные цепочки: когда результат нужен «потом», а не «здесь».
- Пулы потоков: ExecutorService — классический слой, который scope постепенно вытесняет из прикладного кода.