Классическая боль параллельного кода: метод запустил три подзадачи, одна упала — а две другие продолжают жечь ресурсы, и отменить их некому. Или наоборот: метод вернулся, а «осиротевшая» подзадача всё ещё жива и пишет в лог через минуту после ответа клиенту. Структурированная конкурентность решает это одним принципом: время жизни подзадач ограничено блоком кода, который их породил. В 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 без ручного проброса.

Статус и практика

StructuredTaskScopepreview-возможность (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 постепенно вытесняет из прикладного кода.