JavaRush /Курси /JAVA 25 SELF /Скасування завдань і тайм‑аути крізь стек

Скасування завдань і тайм‑аути крізь стек

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

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().

Шаблон «встанови прапор — і швидко вийди»

  1. У коді, що викликає: thread.interrupt()
  2. У завданні: періодично перевіряти Thread.currentThread().isInterrupted()
  3. За потреби — коректно звільняти ресурси та завершуватися.

Типова помилка: очікувати, що 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, дочірні завдання можуть залишитися «висячими».

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