Отмена корутин и тайм-ауты

В этом разделе рассматривается отмена корутин и тайм-ауты.

Отмена выполнения корутин

В долго работающем приложении вам может понадобиться детальное управление фоновыми корутинами. Например, пользователь может закрыть страницу, которая запускала корутину, из-за чего её результат больше не нужен, и её действие можно отменить. Функция launch возвращает Job, которую можно использовать для отмены запущенной корутины.

val job = launch {
    repeat(1000) { i ->
        println("job: I'm sleeping $i ...")
        delay(500L)
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion 
println("main: Now I can quit.")

Полный код находится здесь.

Этот код выведет следующее:

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

Как только главная функция вызывает job.cancel, мы больше не видим какого-либо вывода с другой корутины, потому что она была отменена. Существует также cancelAndJoin функция-расширение Job, которая объединяет вызовы cancel и join.

Отмена кооперативна

Отмена корутин кооперативна. Код корутины должен взаимодействовать, чтобы его можно было отменить. Все suspend-функции в kotlinx.coroutines - отменяемые. Они проверяют отмену корутины, и в случае отмены выбрасывают исключение CancellationException. Однако, если корутина работает над вычислениями и не проверяет на отмену, то её нельзя отменить, как это происходит, например, здесь:

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    while (i < 5) { // computation loop, just wastes CPU
        // print a message twice a second
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

Полный код находится здесь.

Запустив этот код, вы увидите как корутина продолжает выводить на экран "I'm sleeping" даже после отмены, пока job не завершится после пяти итераций.

Делаем код с вычислениями отменяемым

Есть два способа сделать вычислительный код отменяемым. Первый – периодически вызвать suspend-функцию, которая проверяет, активна ли корутина. Для этого хорошо подходит функция yield. Другой — явно проверять статус отмены. Попробуем этот подход.

Замените while (i < 5) в предыдущем примере на while (isActive) и запустите его ещё раз.

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    while (isActive) { // cancellable computation loop
        // print a message twice a second
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

Полный код находится здесь

Как вы можете увидеть, теперь цикл отменяется. isActive — это extension-параметр, доступный внутри корутины, благодаря объекту CoroutineScope.

Закрытие ресурсов при помощи finally

Отменяемые suspend-функции при отмене выбрасывают исключение CancellationException, которое может быть обработано обычным путём. Например, выражение try {...} finally {...} и Kotlin-функция use обыкновенно выполняют свои функции при завершении (отмене) корутин.

val job = launch {
    try {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L)
        }
    } finally {
        println("job: I'm running finally")
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

Полный код находится здесь.

И join, и cancelAndJoin ожидают завершения всех финальных стадий, поэтому приведённый выше пример даёт такой вывод:

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
main: Now I can quit.

Запуск неотменяемого блока

Любая попытка использовать suspend-функцию в блоке finally предыдущего примера приводит к CancellationException, потому что корутина, выполняющая этот код, отменена. Обычно это не проблема, так как все нормально работающие операции закрытия (закрытие файла, отмена Job или закрытие любого вида канала) обычно не блокируются и не требуют каких-либо suspend-функций. Однако в редких случаях, когда вам нужно приостановить работу отмененной корутины, вы можете обернуть соответствующий код в withContext(NonCancellable) {...} с использованием функции withContext и контекста NonCancellable, как показано в следующем примере:

val job = launch {
    try {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L)
        }
    } finally {
        withContext(NonCancellable) {
            println("job: I'm running finally")
            delay(1000L)
            println("job: And I've just delayed for 1 sec because I'm non-cancellable")
        }
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

Полный код находится здесь.

Тайм-аут

Самая очевидная практическая причина отменить выполнение корутины - время её выполнения превысило некоторый тайм-аут. Хотя можно вручную отслеживать обращение к соответствующему Job и запускать отдельную корутину для отмены отслеживаемой после тайм-аута, есть готовая к использованию функция withTimeout, которая делает это. Посмотрите на следующий пример:

withTimeout(1300L) {
    repeat(1000) { i ->
        println("I'm sleeping $i ...")
        delay(500L)
    }
}

Полный код находится здесь.

Этот код выведет следующее:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms

Исключение TimeoutCancellationException, создаваемое withTimeout, является подклассом CancellationException. Мы никогда раньше не видели его трассировку стека, напечатанную на консоли. Это потому, что внутри отмененной корутины CancellationException считается нормальной причиной её завершения. Однако в этом примере мы использовали withTimeout прямо внутри функции main.

Поскольку отмена является лишь исключением, все ресурсы закрываются в обычном порядке. Вы можете обернуть код с тайм-аутом в блоке try {...} catch (e: TimeoutCancellationException) {...}, если вам нужно сделать какое-то дополнительное действие специально для любого тайм-аута или использовать функцию withTimeoutOrNull. Она похожа на withTimeout, но возвращает null по тайм-ауту вместо создания исключения.

val result = withTimeoutOrNull(1300L) {
    repeat(1000) { i ->
        println("I'm sleeping $i ...")
        delay(500L)
    }
    "Done" // will get cancelled before it produces this result
}
println("Result is $result")

Полный код находится здесь.

Теперь ошибки при выполнении этой корутины не будет:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Result is null

Асинхронный тайм-аут и ресурсы

Событие тайм-аута в withTimeout является асинхронным по отношению к коду, работающему в его блоке, и может произойти в любое время, даже прямо перед возвратом из блока тайм-аута. Имейте это в виду, если вы открываете или приобретаете ресурс внутри блока, который необходимо закрыть или освободить за пределами блока.

Например, здесь мы имитируем закрываемый ресурс с помощью класса Resource, который просто отслеживает, сколько раз он был создан путем увеличения счетчика acquired и уменьшения этого счетчика из его функции close. Давайте запустим много корутин с небольшим таймаутом, попробуем получить этот ресурс изнутри блока withTimeout после небольшой задержки и освободить его извне.

var acquired = 0

class Resource {
    init { acquired++ } // Acquire the resource
    fun close() { acquired-- } // Release the resource
}

fun main() {
    runBlocking {
        repeat(100_000) { // Launch 100K coroutines
            launch { 
                val resource = withTimeout(60) { // Timeout of 60 ms
                    delay(50) // Delay for 50 ms
                    Resource() // Acquire a resource and return it from withTimeout block     
                }
                resource.close() // Release the resource
            }
        }
    }
    // Outside of runBlocking all coroutines have completed
    println(acquired) // Print the number of resources still acquired
}

Полный код находится здесь.

Если вы запустите приведенный выше код, вы увидите, что он не всегда выводит ноль, хотя это может зависеть от таймингов вашей машины, вам может потребоваться настроить тайм-ауты в этом примере, чтобы действительно увидеть ненулевые значения.

Обратите внимание, что увеличение и уменьшение счетчика acquired здесь из 100 000 корутин совершенно безопасно, так как это всегда происходит из одного и того же основного потока. Подробнее об этом будет рассказано в главе о контексте корутин.

Чтобы обойти эту проблему, вы можете сохранить ссылку на ресурс в переменной, а не возвращать ее из блока withTimeout.

runBlocking {
    repeat(100_000) { // Launch 100K coroutines
        launch { 
            var resource: Resource? = null // Not acquired yet
            try {
                withTimeout(60) { // Timeout of 60 ms
                    delay(50) // Delay for 50 ms
                    resource = Resource() // Store a resource to the variable if acquired      
                }
                // We can do something else with the resource here
            } finally {  
                resource?.close() // Release the resource if it was acquired
            }
        }
    }
}
// Outside of runBlocking all coroutines have completed
println(acquired) // Print the number of resources still acquired

Полный код находится здесь.

Этот пример всегда выводит 0, а ресурсы не утекают.