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, щоб гарантувати завершення всіх завдань.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ