23 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Отмена и таймауты | Кооперативная отмена, CancellationException, withTimeout, NonCancellable и освобождение ресурсов |
|
Отмена корутин кооперативная: библиотека не может остановить код насильно, она лишь сообщает корутине, что её просят завершиться. Код должен это проверять - сам или через 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()isActiveInterruptedExceptionCancellationExceptionFuture.cancel(true)- одна задачаотмена всего поддерева ExecutorService.shutdownNow()scope.cancel()future.get(50, MILLISECONDS)withTimeout(50)ScheduledExecutorServiceдля дедлайнаwithTimeoutфлаг volatile boolean stoppedisActiveПрерывание в 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. В Kotlincancel()на родителе гарантированно останавливает всё поддерево, включая задачи, запущенные тремя уровнями ниже.Таймаут не требует отдельного планировщика. В 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.
Далее: Исключения в корутинах