JavaRush /Курси /JAVA 25 SELF /Structured Concurrency

Structured Concurrency

JAVA 25 SELF
Рівень 58 , Лекція 0
Відкрита

1. Вступ

Коли потоки грають кожен свою партію

Звичайна багатопоточність часто нагадує репетицію без диригента. Кожен потік — як музикант, який грає свою мелодію, не слухаючи інших. Хтось закінчив раніше й пішов курити, хтось застряг на одному акорді, хтось взагалі переплутав ноти й видав помилку. У результаті виходить не симфонія, а какофонія: розібратися, хто де збився, майже неможливо, а зупинити всіх разом — ще той квест.

Structured Concurrency розв’язує цю проблему. Вона робить із розрізнених потоків справжній ансамбль: усі завдання об’єднані під одним «диригентом». Якщо він дає відбій — оркестр замовкає. Якщо один музикант помилився — решта акуратно зупиняються, не ламаючи загальну гармонію. Усі результати й помилки збираються централізовано, а не розкидані по кутках коду.

Уявіть: ви не кидаєте музикантів грати хто в що здатний, а збираєте їх в одному залі. Є диригент, є партитура, і навіть якщо труба фальшивить — оркестр не зриватиметься, а завершить виступ гідно.

Що дає Structured Concurrency

  • Єдиний «scope» завдання: усі підзадачі живуть у межах одного блоку коду, їхній життєвий цикл обмежений цим блоком.
  • Прогнозоване завершення: батьківський потік не завершиться, доки не завершаться усі підзадачі.
  • Централізоване скасування: якщо одне завдання впало або батьківський вирішив завершитися — усі підзадачі коректно скасовуються.
  • Узгоджене оброблення помилок: помилки підзадач агрегуються, можна отримати «дерево причин» (tree of causes).
  • Чистий і читабельний код: немає «висячих» потоків, немає забутих завдань, немає перегонів за скасуванням.

Structured Concurrency — це не просто новий API, а новий стиль мислення: завдання мають бути структуровані так само, як і звичайні блоки коду (наприклад, try-with-resources).

2. Статус Structured Concurrency у Java

На момент написання курсу Structured Concurrency перебуває у статусі Preview (Java 21–23), але очікується просування до GA (General Availability) у Java 24/25. API знаходиться в пакеті jdk.incubator.concurrent. Перед використанням у продакшені обов’язково перевірте актуальні примітки до випуску вашої версії JDK!

Основні класи:

  • StructuredTaskScope — базовий клас для керування групою завдань.
  • Варіанти: StructuredTaskScope.ShutdownOnFailure, StructuredTaskScope.ShutdownOnSuccess — політики завершення завдань.

Основні концепції StructuredTaskScope

Модель: fork, join і дружня перекличка результатів

Коли диригент (тобто батьківське завдання) дає знак — підзадачі розбігаються по своїх партіях. Цей момент називається fork — ніби ви запускаєте музикантів грати свої шматочки в різних залах.

Потім настає час join — диригент піднімає паличку, і всі повертаються, щоб зіграти фінальний акорд разом.

А далі можна спитати в кожного учасника, як усе пройшло:

  • через resultNow() отримати результат відразу, якщо все зіграно без помилок;
  • через throwIfFailed() — упевнитися, що ніхто не сфальшивив. Якщо хтось таки заплутався в нотах, викидається єдиний виняток — ніби диригент сказав: «У нас збій в оркестрі, починаємо заново».

Політики завершення

У будь-якого диригента є своє правило, коли зупиняти музику. У Structured Concurrency це визначається політикою завершення:

  • ShutdownOnFailure — якщо бодай один музикант збився з ритму, диригент махає рукою: «Стоп! Починаємо спочатку». Решта відразу припиняє грати.
  • ShutdownOnSuccess — навпаки, щойно хтось ідеально зіграв свою партію, диригент задоволений: «Досить, далі не треба, у нас уже є переможець». Інші замовкають — політика першої успішної відповіді.

Робота з віртуальними потоками

Кожну підзадачу StructuredTaskScope запускає у віртуальному потоці. Це якби у вас був оркестр, де кожен музикант тямущий і швидкий, без примх і вимог до сцени. Можете сміливо створювати сотні, тисячі таких виконавців — це не важкі треди, а майже невагомі ноти, які звучать саме тоді, коли потрібно.

3. Приклад: агрегатор HTTP-запитів

Розглянемо практичне завдання: у нас є три джерела даних (наприклад, три різні сервери), і ми хочемо отримати відповідь або від усіх (і агрегувати), або від першого, хто відповість успішно.

Варіант 1: «Усі мають бути успішними» (ShutdownOnFailure)

import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;

public class AggregatorAllSuccess {
    public static void main(String[] args) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Future<String> f1 = scope.fork(() -> fetchFromSource1());
            Future<String> f2 = scope.fork(() -> fetchFromSource2());
            Future<String> f3 = scope.fork(() -> fetchFromSource3());

            scope.join(); // чекаємо завершення всіх завдань
            scope.throwIfFailed(); // якщо бодай одне впало — кидаємо виняток

            // Усі завдання успішні — можна агрегувати результати
            String result = f1.resultNow() + f2.resultNow() + f3.resultNow();
            System.out.println("Агрегований результат: " + result);
        }
    }

    static String fetchFromSource1() { /* ... */ return "A"; }
    static String fetchFromSource2() { /* ... */ return "B"; }
    static String fetchFromSource3() { /* ... */ return "C"; }
}

Що відбувається:

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

Варіант 2: «Успіх за першим валідним результатом» (ShutdownOnSuccess)

import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;

public class AggregatorFirstSuccess {
    public static void main(String[] args) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
            Future<String> f1 = scope.fork(() -> fetchFromSource1());
            Future<String> f2 = scope.fork(() -> fetchFromSource2());
            Future<String> f3 = scope.fork(() -> fetchFromSource3());

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

            String result = scope.result(); // результат першого успішного завдання
            System.out.println("Перший успішний результат: " + result);
        }
    }

    static String fetchFromSource1() { /* ... */ return "A"; }
    static String fetchFromSource2() { /* ... */ return "B"; }
    static String fetchFromSource3() { /* ... */ return "C"; }
}

Що відбувається:

  • Щойно одне завдання завершилося успішно — решта скасовуються.
  • Якщо усі впали — кидається виняток.

4. Автоматичне скасування і деградація

StructuredTaskScope сам дбає про скасування решти завдань, якщо цього вимагає політика. Наприклад, якщо одне завдання впало (ShutdownOnFailure) або одне успішно завершилося (ShutdownOnSuccess), решта завдань отримують сигнал скасування (interrupt).

Приклад: коректне завершення з тайм-аутом

import jdk.incubator.concurrent.StructuredTaskScope;
import java.time.Instant;
import java.util.concurrent.Future;

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> f1 = scope.fork(() -> fetchWithTimeout());
    Future<String> f2 = scope.fork(() -> fetchWithTimeout());

    scope.joinUntil(Instant.now().plusSeconds(2)); // чекаємо максимум 2 секунди
    scope.throwIfFailed();

    String result = f1.resultNow() + f2.resultNow();
    System.out.println(result);
}

Якщо завдання не завершилися за 2 секунди — буде кинуто виняток, усі завдання скасуються.

5. Помилки та оброблення винятків

Як винятки підзадач маршрутизуються в scope

Інколи під час концерту хтось таки промахується по нотах — StructuredTaskScope не робить вигляд, що нічого не сталося. Він акуратно записує, хто саме зіграв повз касу, і потім передає диригентові повний звіт. Коли ви викликаєте throwIfFailed(), він кидає агрегований виняток — щось на кшталт зведеного звіту: «Ось список тих, хто сьогодні видав фальшиву ноту». За потреби можна розгорнути це «дерево причин» і подивитися, хто конкретно підвів. А якщо хочеться дізнатися про конкретного виконавця — Future.exceptionNow() розповість, чим саме закінчилася його партія.

Коли скасування — це не провал

Важливо пам’ятати: скасування завдання не завжди означає помилку. Якщо диригент сказав «все, концерт закінчено», то музиканти просто складають інструменти — це cancelled, але не failed. Помилкою вважається лише та ситуація, коли хтось справді зіграв не те, і цей виняток уже потрапить до загального зведення.

Приклад: дерево причин

import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> f1 = scope.fork(() -> { throw new RuntimeException("Помилка 1"); });
    Future<String> f2 = scope.fork(() -> { throw new RuntimeException("Помилка 2"); });

    scope.join();
    scope.throwIfFailed(); // кине виняток з обома причинами
} catch (Exception e) {
    e.printStackTrace();
    // Можна отримати suppressed exceptions через e.getSuppressed()
}

6. Порівняння з CompletableFuture

StructuredTaskScope і CompletableFuture — обидва дають змогу запускати паралельні завдання, але:

  • StructuredTaskScope зручний, коли завдання логічно пов’язані та мають завершуватися/скасовуватися разом (ієрархія завдань).
  • CompletableFuture підходить для композиції завдань без ієрархії (наприклад, ланцюжки перетворень, реактивні сценарії).

Коли StructuredTaskScope спрощує код:

  • Коли потрібно гарантувати, що усі підзадачі завершилися перед виходом із блоку.
  • Коли потрібні централізоване скасування та оброблення помилок.
  • Коли важливо, щоб не залишилося «висячих» завдань.

Коли CompletableFuture зручніше:

  • Коли завдання не пов’язані й можуть жити власним життям.
  • Коли потрібна складна композиція (thenCombine, thenCompose тощо).

7. Практика: агрегатор HTTP-запитів

Завдання: надіслати запити до 3 джерел, отримати першу успішну відповідь

import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;

public class HttpAggregator {
    public static void main(String[] args) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
            Future<String> f1 = scope.fork(() -> httpRequest("https://api1.example.com"));
            Future<String> f2 = scope.fork(() -> httpRequest("https://api2.example.com"));
            Future<String> f3 = scope.fork(() -> httpRequest("https://api3.example.com"));

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

            String result = scope.result();
            System.out.println("Перша успішна відповідь: " + result);
        }
    }

    static String httpRequest(String url) throws Exception {
        // Імітація запиту (можна використовувати HttpClient)
        Thread.sleep((long) (Math.random() * 1000));
        if (Math.random() < 0.3) throw new RuntimeException("Помилка запиту: " + url);
        return "Відповідь від " + url;
    }
}

Завдання: якщо одна підзадача впала — коректно гасимо решту

import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> f1 = scope.fork(() -> httpRequest("https://api1.example.com"));
    Future<String> f2 = scope.fork(() -> httpRequest("https://api2.example.com"));

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

    String result = f1.resultNow() + f2.resultNow();
    System.out.println("Обидві відповіді: " + result);
} catch (Exception e) {
    System.err.println("Помилка в одному з завдань: " + e.getMessage());
}

8. Типові помилки під час роботи зі StructuredTaskScope

Помилка № 1: забули викликати join() або throwIfFailed().
Якщо не викликати join(), завдання можуть не завершитися до виходу з блоку. Якщо не викликати throwIfFailed(), помилки підзадач залишаться непоміченими.

Помилка № 2: спроба отримати результат до завершення завдання.
Виклик resultNow() до завершення завдання кине IllegalStateException. Спочатку дочекайтеся завершення через join().

Помилка № 3: ігнорування скасування.
Якщо завдання було скасоване (наприклад, через політику scope), не намагайтеся отримати його результат — буде виняток.

Помилка № 4: змішування різних політик завершення.
Не варто намагатися вручну скасовувати завдання всередині scope — використовуйте політики ShutdownOnFailure або ShutdownOnSuccess.

Помилка № 5: запуск довгих CPU-bound завдань у віртуальних потоках.
StructuredTaskScope за замовчуванням використовує віртуальні потоки — вони ідеальні для I/O-bound завдань, але не прискорюють важкі обчислення.

Помилка № 6: забули закрити scope (немає try-with-resources).
StructuredTaskScope реалізує AutoCloseable — завжди використовуйте try-with-resources, щоб гарантувати завершення всіх завдань.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ