Питання 25 · Розділ 9

Що таке structured concurrency?

Structured Concurrency (структурована конкурентність) — це модель програмування, де група пов'язаних задач, що виконуються в різних потоках, розглядається як єдиний блок роботи...

Мовні версії: English Russian Ukrainian

Structured Concurrency (структурована конкурентність) — це модель програмування, де група пов’язаних задач, що виконуються в різних потоках, розглядається як єдиний блок роботи з чіткими межами. Ключова ідея: якщо задача породила дочірні задачі, вона зобов’язана дочекатися їх завершення. Це аналог try-with-resources для багатопоточності.

Чому це важливо: у класичній багатопоточності потоки живуть “самі по собі” — батьківський метод може завершитися, а породжені потоки продовжать працювати (або утечуть). Structured Concurrency гарантує: на момент виходу з блоку всі дочірні задачі завершено — або успішно, або з помилкою.

Важливе застереження: StructuredTaskScope доступний починаючи з Java 21 як Preview API. Це означає, що API може змінитися у майбутніх версіях. Для production-використання на Java 17- використовуйте ExecutorService + CompletableFuture.


Junior рівень

Базове розуміння

Structured Concurrency (структурована конкурентність) — це модель програмування, де група пов’язаних задач, що виконуються в різних потоках, розглядається як єдиний блок роботи з чіткими межами.

Чому класичний підхід проблематичний: коли ви створюєте потоки через new Thread().start(), вони стають “сиротами” — батьківський метод не знає про їх статус, не може скасувати їх при помилці, і не гарантує їх завершення. Structured Concurrency вирішує це через принцип: “батько чекає дітей”.

Проблема класичної багатопоточності

// Потоки-"сироти" — запустив і забув
public void process() {
    Thread t1 = new Thread(() -> fetchUserData());
    Thread t2 = new Thread(() -> fetchOrderHistory());
    t1.start();
    t2.start();
    // Що якщо t1 впав з помилкою? t2 продовжить працювати!
    // Що якщо основний метод впав? Потоки залишаться!
}

Рішення: StructuredTaskScope (Java 21+ Preview)

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User> user = scope.fork(() -> fetchUser(id));
    Subtask<Order> orders = scope.fork(() -> fetchOrders(id));

    scope.join();            // Чекаємо всі задачі
    scope.throwIfFailed();   // Якщо одна впала — решту скасовано

    // Обидва результати доступні
    return new Response(user.get(), orders.get());
}
// На момент виходу з try-with-resources всі потоки завершено!

Аналогія

Класичні потоки:           Structured Concurrency:
  Батько                    Батько
    │                          │
    ├→ Потік 1 (сирота)       ├→ Задача 1 (дочірня)
    ├→ Потік 2 (сирота)       ├→ Задача 2 (дочірня)
    └→ Завершується           └→ Чекає дітей
                                 │
                              Всі діти завершено → Батько завершується

Middle рівень

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

1. ShutdownOnFailure

Якщо хоча б одна підзадача впала — решту скасовується:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<Data> data = scope.fork(() -> fetchData());
    Subtask<Config> config = scope.fork(() -> fetchConfig());

    scope.join();
    scope.throwIfFailed();

    // Якщо fetchData() впав — fetchConfig() автоматично скасовано
    process(data.get(), config.get());
}

Коли: Паралельне виконання кроків одного бізнес-процесу.

2. ShutdownOnSuccess

Як тільки перша задача повернула результат — решту скасовується:

try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    scope.fork(() -> fetchFromServer1(url));
    scope.fork(() -> fetchFromServer2(url));
    scope.fork(() -> fetchFromServer3(url));

    scope.join();
    String fastestResult = scope.result(); // Результат першого завершеного
    // Решта запитів автоматично скасовано!
}

Коли: Запити до дзеркал (хто швидше відповість).

Порівняння з ExecutorService

Характеристика ExecutorService StructuredTaskScope
Життєвий цикл Не обмежений областю видимості Строго всередині try-with-resources
Управління помилками Ручне (через Future) Автоматичне (Policy-based)
Скасування Ручне (future.cancel()) Автоматичне (каскадне)
Context Propagation Складно (потрібні обгортки) Підтримує Scoped Values
Витоки потоків Можливі Неможливі

Error Propagation

// ExecutorService — помилка "тихо вмирає"
Future<?> future = executor.submit(() -> {
    throw new RuntimeException("Помилка!");
});
// Ніхто не дізнається, поки не викличете future.get()

// StructuredTaskScope — помилка летить до батька
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    scope.fork(() -> {
        throw new RuntimeException("Помилка!");
    });
    scope.join();
    scope.throwIfFailed(); // → ExecutionException автоматично
}

Чому це революція у відлагодженні?

Спостережуваність (Observability)

Дампи потоків тепер ієрархічні:

jcmd <pid> Thread.dump_to_file -format=json threads.json
{
  "threads": [
    {
      "name": "main",
      "children": [
        {
          "name": "Scope-1/Worker-1",
          "task": "fetchUser"
        },
        {
          "name": "Scope-1/Worker-2",
          "task": "fetchOrders"
        }
      ]
    }
  ]
}

Senior рівень

Under the Hood: Як працює StructuredTaskScope

// Спрощено
public abstract class StructuredTaskScope<T> implements AutoCloseable {
    private final ThreadFactory factory;
    private final Set<Subtask<T>> subtasks = ConcurrentHashMap.newKeySet();
    private volatile boolean closed = false;

    public <U extends T> Subtask<U> fork(Callable<? extends U> task) {
        if (closed) throw new IllegalStateException("Scope closed");

        Thread thread = factory.newThread(() -> {
            try {
                U result = task.call();
                handleSuccess(result);
            } catch (Throwable t) {
                handleFailure(t);
            }
        });
        thread.start();
        // ...
    }

    @Override
    public void close() {
        // Гарантує: всі підзадачі завершено на момент виходу
        join();
    }
}

Resource Safety

Модель гарантує, що на момент виходу з try всі потоки завершено:

// НЕМОЖЛИВО:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    scope.fork(() -> longRunningTask());
    return; // ← Компілятор не дасть! Scope має бути закритий
}
// На момент виходу з } — всі задачі завершено

Інтеграція з Virtual Threads

// StructuredTaskScope спроєктований для Virtual Threads
try (var scope = new StructuredTaskScope.ShutdownOnFailure>(
        Thread.ofVirtual().factory())) {
    // Створюємо мільйон підзадач — VT масштабуються
    for (int i = 0; i < 1_000_000; i++) {
        scope.fork(() -> processItem(i));
    }
    scope.join();
}
// Створення scope на кожен запит — дешева операція

Scoped Values Integration

// Передача контексту без ThreadLocal оверхеду
static final ScopedValue<UserContext> CONTEXT = new ScopedValue<>();

try (var scope = new StructuredTaskScope.ShutdownOnFailure>()) {
    ScopedValue.runWhere(CONTEXT, userContext, () -> {
        scope.fork(() -> {
            // CONTEXT.get() доступний у всіх підзадачах
            return fetchUser(CONTEXT.get().userId());
        });
        scope.fork(() -> {
            return fetchOrders(CONTEXT.get().userId());
        });
    });
    scope.join();
}

Діагностика

JSON Thread Dump

# Лише цей формат показує ієрархію!
jcmd <pid> Thread.dump_to_file -format=json threads.json

# Звичайний текстовий дамп ієрархію НЕ показує
jstack <pid>

Programmatically

try (var scope = new StructuredTaskScope.ShutdownOnFailure>()) {
    Subtask<?> task = scope.fork(() -> doWork());

    // Перевірка статусу
    System.out.println(task.state()); // RUNNING, SUCCESS, FAILED

    scope.join();

    if (task.state() == Subtask.State.SUCCESS) {
        System.out.println(task.get());
    }
}

Коли використовувати

Сценарій StructuredTaskScope ExecutorService
Бізнес-транзакція (кілька кроків) Так Ні
Запит до дзеркал Так (ShutdownOnSuccess) Ні
Довготривалий фоновий сервіс Ні Так
Неперервна обробка черги Ні Так
Паралельні незалежні задачі Так Так

Best Practices

  1. Використовуйте для бізнес-транзакцій — коли кілька кроків мають завершитися разом
  2. ShutdownOnFailure — коли всі кроки обов’язкові
  3. ShutdownOnSuccess — для запитів до дзеркал
  4. Комбінуйте з VT — для масштабування
  5. Scoped Values — для передачі контексту замість ThreadLocal
  6. try-with-resources — гарантія завершення всіх потоків
  7. JSON thread dump — для відлагодження ієрархії
  8. Не для довготривалих сервісів — використовуйте ExecutorService

Коли НЕ використовувати Structured Concurrency

  • Довготривалі фонові сервіси (демони, обробники черг) — StructuredTaskScope вимагає завершення всіх задач при виході з блоку. Для “вічних” задач потрібен ExecutorService
  • Java нижче 21 — API у preview, може змінитися. Використовуйте CompletableFuture + ExecutorService
  • Незалежні паралельні задачі без спільної транзакції — якщо задачі дійсно незалежні, простіше використовувати executor.submit() без overhead scope
  • Потрібен повний контроль над життєвим циклом — якщо вам потрібно вручну скасовувати, призупиняти, відновлювати задачі, ExecutorService гнучкіший

StructuredTaskScope vs ExecutorService vs CompletableFuture: що обрати?

Ситуація Вибір Чому
Бізнес-транзакція (fetchUser + fetchOrders разом) StructuredTaskScope Авто скасування при помилці, гарантія завершення
Запит до дзеркал (хто швидше) StructuredTaskScope.ShutdownOnSuccess Авто скасування повільних
Фонова обробка черги ExecutorService Нескінченний цикл, scope не підходить
Callback-ланцюжок (thenApply, thenCompose) CompletableFuture Асинхронний pipeline, не блокує
Паралельні незалежні задачі Будь-який Але StructuredTaskScope простіший для відлагодження

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • Structured Concurrency: група задач = єдиний блок; батько чекає завершення всіх дочірніх задач
  • Аналог try-with-resources для багатопоточності: на виході з блоку всі потоки гарантовано завершено
  • ShutdownOnFailure: одна впала — решту скасовується (бізнес-транзакції)
  • ShutdownOnSuccess: перша завершилася — решту скасовується (запити до дзеркал)
  • StructuredTaskScope — Preview API в Java 21, може змінитися
  • Інтеграція з Virtual Threads та Scoped Values для масштабованості та передачі контексту
  • Відмінність від ExecutorService: немає витоків потоків, автоматичне скасування, ієрархічний thread dump

Часті уточнюючі запитання:

  • Чому це краще ExecutorService + CompletableFuture? — ExecutorService: потоки-“сироти”, ручне скасування, можливий витік. SC: автоматичне управління життєвим циклом, каскадне скасування, неможливість витоку
  • Як SC працює з Virtual Threads? — SC створюється з Thread.ofVirtual().factory() — всі fork-задачі виконуються на VT, масштабуються до мільйонів
  • Коли НЕ використовувати SC? — Для довготривалих фонових сервісів (демони, обробники черг) — SC вимагає завершення при виході з блоку
  • Як відлагоджувати SC? — JSON thread dump (jcmd Thread.dump_to_file -format=json) показує ієрархію батько-дитина

Червоні прапорці (НЕ говорити):

  • ❌ “Structured Concurrency — це стабільний API в Java 21” — StructuredTaskScope все ще в Preview
  • ❌ “SC замінює ExecutorService повністю” — SC для бізнес-транзакцій, ExecutorService для довготривалих сервісів
  • ❌ “SC автоматично обробляє всі винятки” — потрібно викликати throwIfFailed() після join()
  • ❌ “Можна повернути результат з блоку SC до його завершення” — блок SC має бути закритий (try-with-resources), всі задачі мають завершитися до виходу

Пов’язані теми:

  • [[23. Що таке Virtual Threads в Java 21]]
  • [[24. В чому перевага Virtual Threads перед звичайними потоками]]
  • [[25. Коли варто використовувати Virtual Threads]]
  • [[28. Що таке Callable та Future]]