Kotlin 协程的取消(一)取消和超时的用法(草稿)

在 Java 语言中提供了线程中断的能力,但并不是所有的线程都可以中断的,因为 interrupt 方法并不是真正的终止线程,而是将一个标志位标记为中断状态,当运行到下一次中断标志位检查时,才能触发终止线程。

但无论如何,终止线程是一个糟糕的方案,因为在线程的销毁和重建,是要消耗系统资源的,造成了不必要的开销。Kotlin 协程提供了更优雅的取消机制,这也是协程比较核心的功能之一。

协程的状态

在了解取消机制之前我们需要知道一些关于 Job 状态的内容:

StateisActive(是否活跃)isCompleted(是否完成)isCancelled(是否取消)
New (可选初始状态)falsefalsefalse
Active (默认初始状态)truefalsefalse
Completing (短暂态)truefalsefalse
Cancelling (短暂态)falsefalsetrue
Cancelled (完成态)falsetruetrue
Completed (完成态)falsetruefalse

可以看出,在完成和取消的过程中,会经过一个短暂的进行中的状态,然后才变成已完成/已取消。

在这里只关注一下取消相关的状态:

  • Cancelling

    抛出异常的 Job 会导致其进入 Cancelling 状态,也可以使用 cancel 方法来随时取消 Job 使其立即转换为 Cancelling 状态。

  • Cancelled

    当它递归取消子项,并等待所有的子项都取消后,该 Job 会进入 Cancelled 状态。

取消协程的执行

协程在代码中抽象的类型是 Job , 下面是一个官方的代码示例,用来展示如何取消协程的执行:

suspend fun main(): Unit = coroutineScope {
    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.

一旦 mian 方法中调用了 job.cancel() ,我们就看不到其他协程的任何输出,因为它已被取消了。

协程取消的协作

协程代码必须通过挂起函数的合作才能被取消。kotlinx.coroutines 中所有挂起函数(带有 suspend 关键字函数)都是可以被取消的。suspend 函数会检查协程是否需要取消并在取消时抛出 CancellationException

但是,如果协程在运行过程中并且不检查取消,则不能取消它,如下例所示:

suspend fun main(): Unit = coroutineScope {
    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.")
}

在这个 job 中,并没有执行任何 suspend 函数,所以在执行过程中并没有对协程是否需要取消进行检查,自然也就无法触发取消。

同样的问题也可以在通过 捕获 CancellationException 并且不抛出的情况下 观察到:

suspend fun main(): Unit = coroutineScope {
    val job = launch(Dispatchers.Default) {
        repeat(5) { i ->
            try {
                // print a message twice a second
                println("job: I'm sleeping $i ...")
                delay(500)
            } catch (e: Exception) {
                // log the exception
                println(e)
            }
        }
    }
    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: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@614acfe9
job: I'm sleeping 3 ...
kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@614acfe9
job: I'm sleeping 4 ...
kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@614acfe9
main: Now I can quit.

从打印结果来看,循环 5 次全部执行了,好像取消并没有起到作用。但实际上不是这样的,为了便于观察加上时间戳:

1665217217682: job: I'm sleeping 0 ...
1665217218196: job: I'm sleeping 1 ...
1665217218697: job: I'm sleeping 2 ...
1665217218996: main: I'm tired of waiting!
1665217219000: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@3a1efc0d
1665217219000: job: I'm sleeping 3 ...
1665217219000: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@3a1efc0d
1665217219000: job: I'm sleeping 4 ...
1665217219000: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@3a1efc0d
1665217219001: main: Now I can quit.

加上时间可以看出,抛出第一次异常后的两次循环和异常捕获都是在同一瞬间完成的。这说明了捕获到异常后,仍然会执行代码,但是所有的 delay 方法都没有生效,即该 Job 的所有子 Job 都失效了。但该 Job 仍在继续循环打印。原因是,父 Job 会等所有子 Job 处理结束后才能完成取消。

而如果我们不使用 try-catch 呢?

suspend fun main(): Unit = coroutineScope {
    val job = launch(Dispatchers.Default) {
        repeat(5) { i ->
            // print a message twice a second
            println("job: I'm sleeping $i ...")
            delay(500)
        }
    }
    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: 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.

很顺利的取消了。

使用 try-catch 来捕获 CancellationException 时需要注意,在挂起函数前的代码逻辑仍会多次执行,从而导致这部分代码仿佛没有被取消一样。

如何写出可以取消的代码

有两种方法可以使代码是可取消的。第一种方法是定期调用挂起函数,检查是否取消。有一个 yield 函数是一个很好的选择。另一个是显式检查取消状态。让我们试试后一种方法。

suspend fun main(): Unit = coroutineScope {
    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.")
}

将上面的循环 5 次通过使用 while (isActive) 进行替换,实现显示检查取消的代码。isActive 是通过 CoroutineScope 对象在协程内部可用的扩展属性。

在 finally 中释放资源

可取消的挂起函数在取消时抛出 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.

使用不可取消的 block

在上面的示例中的 finally 代码块中使用 suspend 函数都会导致抛出 CancellationException 。因为运行这些代码的协程已经被取消了。通常情况下这不会有任何问题,因为所有行为良好的关闭操作,通常都是非阻塞的,不涉及任何挂起函数。

然而,在极少数情况下,如果你需要在 finally 中使用一个挂起函数,你可以通过使用 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 的引用,并在延迟一段时间后启动一个单独的协程来取消被跟踪的 Job 。在 Kotlin 协程库中提供了 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 是 CancellationException 的子类,TimeoutCancellationException 通过 withTimeout 函数抛出。

我们以前从未在控制台上看到过它的堆栈跟踪。这是因为在被取消的协程中,CancellationException被认为是协程完成的正常原因。然而,在本例中,我们在main函数中使用了withTimeout。

由于取消只是一个异常,所以所有资源都以通常的方式关闭。你可以用 try{…} catch (e: TimeoutCancellationException){…} 代码块,如果你需要对任何类型的超时做一些额外的操作,例如你想使用类似于 withTimeout 方法,但在超时的情况下不是抛出异常而是返回 null 的 withTimeoutOrNull 函数:

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 类模拟一个可关闭资源,它只是通过对获得的计数器递增,并对该计数器从其关闭函数递减来跟踪创建次数。让我们用小超时运行大量的协程,尝试在一段延迟后从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
}

如果运行上面的代码,您将看到它并不总是打印 0,尽管它可能取决于您的机器的时间,在本例中您可能需要调整超时以实际看到非零值。

要解决这个问题,可以在变量中存储对资源的引用,而不是从withTimeout块返回它。

fun main() {
    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。资源不会泄漏。