🧵 Розділ 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.


4. Пов’язані питання