Перейти к содержанию
V1 V2

Структурированная конкурентность в приложениях Kora

31 августа 2026

Современные серверные сервисы редко бывают последовательными. Один HTTP-запрос может нуждаться в загрузке профиля пользователя, получении недавних заказов, вычислении прав, вызове одного или нескольких внешних сервисов и получении рекомендаций, прежде чем сможет сконструировать итоговый ответ. Когда эти операции независимы, выполнение их друг за другом создаёт устранимую задержку. Запрос профиля за 40 миллисекунд, запрос заказов за 60 миллисекунд и запрос рекомендаций за 80 миллисекунд дают примерно 180 миллисекунд ожидания при последовательном выполнении. Если запустить их вместе, та же операция может приблизиться к задержке самой медленной ветви — примерно 80 миллисекунд плюс накладные расходы на координацию.

Запустить несколько операций конкурентно — не сложная часть. Java годами предоставляет исполнители, фьючерсы, параллельные стримы, CompletableFuture и множество других механизмов для этого. Сложная часть — определить жизненный цикл этих конкурентных операций. Если запрос заказов падает, должны ли рекомендации продолжать выполняться? Если HTTP-запрос достигает своего дедлайна, как останавливаются дочерние операции? Если один дочерний элемент падает немедленно, а два других заблокированы в I/O, кто владеет их отменой? Могут ли эти задачи случайно продолжиться после того, как родительский запрос уже вернул ошибку? Как контекст трассировки распространяется в них?

Структурированная конкурентность спроектирована, чтобы отвечать на эти вопросы. Вместо того чтобы рассматривать конкурентные задачи как независимые фрагменты работы, отправленные какому-то исполнителю, она рассматривает их как дочерние элементы более крупной операции. Их время жизни, обработка отказов, отмена и завершение привязаны к лексической области, которая их создала. Для приложений на фреймворке Kora эта модель подходит особенно хорошо, потому что фреймворк намеренно использует обычные синхронные сигнатуры методов вместе с виртуальными потоками. Контроллер или сервис остаётся синхронным с точки зрения вызывающего, а локальный участок этой операции может разветвляться на несколько конкурентных виртуальных потоков, когда параллелизм действительно полезен.

Результат — модель, где синхронный не означает последовательный. Такой метод, как Dashboard load(long userId), может оставаться обычным блокирующим методом, внутренне выполняя несколько независимых операций конкурентно. Вызывающий по-прежнему получает простой контракт завершения: когда метод возвращается, операция завершена; когда он бросает исключение, операция провалилась. Нет необходимости раскрывать Future, CompletionStage, Mono, Uni или другую асинхронную абстракцию по всему приложению только потому, что один конкретный шаг оркестрации выигрывает от параллельного выполнения.


Kora 2 и возвращение к синхронному коду приложения

Kora 2 спроектирован вокруг прямого синхронного кода приложения. HTTP-контроллеры, репозитории, клиенты, запланированные операции и другие контракты фреймворка используют обычные сигнатуры методов Java и Kotlin, а виртуальные потоки предоставляют модель исполнения под ними. Это позволяет коду приложения выполнять блокирующий JDBC, HTTP, gRPC, файловый или другой I/O, не требуя, чтобы каждый слой был выражен как асинхронный конвейер.

Поэтому обычный сервис Kora может оставаться прямолинейным:

@Component
public final class UserService {

    private final UserRepository repository;

    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public User get(long id) {
        return repository.findById(id);
    }
}
@Component
class UserService(private val repository: UserRepository) {

    fun get(id: Long): User {
        return repository.findById(id)
    }
}

Большая часть кода должна оставаться ровно такой. Структурированная конкурентность не предназначена для замены обычных вызовов методов или для того, чтобы делать каждую операцию параллельной. Она становится полезной, когда одна логическая операция содержит несколько независимых фрагментов работы, чьи задержки могут перекрываться.

Рассмотрим сервис, который строит дашборд:

public Dashboard getDashboard(long userId) {
    var profile = profileClient.get(userId);
    var orders = orderClient.getRecent(userId);
    var recommendations = recommendationClient.get(userId);

    return new Dashboard(profile, orders, recommendations);
}
fun getDashboard(userId: Long): Dashboard {
    val profile = profileClient.get(userId)
    val orders = orderClient.getRecent(userId)
    val recommendations = recommendationClient.get(userId)

    return Dashboard(profile, orders, recommendations)
}

Код прост, но вызовы последовательны. Если три операции занимают 40, 70 и 90 миллисекунд соответственно, сервис тратит примерно 200 миллисекунд на ожидание, хотя ни одна не зависит от результата другой. Граф зависимостей параллелен, но реализация последовательна.

Структурированная конкурентность позволяет реализации соответствовать фактическому графу зависимостей, не меняя окружающий синхронный API.

public Dashboard getDashboard(long userId) throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        var profile = scope.fork(() -> profileClient.get(userId));
        var orders = scope.fork(() -> orderClient.getRecent(userId));
        var recommendations = scope.fork(() -> recommendationClient.get(userId));

        scope.join();

        return new Dashboard(
            profile.get(),
            orders.get(),
            recommendations.get()
        );
    }
}
fun getDashboard(userId: Long): Dashboard {
    StructuredTaskScope.open<Any>().use { scope ->
        val profile = scope.fork { profileClient.get(userId) }
        val orders = scope.fork { orderClient.getRecent(userId) }
        val recommendations = scope.fork { recommendationClient.get(userId) }

        scope.join()

        return Dashboard(
            profile.get(),
            orders.get(),
            recommendations.get()
        )
    }
}

Важный момент не в том, что три виртуальных потока могут выполняться одновременно. Важный момент в том, что все три задачи принадлежат одной области. Область устанавливает структурное отношение между родительской операцией и её дочерними элементами, и это отношение определяет, когда родитель может завершиться и что происходит при отказе или отмене.


Структурированная конкурентность прежде всего о владении

Легко принять структурированную конкурентность за более удобный синтаксис отправки задач. Однако её главное преимущество — не более короткий код, а явное владение.

С исполнителем приложение может отправить несколько задач:

var profile =
    executor.submit(() -> profileClient.get(userId));

var orders =
    executor.submit(() -> orderClient.getRecent(userId));

var recommendations =
    executor.submit(() -> recommendationClient.get(userId));
val profile =
    executor.submit(Callable { profileClient.get(userId) })

val orders =
    executor.submit(Callable { orderClient.getRecent(userId) })

val recommendations =
    executor.submit(Callable { recommendationClient.get(userId) })

В этот момент приложение создало несколько независимых фрагментов работы и получило дескрипторы на них. Отношение жизненного цикла между этими задачами и исходным запросом существует только потому, что разработчик помнит его поддерживать. Если задача заказов падает, рекомендации могут продолжать выполняться, если приложение явно их не отменит. Если родительский метод выходит раньше, другая задача всё ещё может потреблять соединение, генерировать телеметрию, выполнять удалённый I/O или даже производить побочные эффекты.

Структурированная конкурентность делает это отношение частью структуры программы. Операция в области запроса концептуально становится деревом:

Запрос
└── операция дашборда
    ├── Профиль
    ├── Заказы
    └── Рекомендации

Дочерние элементы существуют как часть операции дашборда и концептуально не отделены от неё. Родитель не может молча завершиться, пока структурированные дочерние элементы бесконечно продолжаются где-то ещё. Когда лексическая область заканчивается, у приложения есть ясная граница завершения.

Это похоже на то, почему структурированное управление ресурсами легче для рассуждения, чем ручное отслеживание ресурсов. Блок try-with-resources делает ясным, где принадлежит файл, поток или соединение и когда его нужно закрыть. Структурированная конкурентность применяет тот же принцип к конкурентным задачам. Область — не просто место, где задачи случайно запускаются; это владелец их времени жизни.


Fan-out как локальный паттерн оркестрации

Самый очевидный вариант использования StructuredTaskScope — fan-out. Входящий запрос достигает слоя оркестрации, где несколько независимых зависимостей можно опросить одновременно, а их результаты объединяются перед возвратом ответа. Типичные примеры включают агрегирующие API-эндпоинты, дашборды, обогащение поиска, агрегацию цен, загрузку прав или получение независимых секций страницы пользователя.

Полезное правило — параллелизовать независимую задержку, а не произвольный код. Если два вызова независимы, например получение профиля и загрузка недавних заказов, они естественные кандидаты для fan-out. Если второму вызову нужны данные, произведённые первым, операции образуют цепочку зависимостей и в этой точке должны оставаться последовательными.

Например:

var customer = customerClient.get(customerId);

try (var scope = StructuredTaskScope.open()) {
    var orders = scope.fork(() -> orderClient.get(customer.id()));
    var permissions = scope.fork(() -> permissionsClient.get(customer.id()));
    var loyalty = scope.fork(() -> loyaltyClient.get(customer.id()));

    scope.join();

    return build(
        customer,
        orders.get(),
        permissions.get(),
        loyalty.get()
    );
}
val customer = customerClient.get(customerId)

StructuredTaskScope.open<Any>().use { scope ->
    val orders = scope.fork { orderClient.get(customer.id) }
    val permissions = scope.fork { permissionsClient.get(customer.id) }
    val loyalty = scope.fork { loyaltyClient.get(customer.id) }

    scope.join()

    return build(
        customer,
        orders.get(),
        permissions.get(),
        loyalty.get()
    )
}

Здесь загрузка клиента обязательно первый этап, потому что последующие операции зависят от разрешённого идентификатора клиента или других данных клиента. Как только эта зависимость удовлетворена, несколько независимых ветвей могут запуститься параллельно. Это создаёт структуру конкурентности, которая отражает фактический доменный граф, а не сплющивает весь запрос в один большой пакет.

Это различие важно. Виртуальные потоки делают создание конкурентных задач недорогим, но дешёвые потоки — не причина вводить параллелизм там, где реальной конкурентности нет. Структурированная область должна описывать подлинную независимость между операциями.


Распространение отказов как часть операции

Конкурентное выполнение становится гораздо интереснее, когда одна ветвь падает. Предположим, загрузка профиля успешна, загрузка заказов падает, а рекомендации всё ещё выполняются. Если для конструирования ответа требуются все три результата, продолжение запроса рекомендаций больше не даёт ценности. Общая логическая операция уже стала невозможной для успешного завершения.

Здесь структурированная конкурентность предоставляет семантику, гораздо более сильную, чем простая отправка задач. Вместо того чтобы рассматривать каждую ветвь как несвязанный фьючерс, родительская область может рассматривать их как один домен отказов. Когда обязательный дочерний элемент падает, родительская операция может упасть, а оставшаяся ненужная работа может быть отменена.

Концептуально выполнение может выглядеть так:

             profile -------- SUCCESS
            /
request ---+--- orders ------- FAILURE
            \
             recommendations - CANCELLED

Важный архитектурный вопрос — действительно ли дочерние элементы принадлежат одному домену отказов. Если все результаты обязательны, политика «падать вместе» естественна. Если некоторые результаты опциональны, приложение должно моделировать это намеренно, а не применять семантику fail-fast вслепую.

Рассмотрим страницу продукта, где сам продукт и его цена обязательны, а рекомендации опциональны. Отказ рекомендаций не обязательно оправдывает провал всего запроса. В этом случае опциональная ветвь может деградировать локально:

var recommendations = scope.fork(() -> {
    try {
        return recommendationClient.get(userId);
    } catch (RecommendationUnavailableException e) {
        return List.of();
    }
});
val recommendations = scope.fork {
    try {
        recommendationClient.get(userId)
    } catch (e: RecommendationUnavailableException) {
        emptyList()
    }
}

Это делает бизнес-правило явным: недоступность рекомендаций — приемлемый деградированный результат. Область всё ещё может представлять обязательную семантику успеха всего запроса, пока опциональные зависимости преобразуют свои отказы в доменные значения-запасные.

Ключ в том, что политика отказов принадлежит логической операции, а не самому примитиву конкурентности. Структурированная конкурентность облегчает выражение этой политики, но приложение всё равно должно решать, делает ли один упавший дочерний элемент недействительным весь результат.


Отмена и кооперативное завершение

Отмена — одна из самых сильных причин предпочесть структурированную конкурентность случайному использованию исполнителя. Когда родитель больше не нуждается в дочерней задаче, дочерний элемент может быть отменён как часть той же структурированной операции. Это предотвращает оставление запросами ненужной работы после отказа или таймаута.

В Java отмена обычно кооперативна. Выполняющаяся задача не уничтожается принудительно; прерывание используется как сигнал о том, что операция должна остановиться. Это означает, что эффективность отмены зависит от выполняемого кода. Если дочерний элемент заблокирован в прерываемой операции, он часто может завершиться быстро. Если он игнорирует прерывание или использует базовый API, который нельзя эффективно отменить, отмена области может не остановить работу немедленно.

Именно поэтому структурированную отмену не следует путать с управлением таймаутами на транспортном уровне. Предположим, у HTTP-запроса бюджет 500 миллисекунд, но один дочерний элемент использует удалённый HTTP-клиент, настроенный с 30-секундным таймаутом сокета. Даже если родительская область решает, что дочерний элемент больше не полезен, поведение базового I/O всё равно имеет значение. Структурированная конкурентность координирует время жизни задач, а таймауты сокета, соединения, базы данных и клиента контролируют базовые взаимодействия с ресурсами.

Поэтому надёжная система сочетает оба уровня. Структурированная область определяет родительскую операцию и семантику отмены, а каждая зависимость имеет разумные таймауты уровня I/O, соответствующие общему бюджету запроса.

Код, выполняющийся внутри структурированных подзадач, также должен избегать непреднамеренного проглатывания прерываний. Такой паттерн разрушает кооперативную отмену:

try {
    doBlockingWork();
} catch (InterruptedException e) {
    // ignored
}
try {
    doBlockingWork()
} catch (e: InterruptedException) {
    // ignored
}

Если прерывание нужно перехватывать, его обычно следует распространять или восстанавливать:

try {
    doBlockingWork();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw e;
}
try {
    doBlockingWork()
} catch (e: InterruptedException) {
    Thread.currentThread().interrupt()
    throw e
}

Структурированная область может предоставить чистую модель отмены, только когда код под ней участвует в этой модели.


Дедлайны и бюджеты запросов

Таймауты и дедлайны тесно связаны, но концептуально различны. Таймаут отвечает, как долго может ждать отдельная операция. Дедлайн отвечает, сколько времени осталось у родительского запроса, прежде чем он перестанет быть полезным.

Различие становится особенно важным в сценариях fan-out. Если у всего запроса есть 300 миллисекунд, мало смысла в том, чтобы каждый дочерний элемент независимо использовал таймаут в одну секунду. Родительскому запросу нужна власть над временем жизни всех своих дочерних элементов.

Java 25 позволяет настроить структурированную область задач с таймаутом:

var timeout = Duration.ofMillis(250);

try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
        config -> config.withName("user-dashboard").withTimeout(timeout))) {

    var profile = scope.fork(() -> profileClient.get(userId));
    var orders = scope.fork(() -> orderClient.getRecent(userId));
    var recommendations = scope.fork(() -> recommendationClient.get(userId));

    scope.join();

    return new Dashboard(
        profile.get(),
        orders.get(),
        recommendations.get()
    );
}
val timeout = Duration.ofMillis(250)

StructuredTaskScope.open(
    StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
    { config -> config.withName("user-dashboard").withTimeout(timeout) }
).use { scope ->
    val profile = scope.fork { profileClient.get(userId) }
    val orders = scope.fork { orderClient.getRecent(userId) }
    val recommendations = scope.fork { recommendationClient.get(userId) }

    scope.join()

    return Dashboard(
        profile.get(),
        orders.get(),
        recommendations.get()
    )
}

Как только область достигает своего таймаута, структурированной операции больше не разрешено продолжаться бесконечно. Оставшиеся дочерние элементы можно отменить, потому что родительский результат уже слишком поздно использовать.

В распределённых системах ещё лучше распространять оставшийся бюджет, а не многократно назначать свежий таймаут на каждой границе сервиса. Если шлюз начинает с дедлайна в 1 секунду и 250 миллисекунд уже потрачено до того, как сервис начинает собственный fan-out, дочерние элементы не должны действовать так, будто у них есть ещё целая секунда. Фактически у них осталось около 750 миллисекунд.

Тот же принцип применим внутри одного процесса. Вложенная операция должна выводить свой таймаут из оставшегося бюджета родительского запроса, а не сбрасывать таймер. Это создаёт временную иерархию, зеркалящую иерархию задач: родительские операции владеют дедлайном, а дочерние операции потребляют оставшееся время.


Область запроса как дерево исполнения

Структурированная конкурентность становится особенно интуитивной, когда область задач рассматривается как часть самого HTTP-запроса. Kora уже выполняет синхронную обработку запросов на виртуальном потоке. Когда сервису нужен внутренний параллелизм, этот корневой виртуальный поток может создать структурированное поддерево дочерних виртуальных потоков и ждать их перед продолжением.

Концептуально:

HTTP-запрос
      |
      v
виртуальный поток запроса
      |
      v
Контроллер
      |
      v
Сервис
      |
      v
StructuredTaskScope
      |
      +--> profile
      |
      +--> orders
      |
      +--> recommendations
      |
      v
     join
      |
      v
сборка ответа
      |
      v
HTTP-ответ

Это сильнее, чем просто сказать, что запрос использует несколько потоков. Иерархия задач теперь зеркалит логическую иерархию операции. Запрос начинается, сервис открывает конкурентную область, дочерние операции выполняются, завершаются или падают, область закрывается, и только затем запрос продолжается к сериализации и записи ответа.

Этот жизненный цикл особенно полезен для наблюдаемости и отладки, потому что дерево исполнения имеет ясного владельца на каждом уровне. Дочерняя операция существует, потому что она нужна родительской операции, и родитель не может небрежно бросить этого дочернего элемента, явно не завершив или не отменив область.


Область контекста и наблюдаемость

Конкурентное выполнение становится гораздо труднее безопасно использовать, когда дочерние задачи теряют контекст запроса. Серверные запросы часто несут информацию трассировки, данные аутентификации, идентификаторы арендатора, метаданные запроса, локаль или значения корреляции логирования. Исторически конкурентность на основе исполнителя требовала, чтобы фреймворки и библиотеки вручную захватывали и восстанавливали этот контекст, потому что произвольный поток исполнителя не наследовал естественно контекстное состояние потока запроса.

Модель трассировки Kora 2 хорошо согласуется со структурированной конкурентностью, потому что его интеграция OpenTelemetry использует Java ScopedValue для хранения контекста. Структурированные области задач наследуют привязки scoped-value в свои дочерние задачи, что означает, что контекст трассировки, связанный с родительским запросом, может естественно течь в структурированные дочерние виртуальные потоки.

Это важно, потому что дерево трассировки может затем зеркалить дерево исполнения. Спан запроса может содержать дочерние спаны для загрузки профиля, загрузки заказов и рекомендаций, производя трейс, отражающий фактическую параллельную структуру запроса. Вместо того чтобы видеть несвязанные спаны или вручную захватывать и восстанавливать контекст телеметрии вокруг отправок исполнителя, контекст запроса остаётся структурно связанным со своими дочерними элементами.

Результирующий трейс также делает анализ задержки гораздо яснее. В последовательной реализации нижестоящие спаны появляются один за другим, и общая задержка приближается к их сумме. В структурированной реализации fan-out спаны перекрываются, и критический путь становится примерно длительностью самой медленной обязательной ветви. Поэтому наблюдаемость становится важным инструментом для проверки того, что fan-out действительно улучшил задержку, а не просто увеличил нижестоящую нагрузку.


Критический путь важнее числа задач

Параллельное выполнение сокращает устранимую сериализацию, но не убирает понятие критического пути. Если загрузка профиля занимает 40 миллисекунд, заказы — 60 миллисекунд, а рекомендации — 90 миллисекунд, параллельное выполнение может сократить суммарное время ожидания примерно со 190 миллисекунд до около 90. Однако если рекомендации внезапно занимают 900 миллисекунд, вся операция всё равно займёт примерно 900 миллисекунд, пока рекомендации остаются обязательными.

Это означает, что структурированную конкурентность не следует рассматривать как универсальный оптимизатор задержки. Она устраняет ожидание, существующее только потому, что независимая работа выполнялась последовательно. Она не делает самую медленную обязательную зависимость быстрее.

Те же рассуждения применимы к многоэтапным графам запросов. Предположим, одна ветвь занимает 70 миллисекунд, а затем запускает другую зависимую операцию, занимающую 100 миллисекунд. Даже если несколько других ветвей завершаются за 40 или 80 миллисекунд, критический путь всё равно может составлять 170 миллисекунд. Оптимизация ветви, не находящейся на этом пути, может почти не повлиять на сквозную задержку.

Именно поэтому структурированная конкурентность хорошо сочетается с распределённой трассировкой. Как только запрос представлен как структурированное дерево исполнения, инженеры могут определить, какие ветви перекрываются, какие доминируют на критическом пути и где работа по оптимизации реально повлияет на видимую пользователю задержку.


Политики fail-fast, wait-for-all и first-success

Не каждая конкурентная операция имеет одинаковую семантику завершения. Некоторые операции требуют успеха всех дочерних элементов, тогда как другим может понадобиться только первый успешный результат.

Дашборд, требующий профиль, права и заказы, естественно является операцией «все успешны». Если один обязательный компонент падает, полный результат не может быть сконструирован. Резервный поиск провайдера — другое дело. Если несколько эквивалентных провайдеров могут предоставить одну и ту же информацию, приложение может захотеть того, кто успеет первым, и отменить остальную работу.

Java 25 представляет эти политики через Joiner. Например, операция first-success концептуально может быть выражена так:

try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<Price>anySuccessfulResultOrThrow())) {

    scope.fork(() -> providerA.getPrice(productId));
    scope.fork(() -> providerB.getPrice(productId));
    scope.fork(() -> providerC.getPrice(productId));

    return scope.join();
}
StructuredTaskScope.open(
    StructuredTaskScope.Joiner.anySuccessfulResultOrThrow<Price>()
).use { scope ->
    scope.fork { providerA.getPrice(productId) }
    scope.fork { providerB.getPrice(productId) }
    scope.fork { providerC.getPrice(productId) }

    return scope.join()
}

Как только один провайдер успевает, результаты других ветвей могут больше не иметь значения, поэтому их можно отменить. Это ещё один пример того, почему структурированная конкурентность — больше, чем просто запуск потоков. Область выражает политику завершения для логической операции.

Поэтому правильную политику join следует выбирать из бизнес-семантики. Приложение должно решить, требуются ли все результаты, достаточно ли первого успешного результата или приемлем частичный отказ, а затем использовать модель конкурентности, соответствующую этому решению.


Держите параллелизм на слое оркестрации

Одно из самых полезных архитектурных свойств структурированной конкурентности — она позволяет конкурентности оставаться локальной. Репозиторию не нужно возвращать фьючерс только потому, что один вызывающий может в итоге захотеть запустить его параллельно с другим репозиторием. HTTP-клиенту не нужен асинхронный тип возврата лишь потому, что некоторый слой оркестрации хочет выдать несколько независимых запросов сразу.

Вместо раскрытия:

CompletionStage<Profile> getProfile(long id);

CompletionStage<List<Order>> getOrders(long id);

CompletionStage<List<Product>> getRecommendations(long id);
fun getProfile(id: Long): CompletionStage<Profile>

fun getOrders(id: Long): CompletionStage<List<Order>>

fun getRecommendations(id: Long): CompletionStage<List<Product>>

компоненты могут сохранять прямые контракты:

Profile getProfile(long id);

List<Order> getOrders(long id);

List<Product> getRecommendations(long id);
fun getProfile(id: Long): Profile

fun getOrders(id: Long): List<Order>

fun getRecommendations(id: Long): List<Product>

Сервис, который на самом деле понимает граф зависимостей, решает, когда эти вызовы должны выполняться конкурентно.

Это локализует конкурентность там, где ей место. Слой оркестрации знает, какие операции независимы, какие отказы обязательны, какие ветви опциональны и какой дедлайн применим к общему результату. Низкоуровневые компоненты в целом должны оставаться в неведении о том, вызывает ли их вызывающий последовательно или внутри структурированной области.

Этот подход также предотвращает распространение абстракций конкурентности через несвязанные части кодовой базы. Один эндпоинт, нуждающийся в fan-out, не заставляет репозиторий, клиент, сервис, контроллер и тестовые слои становиться асинхронными от начала до конца.


Структурированная область — не область приложения

StructuredTaskScope обычно должен быть короткоживущим и привязанным к конкретной операции. Это не замена общеприкладному исполнителю или пулу рабочих потоков.

Предполагаемый жизненный цикл выглядит так:

запрос
  |
  +-- область
      |
      +-- дочерний
      +-- дочерний
      +-- дочерний

Область открывается близко к точке, где появляется независимая работа, и закрывается до того, как эта операция вернётся. Граф внедрения зависимостей Kora управляет долгоживущими компонентами приложения, такими как клиенты, репозитории, пулы и сервисы. Структурированные области задач управляют временными отношениями между конкурентными задачами.

Эти два жизненных цикла не следует смешивать. Общеприкладная структурированная область в значительной степени свела бы на нет цель структурированной конкурентности, потому что у задач больше не было бы ясного лексического владельца или границы завершения.


Дешёвые потоки не означают безграничную конкурентность

Виртуальные потоки делают заблокированные потоки недорогими по сравнению с платформенными, но они не убирают нижестоящие узкие места. Сервис может создавать тысячи виртуальных потоков, пока его база данных всё ещё имеет пул из 50 соединений, а его удалённая зависимость всё ещё принимает лишь несколько сотен запросов в секунду.

Это различие становится особенно важным при fan-out. Предположим, эндпоинт разветвляет 20 операций JDBC на запрос, и сервис получает 1000 конкурентных запросов. Приложение может создать спрос на 20 000 операций базы данных, хотя пул содержит только 50 соединений. Сами виртуальные потоки — не ограничивающий ресурс; они просто ждут доступа к гораздо более дефицитному ресурсу.

То же самое применимо к пулам HTTP-соединений, API с ограничением скорости, партициям Kafka, ядрам CPU, файловым дескрипторам, пропускной способности хранилища или внешним сервисам. Структурированная конкурентность улучшает то, как организованы задачи, но не увеличивает ёмкость систем, от которых они зависят.

Fan-out также может резко усиливать трафик. Сервис, получающий 5000 запросов в секунду и выполняющий пять нижестоящих вызовов на запрос, может генерировать примерно 25 000 нижестоящих вызовов в секунду до учёта повторов. Если затем каждая зависимость агрессивно повторяет во время инцидента, трафик может умножиться дальше. Параллельное выполнение может сократить задержку отдельного запроса, одновременно увеличивая мгновенное давление на нижестоящие системы.

По этой причине введение fan-out следует рассматривать как архитектурное решение о ёмкости, а не чисто локальную оптимизацию кода.


Работа с базой данных требует дополнительной осторожности

JDBC естественно работает с виртуальными потоками, потому что блокировка на операции JDBC больше не означает выделение одного дорогого платформенного потока на запрос. Однако это не означает, что каждую операцию базы данных следует параллелизовать.

Особенно важный случай — транзакция, использующая одно соединение. Если несколько операций являются частью одной транзакции и зависят от последовательного состояния базы данных, выполнение их в отдельных дочерних задачах обычно некорректно. Структурированная конкурентность не меняет семантику транзакций и не делает одно соединение JDBC безопасно конкурентным.

Параллельная работа с базой данных более уместна, когда операции действительно независимы и могут получить независимые соединения, например несвязанные read-only запросы. Даже тогда стоимость для пула соединений и базы данных нужно тщательно учитывать.

Поэтому правильный вопрос — не может ли StructuredTaskScope выполнять несколько вызовов репозитория одновременно. Может. Релевантный вопрос — семантически ли независимы операции и могут ли ресурсы под ними безопасно и эффективно поддерживать дополнительную конкурентность.


CPU-нагруженная работа имеет другую модель стоимости

Структурированная конкурентность особенно привлекательна для I/O-нагруженных серверных рабочих нагрузок, потому что виртуальные потоки могут дёшево блокироваться, ожидая сетевых, базовых или файловых операций. CPU-нагруженная работа ведёт себя иначе.

Если три дочерние задачи все выполняют существенные вычисления, у машины всё равно конечное число ядер процессора. Разветвление тысяч CPU-тяжёлых виртуальных потоков не создаёт дополнительную вычислительную мощность. Оно просто создаёт больше исполняемой работы, которая должна конкурировать за те же CPU.

Структурированная конкурентность всё ещё может быть полезна для выражения отношения между CPU-нагруженными задачами, но ограниченный параллелизм становится гораздо важнее. Наличие виртуальных потоков не следует интерпретировать как разрешение убрать все ограничения конкурентности из вычислительно тяжёлых операций.

Это различие фундаментально: виртуальные потоки прежде всего улучшают экономику ожидания, а не экономику вычислений.


Вложенная структурированная конкурентность

Реальные графы запросов часто иерархичны, а не плоски. Запрос может параллельно загружать профиль, коммерческую информацию и данные персонализации. Коммерческая ветвь может сама конкурентно загружать заказы и возвраты, а персонализация — конкурентно получать рекомендации и назначения экспериментов.

Это даёт такое дерево:

запрос
├── профиль
├── коммерция
│   ├── заказы
│   └── возвраты
└── персонализация
    ├── рекомендации
    └── эксперименты

Структурированная конкурентность обрабатывает это естественно, потому что дочерняя операция может открыть собственную вложенную область. Метод loadCommerce() может оставаться синхронным для своего вызывающего, внутренне выполняя заказы и возвраты конкурентно.

CommerceData loadCommerce(long id) throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        var orders = scope.fork(() -> orders(id));
        var refunds = scope.fork(() -> refunds(id));

        scope.join();

        return new CommerceData(
            orders.get(),
            refunds.get()
        );
    }
}
fun loadCommerce(id: Long): CommerceData {
    StructuredTaskScope.open<Any>().use { scope ->
        val orders = scope.fork { orders(id) }
        val refunds = scope.fork { refunds(id) }

        scope.join()

        return CommerceData(
            orders.get(),
            refunds.get()
        )
    }
}

Родительской операции не нужно знать, как loadCommerce() достигает своего результата. Она просто видит обычный синхронный вызов метода. Это сохраняет инкапсуляцию, позволяя конкурентность на нескольких уровнях графа запроса.

Вложенные структуры также усиливают важность распространения дедлайнов. Если корневой запрос начинается с бюджета в 500 миллисекунд и тратит 80 миллисекунд до вызова вложенной области, вложенная операция не должна получать свежие 500 миллисекунд. У неё осталось примерно 420 миллисекунд. Любые более глубокие дочерние элементы должны работать от оставшегося бюджета снова.


Частичные результаты и корректная деградация

Некоторые эндпоинты должны возвращать полезные данные, даже когда опциональные зависимости падают. Структурированная конкурентность не требует, чтобы каждая ветвь разделяла одну и ту же политику ошибок.

Представьте страницу, содержащую баланс счёта, недавние транзакции, рекомендации и маркетинговый баннер. Баланс и история транзакций могут быть обязательными, а рекомендации и баннер — опциональными. Если опциональные вызовы падают, возврат остальной части страницы может быть предпочтительнее провала запроса.

Это поведение следует представлять явно. Опциональная ветвь может преобразовать свой отказ зависимости в пустое значение, объект-запасной или типизированный результат, описывающий недоступность. Важный момент в том, что деградированное поведение намеренно и видимо в доменной модели, а не возникает случайно из проглоченных исключений.

Для более сложных случаев приложение может использовать явный тип результата:

sealed interface DependencyResult<T> {

    record Success<T>(T value)
        implements DependencyResult<T> {
    }

    record Unavailable<T>(Throwable cause)
        implements DependencyResult<T> {
    }
}
sealed interface DependencyResult<T> {

    data class Success<T>(val value: T) : DependencyResult<T>

    data class Unavailable<T>(val cause: Throwable) : DependencyResult<T>
}

Это позволяет слою оркестрации различать успешные данные и недоступную опциональную зависимость, не превращая каждую локальную проблему в глобальный отказ запроса.

Структурированная конкурентность предоставляет механизм жизненного цикла и координации. Бизнес-слой по-прежнему определяет, какие отказы имеют значение.


Структурированная конкурентность и отказоустойчивость Kora

Kora уже предоставляет механизмы отказоустойчивости, такие как таймаут, повтор, предохранитель и запасной вариант. Структурированная конкурентность дополняет эти механизмы, а не заменяет их.

Предохранитель отвечает, следует ли вообще пытаться выполнить вызов зависимости. Политика повтора решает, следует ли повторить упавшую операцию зависимости. Таймаут зависимости ограничивает, как долго может длиться один вызов. Структурированная конкурентность отвечает на другой вопрос: как несколько связанных вызовов зависимостей должны жить и завершаться как часть одной родительской операции.

Поэтому типичный запрос может иметь такую структуру:

Структурированная операция запроса
|
+-- Клиент профиля
|   +-- предохранитель
|   +-- таймаут
|   +-- опциональный повтор
|
+-- Клиент заказов
|   +-- предохранитель
|   +-- таймаут
|
+-- Клиент рекомендаций
    +-- предохранитель
    +-- запасной вариант

Структурированная область координирует слой оркестрации. Каждая зависимость сохраняет собственную политику отказоустойчивости.

Повторы заслуживают особого внимания, потому что fan-out может их усиливать. Если один запрос разветвляет пять зависимостей и каждая может повторить три раза, этот единственный запрос может потенциально создать около пятнадцати нижестоящих попыток. Под нагрузкой это может стать значительным фактором усиления. Поэтому политика повтора должна учитывать родительский дедлайн, оставшийся бюджет, состояние предохранителя, идемпотентность и нижестоящую ёмкость.

Дочерняя операция не должна продолжать повторять две секунды, когда у запроса, который ею владеет, осталось всего 100 миллисекунд.


Отмена не означает откат

Структурированную конкурентность легче всего рассуждать для операций чтения, потому что отмена чтения обычно означает отказ от ненужной работы. Операции с побочными эффектами сложнее.

Предположим, сервис разветвляет chargeCard, createOrder и sendMessage. Если createOrder падает и область отменяет другие дочерние элементы, эта отмена не гарантирует, что списание с карты ещё не было зафиксировано. Задача может быть отменена до побочного эффекта, во время него или после того, как внешняя система уже его приняла.

Поэтому структурированная отмена — не механизм распределённых транзакций. Она не предоставляет семантику отката между удалёнными системами. Рабочие процессы с побочными эффектами всё ещё могут требовать ключей идемпотентности, транзакций базы данных, транзакционного outbox, саг, логики компенсации или долговечного движка рабочих процессов.

Роль структурированной конкурентности — управлять временем жизни исполнения. Она гарантирует, что задачи принадлежат родительской операции, и делает отмену возможной. Она не отменяет эффекты, которые уже произошли за пределами JVM.


Структурированная конкурентность и CompletableFuture

Соблазнительно рассматривать StructuredTaskScope как более приятный синтаксис для CompletableFuture, но у этих двух абстракций разная семантика.

CompletableFuture представляет асинхронный результат, чьё время жизни изначально не ограничено лексической областью, где он был создан. Метод может вернуть фьючерс, пока вычисление продолжается где-то ещё. Это поведение полезно, когда асинхронные значения действительно являются частью API или когда работа намеренно переживает текущий вызов.

Структурированная область задач намеренно навязывает более сильное отношение. Родитель владеет своими дочерними элементами и устанавливает ясную границу вокруг них. Когда структурированная операция заканчивается, её дочерние задачи также должны завершиться, упасть или быть отменены.

Это ограничение полезно, потому что делает время жизни задач легче для рассуждения. Для оркестрации в области запроса более сильная семантика владения обычно ровно то, что хочет приложение.

CompletableFuture и другие асинхронные конструкции остаются уместными для рабочих нагрузок, чьё время жизни намеренно превышает текущий метод, таких как событийно-ориентированные API, долгоживущие конвейеры, точки интеграции фреймворка или работа, переданная другой подсистеме. Если фоновая работа должна пережить HTTP-запрос, её обычно следует передать долговечной очереди, планировщику или системе рабочих потоков, а не отсоединять от запроса как неотслеживаемый виртуальный поток.


Структурированная конкурентность — не parallelStream()

Параллельные стримы также выполняют работу конкурентно, но решают другой класс проблем. Параллельный стрим прежде всего представляет параллелизм данных: применить одно и то же преобразование ко многим элементам коллекции.

Серверный fan-out обычно является параллелизмом задач. Ветви часто имеют разные типы возврата, разные зависимости и разную семантику ошибок. Одна ветвь может загружать профиль, другая — получать заказы, а третья — опрашивать внешнюю систему рекомендаций.

StructuredTaskScope маппится гораздо более прямо на такой гетерогенный тип отношений задач. Он представляет несколько дочерних операций, которые вместе образуют одну родительскую операцию, а не применяет одну операцию единообразно по коллекции.


Выбор того, что параллелизовать

Хорошие кандидаты для структурированного fan-out обычно разделяют несколько характеристик. Операции действительно независимы, их задержка достаточно велика, чтобы перекрытие имело значение, нижестоящие системы могут поглотить дополнительную конкурентность, и операции разделяют целостный родительский жизненный цикл.

Параллелизация трёх удалённых вызовов, занимающих десятки миллисекунд каждый, может значительно сократить время ответа. Параллелизация нескольких преобразований в масштабе микросекунд обычно добавляет сложность без значимой выгоды.

Операции в целом должны оставаться последовательными, когда одна зависит от результата другой, когда они мутируют общее состояние, когда они разделяют транзакционный или иной ресурс с состоянием, или когда параллельное выполнение просто переносит конкуренцию в меньший нижестоящий пул.

Выбор по умолчанию всё ещё должен быть простым последовательным кодом. Структурированная конкурентность ценна именно потому, что её можно вводить локально там, где существует реальная конкурентность, а не делать формой всего приложения по умолчанию.


Производственно-ориентированный паттерн Kora

Поэтому типичный агрегирующий эндпоинт Kora может использовать такую структуру:

HTTP-запрос
|
+-- аутентификация
|
+-- валидация
|
+-- определить оставшийся дедлайн
|
+-- StructuredTaskScope
|      |
|      +-- клиент профиля
|      |      +-- таймаут
|      |      +-- предохранитель
|      |
|      +-- клиент заказов
|      |      +-- таймаут
|      |      +-- предохранитель
|      |
|      +-- клиент рекомендаций
|             +-- таймаут
|             +-- запасной вариант
|
+-- join
|
+-- собрать ответ
|
+-- сериализовать
|
HTTP-ответ

Эта архитектура чисто разделяет обязанности. HTTP-слой остаётся синхронным. Контракт сервиса остаётся синхронным. Отдельные зависимости владеют своими политиками отказоустойчивости. Слой оркестрации владеет параллелизмом. Структурированная область владеет временем жизни дочерних задач. Дедлайн запроса ограничивает всю операцию. Трассировка следует за деревом исполнения.

Самое главное, никакому асинхронному типу возврата не нужно покидать сервис лишь потому, что несколько вызовов происходят конкурентно внутри него.


Java 25 и соображения о preview API

Kora 2.0 нацелен на современную Java, и API структурированной конкурентности, доступный в Java 25, всё ещё является preview API. Это имеет практические следствия для конфигурации сборки и развёртывания, потому что preview-функции должны быть включены и во время компиляции, и при запуске JVM.

Для Gradle конфигурация обычно будет включать:

tasks.withType(JavaCompile).configureEach {
    options.compilerArgs += "--enable-preview"
}

tasks.withType(Test).configureEach {
    jvmArgs += "--enable-preview"
}

tasks.withType(JavaExec).configureEach {
    jvmArgs += "--enable-preview"
}

Производственная JVM также должна запускаться с:

--enable-preview

Это нужно последовательно отражать в локальной разработке, тестах, интеграционных тестах, конфигурациях IDE, дистрибутивах приложения, Docker-образах и командах запуска Kubernetes.

Сам API также эволюционировал через несколько preview JDK. Старые статьи часто показывают ShutdownOnFailure и связанные более ранние API, тогда как Java 25 использует StructuredTaskScope.open(...), Joiner и конфигурацию области. При написании кода Kora 2 примеры следует выравнивать по точной версии JDK, используемой приложением, а не копировать из более старого материала Project Loom.


Приложения на Kotlin

Та же архитектурная модель применима к приложениям на Kotlin, работающим на Kora 2. API уровня фреймворка не должен становиться на основе корутин лишь потому, что задействован I/O. Сервис может раскрывать обычную синхронную функцию, такую как:

fun getDashboard(userId: Long): Dashboard

и выполняться на виртуальном потоке. Когда реализация достигает точки, где несколько независимых операций должны выполняться конкурентно, она может локально использовать JVM API структурированной конкурентности.

Архитектурный принцип остаётся тем же: сохранять обычные синхронные контракты на границах компонентов и вводить явную конкурентность только на слое оркестрации, где граф зависимостей известен.


Частые ошибки

Несколько ошибок стоит избегать при введении структурированной конкурентности в существующее приложение Kora.

Первая — создание областей вокруг каждого метода. Большинство операций не содержат полезного параллелизма, и оборачивание их в конструкцию конкурентности лишь затемняет иначе простой код.

Вторая — допущение, что виртуальные потоки убирают лимиты ёмкости. Они снижают стоимость ожидающих потоков, но пулы баз данных, CPU, удалённые сервисы и лимиты соединений остаются конечными.

Ещё одна частая ошибка — введение одного огромного fan-out, потому что создание дочерних виртуальных потоков дёшево. Запрос, запускающий сотни удалённых вызовов, может легко перегрузить свои зависимости, даже если сама JVM без труда обрабатывает потоки.

Проектирование таймаутов — ещё один источник проблем. Выдача каждой вложенной операции свежего большого таймаута может сделать сквозную задержку намного превышающей исходный бюджет запроса. Дедлайны обычно должны сужаться по мере углубления работы в дерево задач.

Семантику транзакций также необходимо уважать. Структурированная конкурентность не делает одну транзакцию JDBC безопасно параллельной, и отмена не предоставляет откат для удалённых побочных эффектов.

Наконец, фоновую работу не следует просто отсоединять от запроса. Если операция должна пережить жизненный цикл запроса, ей место в долговечной модели фоновой обработки, а не в структурированной области запроса.


Практический контрольный список дизайна

Перед преобразованием последовательного кода в структурированный fan-out полезно ответить на небольшой набор архитектурных вопросов. Действительно ли операции независимы? Достаточно ли задержки, чтобы перекрытие имело значение? Все ли результаты обязательны, или некоторые зависимости могут корректно деградировать? Что должно произойти, когда одна ветвь падает? Можно ли безопасно отменить другие ветви? Правильно ли базовые библиотеки реагируют на прерывание? Правильно ли настроены таймауты транспортного уровня? Каков оставшийся дедлайн запроса? Могут ли нижестоящие системы выдержать повышенную конкурентность? Разделяют ли какие-либо операции транзакцию или другой ресурс с состоянием? Есть ли побочные эффекты, которые отмена не может отменить? Принадлежит ли работа текущему запросу или должна его пережить?

Если у этих вопросов есть ясные ответы, StructuredTaskScope, вероятно, вводится на правильной архитектурной границе.


От потока-на-запрос к структурированным деревьям задач

Самое интересное следствие виртуальных потоков — не просто то, что Java может вернуться к старой модели потока-на-запрос. Новая модель богаче, потому что один запрос может стать структурированным деревом связанных виртуальных потоков.

Запрос может начаться на одном виртуальном потоке, разветвить операции профиля и заказов в два дочерних виртуальных потока, а затем ветвь заказов сама разветвить вычисления мошенничества и доставки. Все эти задачи всё ещё принадлежат одному логическому запросу.

VT запроса
|
+-- VT профиля
|
+-- VT заказа
|   |
|   +-- VT проверки мошенничества
|   +-- VT доставки
|
+-- VT рекомендаций

Традиционные системы потока-на-запрос предлагали прямую синхронную модель программирования, но испытывали трудности, когда требовалось очень большое количество блокирующих платформенных потоков. Реактивные системы решали проблему масштабирования потоков, представляя исполнение через колбэки, издателей, операторы и асинхронные конечные автоматы, но это часто делало контроль потока, стек-трейсы, распространение контекста и отладку более сложными.

Виртуальные потоки восстанавливают недорогое синхронное ожидание. Структурированная конкурентность затем восстанавливает явную иерархию, когда одной синхронной операции нужен внутренний параллелизм. Результат — не просто возврат к архитектуре двадцатилетней давности. Это модель, где один логический запрос представлен одним корневым виртуальным потоком и структурированным деревом дочерних виртуальных потоков, чьё время жизни остаётся привязанным к этому запросу.


Почему структурированная конкурентность подходит Kora

Эта модель особенно хорошо соответствует более широкой философии дизайна Kora. Kora старается держать абстракции фреймворка тонкими и оставаться близко к стандартной Java и базовым технологиям, с которыми он интегрируется. Внедрение зависимостей генерируется на этапе компиляции, репозитории раскрывают прямые типизированные методы, HTTP-контроллеры и клиенты используют обычные сигнатуры, а виртуальные потоки позволяют блокирующим API оставаться практичными при высокой конкурентности.

Структурированная конкурентность следует тому же паттерну. Kora не нуждается в проприетарной абстракции задач или специфичном для фреймворка DSL параллелизма. JDK уже предоставляет примитив, необходимый для выражения конкурентности в области запроса.

Концептуальный стек остаётся небольшим:

Бизнес-код
     |
StructuredTaskScope
     |
Виртуальные потоки
     |
JDK

У этого есть преимущества за пределами эстетики. Стандартные инструменты конкурентности JDK легче отлаживать, профилировать, анализировать и переиспользовать в библиотеках. Они также сокращают семантический разрыв между кодом приложения и поведением времени выполнения под ним.


Архитектурный сдвиг: синхронный не означает последовательный

Долгое время серверная разработка часто рассматривала синхронное и конкурентное программирование как противоположности. Синхронное обычно подразумевало блокирующее и последовательное исполнение, тогда как асинхронные API были основным способом выразить большие объёмы конкурентности.

Виртуальные потоки разрывают это отношение, потому что блокировка больше не требует выделения тяжёлого платформенного потока каждой ожидающей операции. Структурированная конкурентность разрывает его дальше, потому что синхронный метод может внутренне управлять несколькими конкурентными дочерними задачами, всё ещё представляя один простой барьер завершения своему вызывающему.

Такой метод, как:

Dashboard load(long userId)

может внутренне представлять:

load
├── профиль
├── заказы
└── рекомендации

не меняя своего внешнего контракта. Вызывающему не нужно понимать, как планируется работа или сколько дочерних потоков существует. Ему нужна только семантическая гарантия, что когда load() возвращается, работа, связанная с этой операцией, завершена.

Это одно из самых важных архитектурных следствий структурированной конкурентности. Синхронный дизайн API больше не подразумевает последовательное исполнение. Он означает, что у операции есть ясная граница.


Заключение

Структурированная конкурентность — естественное продолжение модели исполнения Kora 2 с приоритетом виртуальных потоков. Виртуальные потоки решают проблему масштабируемости блокирующего I/O, делая ожидающие потоки недорогими. Структурированная конкурентность решает проблему жизненного цикла, которая появляется, когда одной логической операции нужно несколько конкурентных дочерних элементов.

В HTTP-запросе Kora модель остаётся прямолинейной. Запрос выполняется на виртуальном потоке, контроллер вызывает обычный синхронный сервис, и этот сервис может открыть StructuredTaskScope, когда достигает точки, где независимые операции должны выполняться конкурентно. Эти дочерние задачи выполняются параллельно, разделяют ясного родителя, наследуют релевантный контекст области и объединяются до того, как сервис вернётся.

Fan-out сокращает устранимую последовательную задержку. Распространение отказов позволяет связанным задачам падать как одна операция, когда это уместно. Отмена предотвращает продолжение ненужной работы братьев после отказа или таймаута. Дедлайны ограничивают всю конкурентную область. Область запроса даёт дочерним задачам ясного владельца. Трассировка может следовать тому же дереву исполнения, которое приложение на самом деле использует.

Поэтому важный урок — не просто заменить:

profile()
orders()
recommendations()

на:

fork(profile)
fork(orders)
fork(recommendations)
join()

Более глубокая идея — моделировать конкурентную работу как структурированное дерево, в котором время жизни, отказ, отмена, дедлайн и контекст принадлежат операции, создавшей задачи.

Именно это делает StructuredTaskScope фундаментально отличным от простой отправки работы исполнителю, и именно поэтому модель так естественно подходит Kora 2. Фреймворк уже предоставляет синхронный код приложения на виртуальных потоках; структурированная конкурентность добавляет дисциплинированный способ выразить параллелизм, не отказываясь от этой простоты.