Files
obsidian/Kotlin/Корутины/Отмена и таймауты.md
T

526 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<br/>в точке приостановки
Д->>Д: выполнить 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<String>? = 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)