如何优雅的处理协程的取消?

这是关于 协程的取消和异常 系列第二篇文章,强烈推荐!

原文作者:Florina Muntenescu

原文地址:Cancellation in coroutines

译者:秉心说

在软件开发乃至生活中,我们都要避免过多无用的工作,这样只会浪费内存和精力。这个原则对协程也是一样。确保你可以控制协程的生命周期,在它不需要工作的时候取消它,这就是 结构化并发 。继续阅读下面的内容,来了解关于协程取消的来龙去脉。

如果你更倾向于视频,可以点击下面的链接观看 Manuel Vivo 和我在 KotlinConf’19 上的演讲。

https://www.youtube.com/watch?v=w0kfnydnFWI&feature=emb_logo

为了帮你更好的理解本文的剩余内容,建议首先阅读该系列的第一篇文章 Coroutines: First things first

调用 cancel

当启动多个协程时,逐个的追踪管理和取消它们是很痛苦的。相反,我们可以依赖于取消整个协程作用域来取消所有通过其创建的子协程。

// 假设我们在这定义了一个协程作用域 scope
val job1 = scope.launch { … }
val job2 = scope.launch { … }
scope.cancel()

取消协程作用域将取消它的所有子协程。

有的时候你可能仅仅只需取消一个协程,例如响应用户输入。job1.cancel 可以确保只有特定的协程被取消,而其他的不受影响。

// 假设我们在这定义了一个协程作用域 scope
val job1 = scope.launch { … }
val job2 = scope.launch { … }
// 第一个协程将被取消,而其他的不受影响
job1.cancel()

取消子协程不会影响其他子协程。

Coroutines 通过抛出一个特殊的异常 CancellationException 来实现协程的取消。如果你想提供更多关于取消原因的细节信息,在调用 cancel() 方法是可以传入一个自定义的 CancellationException 实例:

fun cancel(cause: CancellationException? = null)

如果你并没有提供自己的 CancellationException 实例,系统会提供默认实现。(完整代码在这里)

public override fun cancel(cause: CancellationException?) {
    cancelInternal(cause ?: defaultCancellationException())
}

由于抛出了 CancellationException ,你就可以利用此机制来处理协程的取消了。详见下面的 处理协程取消带来的副作用 章节。

实际上,子 Job 通过异常机制来通知父亲它的取消。父亲通过取消的原因来决定是否处理异常。如果子任务是由于 CancellationException 而取消,父亲就不会做其他额外处理。

⚠️ 协程作用域一旦被取消,就不能在其中创建新协程了。

如果你在使用 androidx KTX 类库的话,大多数情况下你不需要创建自己的作用域,因此你也不需要负责取消它们。如果你在 ViewModel 中工作,直接使用 viewModelScope 。如果你想在生命周期相关的作用内启动协程,直接使用 lifecyclescope

viewModelScopelifecycleScope 都是 CoroutineScope 对象,并且会在适当的时机自动取消。例如,当 ViewModel 进入 cleared 状态时,会自动取消其中启动的所有协程。

为什么协程中的工作没有停止?

当我们调用 cancel 时,并不意味着协程中的工作会立即停止。如果你正在进行重量级的操作,例如读取多个文件,取消协程并不能自动阻止你的代码运行。

让我们做一个小测试看看会发生什么。通过协程每秒打印两次 “Hello”,运行 1 秒之后取消协程。实现代码如下:

fun main(args: Array<String>) = runBlocking<Unit> {
   val startTime = System.currentTimeMillis()
    val job = launch (Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) {
            // print a message twice a second
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("Hello ${i++}")
                nextPrintTime += 500L
            }
        }
    }
    delay(1000L)
    println("Cancel!")
    job.cancel()
    println("Done!")
}

让我们逐步来看发生了什么。当调用 launch 时,创建了一个新的协程,并处于 active 状态。接着让协程运行了 1000ms,会打印如下内容:

Hello 0
Hello 1
Hello 2

一旦 Job.cancel() 被调用,协程变为 Cancelling 状态。但是,控制台仍然打印了 Hello3Hello4 。只有当工作完成之后,协程才进入 Cancelled 状态。

当 cancel 被调用时协程中的工作并不会立即停止。因此,我们需要修改代码来定期检查协程是否处于 active 状态。

代码需要配合完成协程的取消!

让你的协程工作可以被取消

你需要确保创建的所有协程都是可以配合实现取消的,因此你需要定期或者在执行耗时任务之前检查协程状态。例如,你正在从磁盘读取多个文件,那么在读每个文件之前,检查协程是否被取消。这样可以避免进行一些不需要的 CPU 密集型工作。

val job = launch {
    for(file in files) {
        // TODO check for cancellation
        readFile(file)
    }
}

kotlinx.coroutines 中的所有挂起函数都是可取消的:withContextdelay 等等。因此你在使用它们时不需要检查,反之,为了是你的协程代码可以配合实现取消,有下面两种方案:

  • 检查 job.isActive 或者 ensureActive
  • Let other work happen using yield()

检查 Job 状态

一种方案是在 while(i<5) 中添加检查协程状态的代码。

// Since we're in the launch block, we have access to job.isActive
while (i < 5 && isActive)

这样意味着只有当协程处于 active 状态时,我们工作的才会执行。如果我们想在协程被取消之后做些其他工作,例如打印 log,可以检测 !isActive

协程标准库提供了一个有用的函数:ensureActive(),它的实现是这样的:

fun Job.ensureActive(): Unit {
    if (!isActive) {
         throw getCancellationException()
    }
}

ensureActive() 在协程不在 active 状态时会立即抛出异常,所以也可以这样做:

while (i < 5) {
    ensureActive()
    …
}

使用 ensureActive(),无需你手动检测 isActive,减少了样板代码,但丧失了一定灵活性,例如在协程取消后打印 log 。

使用 yield()

如果正在进行的任务是这样的:

  1. 占用大量 CPU 资源
  2. 可能会耗尽线程池资源
  3. 允许在不往线程池中添加线程的前提下,执行其他任务

这时候请使用 yield()yield 会进行的第一个工作就是检查任务是否完成,如果 Job 已经完成的话,就会抛出 CancellationException 来结束协程。yield 应该在定时检查中最先被调用,就像前面提到的 ensureActive 一样。

Job.join() 和 Deferred.await() 的取消

获取协程的返回值有两种方法。第一种是,由 launch 方法启动的 Job,可以调用它的 join() 方法;async 方法启动的 Deferred(也是一种 Job),可以调用它的 await() 方法。

Job.join 会挂起协程直到任务结束。它和 Job.cancel() 一起配合会表现如下:

  • 如果先调用 job.cancel,再调用 job.join,协程依然会被挂起直到任务结束。
  • job.join 之后调用 job.cancel 不会产生任务影响,因为任务已经结束了。

通过 Deferred 也可以获取协程的执行结果。当任务结束时,Deferred.await 就会返回执行结果。Deferred 是一种 Job,它也是可以被取消的。

对已经被取消的 deferred 调动 await 方法会抛出 JobCancellationException

val deferred = async { … }

deferred.cancel()
val result = deferred.await() // throws JobCancellationException!

await 的作用是挂起协程直到结果被计算出来。由于协程被取消了,结果无法被计算。所以,cancel 之后再 await 会导致 JobCancellationException: Job was cancelled

另外,如果你在 deferred.await 之后调用 deferred.cancel ,那么什么都不会发生,因为任务已经结束了。

处理协程取消带来的副作用

现在假设我们需要在协程取消时做一些特定的任务:关闭正在使用的资源,打印取消日志,或者其他一些你想执行的清理类代码,有以下几种方法可以实现。

检查 !isActive

定期检查 isActive,一旦跳出 while 循环,就可以清理资源了。我们的示例代码更新如下:

while (i < 5 && isActive) {
    // print a message twice a second
    if (…) {
        println(“Hello ${i++}”)
        nextPrintTime += 500L
    }
}
// 协程已经完成工作
println(“Clean up!”)

Try catch finally

由于协程被取消时会抛出 CancellationException ,所以我们可以把挂起函数包裹在 try/catch 代码块中,这样就可以在 finally 代码块中进行资源清理操作了。

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      println(“Clean up!”)
    }
}
delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

但是,如果执行清理任务的函数也是需要挂起的,那么上面的代码是无效的,因为协程已经处于 Cancelling 状态了。完整代码在 这里

处于取消状态的协程无法再被挂起!

为了能够在协程被取消时调用挂起函数,我们需要将任务切换到 NonCancellable 的协程上下文来执行,它会将协程保持在 Cancelling 状态直到任务结束。

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      withContext(NonCancellable){
         delay(1000L) // or some other suspend fun 
         println(“Cleanup done!”)
      }
    }
}
delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

这里 你可以进行练习。

suspendCancellableCoroutine 和 invokeOnCancellation

如果你使用 suspendCoroutine 来将回调转换为协程,那么请考虑使用 suspendCancellableCoroutine 。协程取消时需要进行的工作可以在 continuation.invokeOnCancellation 中实现。

suspend fun work() {
   return suspendCancellableCoroutine { continuation ->
       continuation.invokeOnCancellation { 
          // do cleanup
       }
   // rest of the implementation
}

最后

为了实现结构化并发以及避免进行无用工作,你必须确保你的任务可以被取消。

使用 Jetpack 中定义的协程作用域(viewModelScopelifecycleScope)可以帮助你自动取消任务。如果你使用自己定义的协程作用域,请绑定 Job 并在适当的时候取消它。

协程的取消需要代码配合实现,所以确保你在代码中检测了取消,以避免额外的无用工作。

但是,在某些工作模式下,任务不应该被取消?那么,应该如何实现呢,请等待该系列第四篇文章。


今天的文章就到这里了,这个系列还有两篇文章,都很精彩,扫描下方二维码持续关注吧!

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://luyao.tech/archives/cancellation-in-coroutines