🧵 Раздел 9 · Вопрос #25

Что такое structured concurrency

В классической многопоточности (ExecutorService, Thread.start()) потоки являются «неструктурированными» (fire-and-forget): родительский метод может завершиться или упасть с ошиб...


🟢 Junior Level

Краткий ответ для собеседования (30 секунд)

Structured Concurrency (структурированная многопоточность / структурированный параллелизм) — это парадигма программирования (представленная в Java 21 в рамках JEP 453 как Preview API), при которой параллельные задачи, выполняемые в разных потоках, рассматриваются как единая неделимая единица работы со строго определенными лексическими границами времени жизни.

В классической многопоточности (ExecutorService, Thread.start()) потоки являются «неструктурированными» (fire-and-forget): родительский метод может завершиться или упасть с ошибкой, оставив за собой работающие потоки-сироты (Thread Leaks / Orphan Tasks). Structured Concurrency устраняет эту проблему через строгий контракт: «жизненный цикл дочерних потоков строго ограничен временем жизни породившего их блока кода». Если одна из подзадач терпит неудачу, остальные автоматически отменяются, а родитель гарантированно дожидается завершения всех порожденных задач до выхода из скоупа.


Аналогия: Искоренение goto в многопоточности

В 1968 году Эдсгер Дейкстра опубликовал манифест против неструктурированного перехода goto, показав, что неконтролируемые прыжки по коду делают программы непостижимыми для человека. Структурное программирование заменило goto на блоки if, while и вызовы функций: выполнение входит в блок и гарантированно выходит из него.

Классический запуск потока через executor.submit() — это современный аналог goto: выполнение улетает в фоновый поток без обязательств когда-либо вернуться в вызывающий контекст.
Structured Concurrency превращает многопоточность в структурированный блок: поток управления разветвляется (fork) и гарантированно сходится в одну точку (join) внутри конструкции try-with-resources.


Сравнительная таблица: Неструктурированный vs Структурированный параллелизм

Характеристика Классический подход (ExecutorService) Structured Concurrency (StructuredTaskScope)
Отношение задач Потоки не знают друг о друге (разрозненные задачи) Строгая иерархия: Родитель $\to$ Дочерние подзадачи
Утечки потоков (Thread Leaks) Частые (поток продолжает работать при ошибке родителя) Физически невозможны (блок не закроется, пока живы дети)
Реакция на сбой Ручной обход через future.cancel() Автоматическая каскадная отмена (Short-circuiting)
Трассировка и дамп потоков Плоский список тысяч безымянных воркеров Иерархическое дерево вызовов (Parent/Child Tree)
Передача контекста InheritableThreadLocal (медленно, риск утечек) ScopedValue (легковесно, иммутабельно, безопасно)
Связка с Virtual Threads Требует ручного контроля пулов Спроектирован специально под виртуальные потоки

Базовый пример на Java 21

import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;

public class StructuredConcurrencyBasicsDemo {

    public record UserProfile(String user, String orders) {}

    // Обработка запроса: параллельный сбор профиля и заказов
    public UserProfile fetchUserProfile(long userId) throws Exception {
        // Создаем область видимости с политикой: отмена всех при ошибке любой задачи
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            
            // Запускаем подзадачи (автоматически на виртуальных потоках)
            Subtask<String> userTask = scope.fork(() -> queryUserService(userId));
            Subtask<String> ordersTask = scope.fork(() -> queryOrderService(userId));

            // Ждем завершения обоих потоков
            scope.join();
            
            // Если хотя бы один упал — выбросит исключение и отменит оставшиеся!
            scope.throwIfFailed(ex -> new RuntimeException("Сбой агрегации профиля", ex));

            // Результаты гарантированно получены и безопасны к чтению
            return new UserProfile(userTask.get(), ordersTask.get());
        } // try-with-resources гарантирует: к этой фигурной скобке все потоки завершены!
    }

    private String queryUserService(long id) throws Exception {
        Thread.sleep(50); // Имитация I/O
        return "UserData_" + id;
    }

    private String queryOrderService(long id) throws Exception {
        Thread.sleep(80); // Имитация I/O
        return "OrdersList_" + id;
    }
}

🟡 Middle Level

Политики завершения (Shutdown Policies)

Интерфейс StructuredTaskScope предоставляет две стандартные реализации стратегий управления жизненным циклом:

1. ShutdownOnFailure (All-or-Nothing / Все или ничего)

Используется, когда для формирования бизнес-результата требуются все параллельные вычисления (например, агрегация данных в API Gateway).

  • Если одна из задач выбрасывает исключение:
    1. Scope переходит в режим завершения (shutdown);
    2. Всем остальным незавершенным подзадачам немедленно отправляется сигнал Thread.interrupt();
    3. Метод scope.throwIfFailed() пробрасывает причину ошибки в родительский поток.

2. ShutdownOnSuccess<T> (First-Success / Гонка зеркал)

Используется, когда достаточно получить результат от любого первого успешно завершившегося исполнителя (например, запрос к нескольким DNS-серверам или кэш-нодам).

import java.util.concurrent.StructuredTaskScope;

public class FastMirrorService {

    // Опрос трех зеркал: берем самый быстрый ответ, остальные прерываем
    public String getFastestContent(String url) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
            scope.fork(() -> downloadFromHost("mirror-us.cdn.org", url));
            scope.fork(() -> downloadFromHost("mirror-eu.cdn.org", url));
            scope.fork(() -> downloadFromHost("mirror-asia.cdn.org", url));

            scope.join(); // Ждем первого успешного

            // Возвращает результат первого финишировавшего потока;
            // Остальные два потока автоматически получают interrupt()!
            return scope.result();
        }
    }

    private String downloadFromHost(String host, String url) throws Exception {
        // Имитация загрузки
        return "Data from " + host;
    }
}

Механизм кооперативной отмены (Cancellation)

Отмена задач в Structured Concurrency базируется на классическом механизме прерывания Java — Thread.interrupt().

Когда политика scope инициирует закрытие (shutdown()):

  1. Несущие виртуальные потоки получают флаг прерывания;
  2. Все стандартные блокирующие вызовы (Socket.read(), Thread.sleep(), ReentrantLock.lockInterruptibly()) немедленно просыпаются и выбрасывают InterruptedException;
  3. Процессорные циклы обязаны периодически проверять статус:
    while (!Thread.currentThread().isInterrupted()) {
        // Вычисления
    }
    
  4. Метод scope.join() ожидает, пока прерванные потоки корректно завершат блоки finally и освободят ресурсы.

Интеграция с ScopedValue: Контекст без загрязнения памяти

В отличие от InheritableThreadLocal, который глубоко копирует всю карту параметров при рождении каждого потока, ScopedValue разделяет контекст между родителем и fork-задачами безопасно и бесплатно:

public static final ScopedValue<SecurityPrincipal> PRINCIPAL = ScopedValue.newInstance();

public void handleUserRequest(SecurityPrincipal user) throws Exception {
    ScopedValue.where(PRINCIPAL, user).run(() -> {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // Подзадачи автоматически и без оверхеда видят PRINCIPAL из родительского scope!
            scope.fork(() -> auditLog(PRINCIPAL.get()));
            scope.fork(() -> executeOperation(PRINCIPAL.get()));
            scope.join();
        }
    });
}

🔴 Senior Level

Under the Hood: Инварианты структуры и защита от сбоев

StructuredTaskScope на уровне JVM строго контролирует соблюдение инвариантов вызовов, предотвращая ошибки проектирования:

Состояния Scope:
[ OPEN ] ── fork() ──> [ FORKING ] ── join() ──> [ JOINED ] ── close() ──> [ CLOSED ]
  1. Запрет вызова subtask.get() до join():
    Если вызвать subtask.get() до того, как вызван scope.join(), рантайм немедленно выбросит IllegalStateException, так как состояние подзадачи еще неопределено.
  2. Обязательность scope.close():
    Если родительский поток попытается покинуть блок кода без закрытия scope (например, через return или необработанный throw), блок try-with-resources автоматически вызовет close(). Внутри close() вызывается неявный join(), который заблокирует поток до тех пор, пока все запущенные виртуальные потоки гарантированно не остановятся. Ни один поток не может «убежать» из скоупа.

Иерархическая наблюдаемость (Hierarchical Observability)

Главная боль распределенных систем на классических Thread Pools — потеря контекста: при снятии jstack все 500 воркеров пула выглядят одинаково, и невозможно понять, какой поток ОС выполняет чей HTTP-запрос.

Structured Concurrency внедряет в рантайм дерево задач (Task Hierarchy):

# Снятие дампа потоков в формате JSON:
jcmd <PID> Thread.dump_to_file -format=json thread_dump.json

Вывод отражает точную древовидную структуру отношений:

{
  "thread": "VirtualThread[#45,tomcat-handler-1]",
  "scope": "StructuredTaskScope$ShutdownOnFailure[#12]",
  "children": [
    { "thread": "VirtualThread[#46,fork-queryUserService]" },
    { "thread": "VirtualThread[#47,fork-queryOrderService]" }
  ]
}

Инженер службы эксплуатации видит причинно-следственную связь: «Поток #47 завис в чтении базы данных, задерживая родительский запрос Tomcat-handler-1».


Реализация пользовательской политики (Custom Policy): Кворум

Senior-инженер может переопределить метод handleComplete(Subtask<T>) для реализации произвольной стратегии согласования (например, паттерн Quorum: ждать ответов от 2 из 3 реплик):

import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.Collection;
import java.util.Collections;

public class QuorumScope<T> extends StructuredTaskScope<T> {
    private final int threshold;
    private final Collection<T> successes = new ConcurrentLinkedQueue<>();
    private final Collection<Throwable> failures = new ConcurrentLinkedQueue<>();

    public QuorumScope(int threshold) {
        this.threshold = threshold;
    }

    @Override
    protected void handleComplete(Subtask<? extends T> subtask) {
        if (subtask.state() == Subtask.State.SUCCESS) {
            successes.add(subtask.get());
            if (successes.size() >= threshold) {
                // Кворум набран — отменяем оставшиеся медленные ноды!
                this.shutdown();
            }
        } else if (subtask.state() == Subtask.State.FAILED) {
            failures.add(subtask.exception());
        }
    }

    public Collection<T> getQuorumResults() {
        super.ensureOwnerAndJoined();
        if (successes.size() < threshold) {
            throw new RuntimeException("Кворум не достигнут. Сбои: " + failures);
        }
        return Collections.unmodifiableCollection(successes);
    }
}

🎯 Шпаргалка для интервью

1. 30-секундный эссенс (Elevator Pitch)

Structured Concurrency (Java 21, JEP 453) структурирует параллельный код так же, как блоки if/while структурируют императивный код. Все параллельные подзадачи объединяются в единый логический scope внутри try-with-resources. Модель исключает утечки потоков («потоки-сироты»), обеспечивает автоматическую каскадную отмену всех задач при падении одной (ShutdownOnFailure) или при первом успешном ответе (ShutdownOnSuccess), и восстанавливает иерархическую видимость потоков в дампе памяти (jcmd).


2. 4 каверзных вопроса с глубокими ответами

Вопрос 1: В чём концептуальная аналогия между искоренением оператора goto Эдсгером Дейкстрой в 1968 году и появлением Structured Concurrency?

Ответ: Оператор goto позволял совершать неконтролируемые прыжки в произвольные адреса памяти, разрушая ментальную модель программы и делая невозможным локальный анализ кода. Классический запуск потока (new Thread().start() или executor.submit()) — это многопоточный аналог goto: поток запускается асинхронно, теряет связь со стеком вызова, и компилятор/рантайм не контролируют момент его возврата. Structured Concurrency устраняет этот хаос, создавая строгую структуру: параллельные ветви исполнения гарантированно порождаются и схлопываются внутри единого блока try-with-resources.

Вопрос 2: Что произойдет, если в блоке StructuredTaskScope забыть вызвать метод join() до обращения к subtask.get() или до выхода из блока?

Ответ:

  1. Если вызвать subtask.get() до вызова scope.join(), метод немедленно выбросит IllegalStateException: Subtask not completed. По спецификации API читать результат можно только после того, как родитель явно дождался завершения вычислений.
  2. Если забыть вызвать join() и попытаться выйти из блока try-with-resources, метод scope.close() автоматически заблокирует текущий поток и выполнит внутренний join(), пока все дочерние потоки не завершатся. Таким образом, уйти из метода с работающими «в фоне» потоками технически невозможно.

Вопрос 3: Как StructuredTaskScope взаимодействует с прерыванием родительского потока?

Ответ: Если родительский поток, ожидающий на scope.join(), получает внешний сигнал прерывания (Thread.currentThread().interrupt()), метод join() немедленно перехватывает его, переводит scope в состояние shutdown и автоматически распространяет сигнал interrupt() на все порожденные через fork() дочерние подзадачи. Затем join() выбрасывает InterruptedException. Это гарантирует, что при отмене родительской транзакции ни одна дочерняя операция не останется выполняться вхолостую.

Вопрос 4: Чем передача контекста через ScopedValue внутри StructuredTaskScope эффективнее и безопаснее, чем через InheritableThreadLocal?

Ответ: InheritableThreadLocal при создании каждого дочернего потока выполняет глубокое клонирование всей внутренней хеш-таблицы переменных родителя в куче. При тысячах виртуальных потоков это вызывает колоссальные аллокации памяти и риск утечек, если контекст не очищен через remove().
ScopedValue неизменяем (Immutable) и привязан к границам лексического блока. Внутри StructuredTaskScope дочерние потоки просто получают ссылку на ту же самую неизменяемую область памяти родителя без аллокаций новых таблиц, а сборка мусора происходит автоматически при завершении блока.


3. Типичные ошибки и Red Flags

❌ “Использование StructuredTaskScope для фоновых очередей и задач-демонов.”
Правильно: Structured Concurrency спроектирован для задач с четкими границами начала и конца (Fork-Join сценарии). Для вечноживущих обработчиков событий (Kafka consumer, background queue worker) по-прежнему используется ExecutorService.

❌ “Игнорирование статуса прерывания в цикле подзадачи.”
Правильно: Если задача выполняет длительный CPU-цикл и не опрашивает Thread.currentThread().isInterrupted(), каскадная отмена scope.shutdown() не сможет остановить поток, и scope.close() заблокируется на неопределенное время.

❌ “Использование Structured Concurrency как стабильного API в production без preview флага.”
Правильно: В Java 21 StructuredTaskScope является Preview-функциональностью (--enable-preview), его сигнатура дорабатывается в Java 22/23.


4. Связанные вопросы