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

23 KiB
Raw Blame History

title, description, tags
title description tags
Отмена и таймауты Кооперативная отмена, CancellationException, withTimeout, NonCancellable и освобождение ресурсов
kotlin
coroutines

Отмена корутин кооперативная: библиотека не может остановить код насильно, она лишь сообщает корутине, что её просят завершиться. Код должен это проверять - сам или через suspend-функции.

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 в точке приостановки:

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 - особое исключение: оно не считается ошибкой и не приводит к падению родителя. Подробнее: Исключения в корутинах

Код без точек приостановки не отменяется

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
}

Два способа сделать такой код отменяемым:

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 работает как обычно:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            println("ресурс открыт")
            delay(1000)
        } finally {
            println("ресурс закрыт")
        }
    }
    delay(20)
    job.cancelAndJoin()
    println("готово")
    // ресурс открыт
    // ресурс закрыт
    // готово
}

Но внутри finally отменённой корутины нельзя приостанавливаться:

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:

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:

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

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 - обычно это удобнее:

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.

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. Открытие снаружи с закрытием внутри - источник утечек.

Отмена распространяется вниз

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 завершён
    // родитель завершён
    // всё дерево остановлено
}

Это бесплатное следствие структурированной конкурентности: отменив одну корутину, вы останавливаете всё поддерево. Подробнее: Структурированная конкурентность

sequenceDiagram
    participant В as Вызывающий
    participant Р as Родитель
    participant Д as Дети
    В->>Р: cancel()
    Р->>Р: состояние Cancelling
    Р->>Д: отменить всех детей
    Д->>Д: CancellationException<br/>в точке приостановки
    Д->>Д: выполнить finally
    Д-->>Р: ребёнок Cancelled
    Р->>Р: выполнить finally
    Р-->>В: состояние Cancelled, join() вернулся

cancel против cancelAndJoin

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

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

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 - проверяемое исключение, которое приходится ловить в каждом блокирующем вызове, а при перехвате легко потерять флаг:

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 отменяет сам блок:

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, Система исключений в Java

[!warning] Подводные камни Перехват CancellationException без проброса ломает отмену:

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-вызова перехватывает отмену:

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 отменённой корутины не работает:

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 вокруг обычной работы делает корутину неостановимой:

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 бросает исключение, которое легко не заметить:

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, может утечь:

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.

Далее: Исключения в корутинах