--- title: Отмена и таймауты description: Кооперативная отмена, CancellationException, withTimeout, NonCancellable и освобождение ресурсов tags: - kotlin - coroutines --- Отмена корутин **кооперативная**: библиотека не может остановить код насильно, она лишь сообщает корутине, что её просят завершиться. Код должен это проверять - сам или через `suspend`-функции. ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val job = launch { repeat(100) { i -> delay(10) println("шаг $i") } } delay(35) job.cancelAndJoin() println("отменено") // шаг 0 // шаг 1 // шаг 2 // отменено } ``` Все `suspend`-функции из `kotlinx.coroutines` (`delay`, `join`, `await`, `withContext`) проверяют отмену и бросают `CancellationException`. ## Как работает отмена `cancel()` переводит `Job` в состояние Cancelling и бросает `CancellationException` в точке приостановки: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val job = launch { try { delay(1000) } catch (e: CancellationException) { println("поймали: ${e::class.simpleName}") throw e // обязательно пробросить! } } delay(20) job.cancelAndJoin() println("состояние: отменён=${job.isCancelled}, завершён=${job.isCompleted}") // поймали: JobCancellationException // состояние: отменён=true, завершён=true } ``` `CancellationException` - особое исключение: оно не считается ошибкой и не приводит к падению родителя. Подробнее: [Исключения в корутинах](Исключения%20в%20корутинах.md) ## Код без точек приостановки не отменяется ```kotlin import kotlinx.coroutines.* import kotlin.system.measureTimeMillis fun main() = runBlocking { val t = measureTimeMillis { val job = launch(Dispatchers.Default) { var sum = 0L repeat(200_000_000) { sum += it } // ни одной точки приостановки println("досчитал несмотря на отмену: ${sum > 0}") } delay(20) job.cancelAndJoin() } println("cancel ждал окончания цикла: ${t > 100}") // досчитал несмотря на отмену: true // cancel ждал окончания цикла: true } ``` Два способа сделать такой код отменяемым: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { // 1. проверять isActive val job1 = launch(Dispatchers.Default) { var i = 0 while (isActive && i < 200_000_000) i++ println("вышел по isActive: ${i < 200_000_000}") } delay(20) job1.cancelAndJoin() // 2. вызывать ensureActive() — бросит CancellationException val job2 = launch(Dispatchers.Default) { try { repeat(200_000_000) { if (it % 100_000 == 0) ensureActive() } } catch (e: CancellationException) { println("вышел по ensureActive") throw e } } delay(20) job2.cancelAndJoin() // вышел по isActive: true // вышел по ensureActive } ``` Есть и третий вариант - `yield()`: он и проверяет отмену, и даёт другим корутинам шанс выполниться. | Способ | Бросает исключение | Когда применять | |---|---|---| | `isActive` | нет, возвращает `Boolean` | условие цикла | | `ensureActive()` | да, `CancellationException` | внутри тела, быстрый выход | | `yield()` | да | ещё и уступает поток другим | ## Освобождение ресурсов `finally` работает как обычно: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val job = launch { try { println("ресурс открыт") delay(1000) } finally { println("ресурс закрыт") } } delay(20) job.cancelAndJoin() println("готово") // ресурс открыт // ресурс закрыт // готово } ``` Но внутри `finally` отменённой корутины **нельзя приостанавливаться**: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val job = launch { try { delay(1000) } finally { val failed = runCatching { delay(50) }.isFailure println("delay в finally упал: $failed") // delay в finally упал: true } } delay(20) job.cancelAndJoin() } ``` Корутина уже отменена, поэтому любая приостановка немедленно бросает `CancellationException`. Если в очистке нужна приостановка - оберните её в `NonCancellable`: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val job = launch { try { delay(1000) } finally { withContext(NonCancellable) { delay(20) println("асинхронная очистка выполнена") } } } delay(20) job.cancelAndJoin() println("готово") // асинхронная очистка выполнена // готово } ``` `NonCancellable` нужен именно и только для этого. Использовать его, чтобы «защитить» обычную работу от отмены - ошибка: такая корутина станет неостановимой. Для `Closeable`-ресурсов проще `use`: ```kotlin import kotlinx.coroutines.* class Connection : AutoCloseable { fun query() = "результат" override fun close() = println("соединение закрыто") } fun main() = runBlocking { val job = launch { Connection().use { conn -> println(conn.query()) delay(1000) } } delay(20) job.cancelAndJoin() // результат // соединение закрыто } ``` ## withTimeout ```kotlin import kotlinx.coroutines.* suspend fun slowOperation(): String { delay(1000) return "готово" } fun main() = runBlocking { try { withTimeout(50) { slowOperation() } } catch (e: TimeoutCancellationException) { println("не успели: ${e.message}") } // не успели: Timed out waiting for 50 ms } ``` `withTimeoutOrNull` вместо исключения возвращает `null` - обычно это удобнее: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val fast = withTimeoutOrNull(100) { delay(20); "успели" } val slow = withTimeoutOrNull(20) { delay(100); "не успели" } println(fast) // успели println(slow) // null } ``` `TimeoutCancellationException` - подкласс `CancellationException`, поэтому он отменяет только свой блок, а не всю область. ## Таймаут и ресурсы Опасное место: таймаут может сработать между получением ресурса и входом в `try`. ```kotlin import kotlinx.coroutines.* class Resource2 { var closed = false fun close() { closed = true } } fun main() = runBlocking { var resource: Resource2? = null withTimeoutOrNull(30) { resource = Resource2() // создали try { delay(1000) // здесь таймаут } finally { resource?.close() // закрыли } } println("ресурс закрыт: ${resource?.closed}") // ресурс закрыт: true } ``` Правило: открывайте ресурс **внутри** блока и закрывайте в `finally` или через `use`. Открытие снаружи с закрытием внутри - источник утечек. ## Отмена распространяется вниз ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val parent = launch { launch { try { delay(1000) } finally { println("ребёнок 1 завершён") } } launch { try { delay(1000) } finally { println("ребёнок 2 завершён") } } try { delay(1000) } finally { println("родитель завершён") } } delay(20) parent.cancelAndJoin() println("всё дерево остановлено") // ребёнок 1 завершён // ребёнок 2 завершён // родитель завершён // всё дерево остановлено } ``` Это бесплатное следствие структурированной конкурентности: отменив одну корутину, вы останавливаете всё поддерево. Подробнее: [Структурированная конкурентность](Структурированная%20конкурентность.md) ```mermaid sequenceDiagram participant В as Вызывающий participant Р as Родитель participant Д as Дети В->>Р: cancel() Р->>Р: состояние Cancelling Р->>Д: отменить всех детей Д->>Д: CancellationException
в точке приостановки Д->>Д: выполнить finally Д-->>Р: ребёнок Cancelled Р->>Р: выполнить finally Р-->>В: состояние Cancelled, join() вернулся ``` ## cancel против cancelAndJoin ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val job1 = launch { try { delay(1000) } finally { println("1: очистка") } } job1.cancel() // не ждём завершения println("после cancel: завершён=${job1.isCompleted}") val job2 = launch { try { delay(1000) } finally { println("2: очистка") } } job2.cancelAndJoin() // ждём println("после cancelAndJoin: завершён=${job2.isCompleted}") // после cancel: завершён=false // 1: очистка // 2: очистка // после cancelAndJoin: завершён=true } ``` `cancel()` только отправляет сигнал; `cancelAndJoin()` дожидается, пока корутина действительно остановится и выполнит очистку. Для корректного освобождения ресурсов нужен второй вариант. ## Отмена и async ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val deferred = async { delay(1000); "результат" } delay(20) deferred.cancel() val error = runCatching { deferred.await() }.exceptionOrNull() println("await после cancel бросил: ${error is CancellationException}") // await после cancel бросил: true } ``` Отменённый `Deferred` при `await()` бросает `CancellationException`, а не возвращает `null`. Для «результат или ничего» используйте `withTimeoutOrNull`. ## Таймаут на весь scope ```kotlin import kotlinx.coroutines.* suspend fun loadWithDeadline(): List? = withTimeoutOrNull(60) { coroutineScope { val a = async { delay(20); "быстрый" } val b = async { delay(1000); "медленный" } listOf(a.await(), b.await()) } } fun main() = runBlocking { println(loadWithDeadline()) // null — медленный не успел, отменились оба } ``` Таймаут поверх `coroutineScope` даёт дедлайн на всю группу задач: не успел один - отменяются все. Это правильное поведение для запроса с общим SLA. > [!info] Отличия от Java > | Java | Kotlin | > |---|---| > | `Thread.interrupt()` | `job.cancel()` | > | `Thread.isInterrupted()` | `isActive` | > | `InterruptedException` | `CancellationException` | > | `Future.cancel(true)` - одна задача | отмена всего поддерева | > | `ExecutorService.shutdownNow()` | `scope.cancel()` | > | `future.get(50, MILLISECONDS)` | `withTimeout(50)` | > | `ScheduledExecutorService` для дедлайна | `withTimeout` | > | флаг `volatile boolean stopped` | `isActive` | > > **Прерывание в Java тоже кооперативное, но работает хуже.** `Thread.interrupt()` ставит флаг и прерывает только блокирующие вызовы, которые умеют его замечать (`sleep`, `wait`, `join`, часть NIO). Проблемы знакомые: `InterruptedException` - проверяемое исключение, которое приходится ловить в каждом блокирующем вызове, а при перехвате легко потерять флаг: > ```java > try { > Thread.sleep(1000); > } catch (InterruptedException e) { > // забыли Thread.currentThread().interrupt() — прерывание потеряно > } > ``` > В Kotlin `CancellationException` непроверяемое, поэтому его не нужно ловить везде, а `catch (e: Exception)` без проброса ломает отмену ровно так же - разница в том, что IDE и детекторы это подсказывают. > > **Отмена по дереву против отмены по задаче.** `Future.cancel(true)` прерывает один поток. Если задача породила подзадачи через другой `submit`, они продолжат работу, и отследить их можно только вручную - списком `Future`. В Kotlin `cancel()` на родителе гарантированно останавливает всё поддерево, включая задачи, запущенные тремя уровнями ниже. > > **Таймаут не требует отдельного планировщика.** В Java `future.get(50, MILLISECONDS)` прерывает **ожидание**, но не саму задачу - она продолжит выполняться в пуле. Чтобы остановить и её, нужен `cancel(true)` плюс поддержка прерывания в коде задачи. `withTimeout` отменяет сам блок: > ```kotlin > import kotlinx.coroutines.* > > fun main() = runBlocking { > var finished = false > withTimeoutOrNull(30) { > try { delay(1000); finished = true } finally { println("задача остановлена") } > } > println("тело доработало: $finished") > // задача остановлена > // тело доработало: false > } > ``` > > **`shutdownNow()` против `scope.cancel()`.** `shutdownNow` возвращает список незапущенных задач и прерывает потоки работающих - результат зависит от того, умеет ли код реагировать на прерывание. `scope.cancel()` отменяет корутины в известных точках приостановки, а `join()` даёт дождаться завершения очистки. > > Сравнить: [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md) > [!warning] Подводные камни > **Перехват `CancellationException` без проброса ломает отмену:** > ```kotlin > import kotlinx.coroutines.* > > fun main() = runBlocking { > val job = launch { > repeat(5) { > try { > delay(20) > } catch (e: CancellationException) { > println("проглотили отмену на шаге $it") > // нет throw — цикл продолжается! > } > } > println("корутина доработала до конца") > } > delay(30) > job.cancelAndJoin() > // проглотили отмену на шаге 1 > // проглотили отмену на шаге 2 > // проглотили отмену на шаге 3 > // проглотили отмену на шаге 4 > // корутина доработала до конца > } > ``` > Корутина стала неотменяемой. Та же беда возникает от `catch (e: Exception)` - `CancellationException` в него попадает. > > **`try/catch (Exception)` вокруг `suspend`-вызова перехватывает отмену:** > ```kotlin > import kotlinx.coroutines.* > > fun main() = runBlocking { > val job = launch { > try { > delay(1000) > } catch (e: Exception) { > println("перехвачено как обычная ошибка: ${e is CancellationException}") > } > println("продолжили работу после отмены") > } > delay(20) > job.cancelAndJoin() > // перехвачено как обычная ошибка: true > // продолжили работу после отмены > } > ``` > Правильно: сначала `catch (e: CancellationException) { throw e }`, затем `catch (e: Exception)`. > > **`runCatching` глотает `CancellationException`** по той же причине - он ловит `Throwable`. В корутинном коде это регулярная проблема: `runCatching { suspendCall() }` делает блок неотменяемым. Для `suspend`-кода нужен явный `try/catch` с пробросом отмены. > > **Приостановка в `finally` отменённой корутины не работает:** > ```kotlin > import kotlinx.coroutines.* > > fun main() = runBlocking { > val job = launch { > try { > delay(1000) > } finally { > val ok = runCatching { delay(50); "очистка" }.isSuccess > println("приостановка в finally удалась: $ok") // приостановка в finally удалась: false > } > } > delay(20) > job.cancelAndJoin() > } > ``` > Нужен `withContext(NonCancellable)`. > > **`NonCancellable` вокруг обычной работы делает корутину неостановимой:** > ```kotlin > import kotlinx.coroutines.* > import kotlin.system.measureTimeMillis > > fun main() = runBlocking { > val t = measureTimeMillis { > val job = launch { > withContext(NonCancellable) { > delay(200) // отмена проигнорирована > println("доработал несмотря на cancel") > } > } > delay(20) > job.cancelAndJoin() > } > println("cancel ждал 200 мс: ${t >= 190}") > // доработал несмотря на cancel > // cancel ждал 200 мс: true > } > ``` > `NonCancellable` оправдан только для короткой очистки в `finally`. > > **`cancel()` без `join()` не ждёт очистки.** Если после `cancel()` сразу закрыть ресурс или выйти из процесса, `finally` может не успеть выполниться. Используйте `cancelAndJoin()`. > > **Чистый вычислительный цикл игнорирует отмену.** Любой `while`/`repeat` без `suspend`-вызовов нужно дополнять `isActive` или `ensureActive()` - иначе корутина отработает до конца, а `cancelAndJoin()` будет её ждать. > > **`withTimeout` бросает исключение, которое легко не заметить:** > ```kotlin > import kotlinx.coroutines.* > > fun main() = runBlocking { > val result = runCatching { withTimeout(20) { delay(100); "значение" } } > println("упало с таймаутом: ${result.exceptionOrNull() is TimeoutCancellationException}") > // упало с таймаутом: true > } > ``` > `TimeoutCancellationException` - подкласс `CancellationException`, поэтому обычный `catch (e: CancellationException) { throw e }` его пробросит, и таймаут «не сработает» там, где вы ждали обработки. Используйте `withTimeoutOrNull` или ловите `TimeoutCancellationException` до отмены. > > **Ресурс, открытый до `withTimeout`, может утечь:** > ```kotlin > import kotlinx.coroutines.* > > fun main() = runBlocking { > var opened = 0 > var closed = 0 > withTimeoutOrNull(30) { > opened++ // открыли внутри — хорошо > try { delay(1000) } finally { closed++ } > } > println("открыто $opened, закрыто $closed") // открыто 1, закрыто 1 > } > ``` > Если бы `opened++` стоял перед `withTimeoutOrNull`, а `closed++` внутри - при срабатывании таймаута до входа в блок ресурс остался бы открытым. > > **Отменённый scope нельзя переиспользовать** - `launch` в нём сразу возвращает отменённый `Job`. Для перезапуска создавайте новый scope. > > **`await()` на отменённом `Deferred` бросает исключение, а не возвращает `null`.** Код, ожидающий `null` при отмене, получит падение. > > **Отмена не мгновенна.** Между `cancel()` и фактическим завершением проходит время: корутина должна дойти до точки приостановки и выполнить `finally`. Проверка `isCompleted` сразу после `cancel()` обычно даёт `false`. Далее: [Исключения в корутинах](Исключения%20в%20корутинах.md)