1. Thread.interrupt() і кооперативне скасування
У реальних застосунках завдання можуть бути тривалими, а інколи й зовсім «зависати» — під час роботи з мережею, файлами або зовнішніми сервісами. Користувач може скасувати операцію, сервер — перервати обробку запиту або просто спливає загальний тайм‑аут. Якщо не вміти коректно скасовувати завдання, застосунок підвисатиме, марно витрачатиме ресурси і погано реагуватиме на зовнішні події.
Ключова ідея: скасування має бути кооперативним — завдання саме повинно перевіряти, чи не попросили його завершитися, і коректно звільняти ресурси.
Як працює Thread.interrupt()
У кожного потоку є прапор «переривання». Коли ви викликаєте thread.interrupt(), цей прапор встановлюється в true. Сам потік при цьому не «вбивається», він має перевіряти свій статус і завершитися: періодично викликати Thread.currentThread().isInterrupted() і коректно виходити.
Приклад:
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// Працюємо...
try {
Thread.sleep(100); // Може бути перерваний
} catch (InterruptedException e) {
// Прапор скидається, але ми можемо знову перервати себе
Thread.currentThread().interrupt();
break;
}
}
System.out.println("Потік завершено через переривання.");
});
worker.start();
// ... пізніше
worker.interrupt();
Де прапор спрацьовує автоматично?
- Методи, що можуть блокуватися (sleep, wait, join, операції блокувальних структур), кидають InterruptedException у разі переривання.
- В інших випадках (наприклад, в обчислювальному циклі) потрібно вручну перевіряти isInterrupted().
Шаблон «встанови прапор — і швидко вийди»
- У коді, що викликає: thread.interrupt()
- У завданні: періодично перевіряти Thread.currentThread().isInterrupted()
- За потреби — коректно звільняти ресурси та завершуватися.
Типова помилка: очікувати, що interrupt() миттєво «вб’є» потік. Ні — це лише сигнал, завдання має самостійно реагувати.
2. Future.cancel(), CancellationException і скасування завдань
Як працює Future.cancel
Коли ви запускаєте завдання через ExecutorService.submit(), ви отримуєте об’єкт Future. У нього є метод cancel(boolean mayInterruptIfRunning):
- Якщо завдання ще не почалося — його не буде запущене.
- Якщо завдання вже виконується і mayInterruptIfRunning == true — буде викликано interrupt() у потоці, що виконує завдання.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
// Тривала робота
}
System.out.println("Завдання завершено через скасування.");
});
// ... пізніше
future.cancel(true); // Попросимо скасувати завдання
Що насправді відбувається із завданням
Скасування через Future — це не чарівна кнопка «вбити потік», а фактично ввічлива форма Thread.interrupt(). Якщо завдання коректно перевіряє прапор переривання — воно акуратно завершиться. Якщо ні — продовжить працювати до природного завершення.
Якщо викликати future.get() після скасування, ви отримаєте CancellationException — нагадування про те, що завдання було скасовано.
3. CompletableFuture: скасування, тайм‑аути та ланцюжки
Скасування CompletableFuture
У CompletableFuture також є cancel(boolean). Якщо завдання ще не завершилося, воно буде скасоване, і всі подальші обробники (thenApply, thenAccept тощо) не будуть викликані.
CompletableFuture<Void> cf = CompletableFuture.runAsync(() -> {
while (!Thread.currentThread().isInterrupted()) {
// Працюємо...
}
System.out.println("CF завершено через скасування.");
});
// ... пізніше
cf.cancel(true);
Тайм‑аути: orTimeout і completeOnTimeout
- orTimeout(timeout, unit) — завершує CompletableFuture із TimeoutException, якщо не встигне за відведений час.
- completeOnTimeout(value, timeout, unit) — завершує з указаним значенням, якщо не встигне.
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(5000); } catch (InterruptedException e) {}
return "OK";
});
cf.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> "TIMEOUT")
.thenAccept(System.out::println); // Через 2 секунди: "TIMEOUT"
Прокидання скасування в ланцюжках
Якщо скасувати «верхній» CompletableFuture, усі наступні кроки в ланцюжку не викличуться. Але під час використання thenCompose для запуску внутрішніх асинхронних операцій скасування не прокидається «вгору» автоматично — це потрібно проєктувати явно (перевіряти статус, скасовувати дочірні завдання, використовувати спільний дедлайн).
Обережно з thenCompose та власним Executor! Переконайтеся, що внутрішні завдання вміють реагувати на переривання/скасування та/або отримують спільний тайм‑аут.
4. StructuredTaskScope: скасування групи завдань
Structured Concurrency і скасування
StructuredTaskScope (Java 21+) дає змогу запускати групу завдань і керувати їхнім життєвим циклом як єдиним цілим. Якщо одне із завдань завершилося з помилкою або сплив тайм‑аут — інші завдання автоматично скасовуються.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchData1());
Future<String> f2 = scope.fork(() -> fetchData2());
scope.join(); // чекаємо завершення всіх завдань
scope.throwIfFailed(); // якщо хоча б одне впало — кидаємо виняток
String result = f1.resultNow() + f2.resultNow();
System.out.println(result);
}
- Якщо будь‑яке завдання завершилося з помилкою — scope скасовує решту завдань.
- Якщо сплив тайм‑аут (через scope.joinUntil(deadline)) — scope скасовує всі завдання.
Політики завершення
- ShutdownOnFailure — скасовує всі завдання при першій помилці.
- ShutdownOnSuccess — скасовує решту завдань, щойно одне завершилося успішно.
5. Практика: безпечне скасування тривалих операцій
Приклад: скасування блокувального IO
Якщо завдання блокується на читанні з файлу або мережі, переривання потоку не завжди допомагає — деякі IO‑операції не реагують на interrupt. У сучасних API (NIO, AsynchronousFileChannel) переривання підтримується краще, але й досі не всюди.
Рекомендації:
- Використовуйте неблокувальне IO, якщо потрібне скасування.
- Для блокувального IO — виставляйте тайм‑аути на рівні API (наприклад, Socket.setSoTimeout).
- Для асинхронних завдань — використовуйте Future.cancel і коректно реагуйте на переривання.
Приклад: скасування очікування черги/бар’єра
Багато синхронізаторів (BlockingQueue.take(), CountDownLatch.await(), CyclicBarrier.await()) кидають InterruptedException під час переривання. В обробнику ловіть виняток, за потреби відновлюйте прапор і коректно завершуйте завдання.
6. Патерн «time‑budget»: спільний дедлайн на групу операцій
У складних застосунках часто потрібно задати спільний тайм‑аут на виконання групи операцій. Наприклад, якщо користувач чекає відповідь не довше 2 секунд, а всередині потрібно зробити 3 мережеві запити — усі вони мають уміститися в спільний дедлайн.
Як прокидати дедлайн вниз по стеку?
- Передавайте об’єкт дедлайну (наприклад, Instant deadline) у всі потенційно блокувальні методи.
- У кожному методі обчислюйте час, що залишився: Duration.between(Instant.now(), deadline).
- Використовуйте цей час для тайм‑аутів у блокувальних операціях (await(timeout), poll(timeout), orTimeout(timeout)).
Instant deadline = Instant.now().plusSeconds(2);
void doWork(Instant deadline) throws TimeoutException, InterruptedException {
Duration left = Duration.between(Instant.now(), deadline);
if (left.isNegative() || left.isZero()) throw new TimeoutException();
// Використовуємо left для тайм‑ауту
queue.poll(left.toMillis(), TimeUnit.MILLISECONDS);
}
Scoped Values / контекст
У Java 21+ можна використовувати Scoped Values для передавання дедлайну по стеку викликів, щоб не передавати його явно в кожен метод.
7. Structured Concurrency: скасування всього scope у разі збою/тайм‑ауту
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchData1());
Future<String> f2 = scope.fork(() -> fetchData2());
boolean completed = scope.joinUntil(Instant.now().plusSeconds(2));
if (!completed) {
scope.shutdown();
throw new TimeoutException("Дедлайн сплив!");
}
scope.throwIfFailed();
// ...
}
- Якщо дедлайн сплив — scope скасовує всі завдання.
- Якщо одне завдання впало — інші скасовуються автоматично.
8. Типові помилки під час роботи зі скасуванням і тайм‑аутами
Помилка № 1: Очікування, що interrupt() миттєво завершить потік. Насправді це лише сигнал — завдання має самостійно перевіряти статус і коректно завершуватися.
Помилка № 2: Не перевіряється isInterrupted() у тривалих циклах. Якщо не перевіряти прапор переривання, завдання працюватиме вічно, навіть якщо його попросили завершитися.
Помилка № 3: Future.cancel() не призводить до скасування, якщо завдання не реагує на interrupt. Якщо завдання «глухе», cancel() не допоможе.
Помилка № 4: Тайм‑аути не прокидаються вниз по стеку. Якщо не передавати дедлайн у всі методи, внутрішня операція може «підвиснути» довше, ніж потрібно.
Помилка № 5: У thenCompose ланцюжках CompletableFuture скасування не прокидається автоматично. Якщо скасувати «верхній» future, внутрішні завдання можуть продовжувати працювати — обробіть скасування явно.
Помилка № 6: StructuredTaskScope не закривається (немає try‑with‑resources). Якщо не закрити scope, дочірні завдання можуть залишитися «висячими».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ