526 lines
23 KiB
Markdown
526 lines
23 KiB
Markdown
---
|
||
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)
|