【译】Kotlin协程的取消

8,129 阅读5分钟

原文:Cancellation in coroutines
作者:Florina Muntenescu
译者:luckykelan

在开发中就像在生活中一样,我们知道要避免做过多的工作,因为这会浪费内存和经历。这个原则同样适用于协程。您需要确保控制好协程的生命周期并在不需要时取消它 —这就是协程结构化并发所表现的。

⚠️ 为了无障碍的阅读本文的其余部分,请阅读并理解本系列的第一章

取消正在进行的协程

当启动多个协程时,逐个跟踪或取消它们可能会很麻烦,但是我们可以依靠取消父协程或协程作用域,因为这将取消它创建的所有协程。

//假设我们已经为以下代码定义了一个作用域scope

val job1 = scope.launch {...} 
val job2 = scope.launch {...} 

scope.cancel()

取消一个协程作用域将同时取消此作用域下的所有子协程(Cancelling the scope cancels its children)

有时您可能只需要取消一个协程,调用job1.cancel()可确保仅取消特定协程,所有它的同级协程都不会受到影响。

val job1 = scope.launch { … }
val job2 = scope.launch { … }
// 第一个协程被取消,第二个不受影响
job1.cancel()

被取消的子协程不会影响到其他同级的协程(A cancelled child doesn’t affect other siblings)

协程通过抛出一个特殊的异常来处理取消操作:CancellationException 。如果您想要提供更多关于取消原因的细节,可以在调用在调用cancel()方法时传入一个CancellationException 实例,因为这是cancel()的完整方法签名:

fun cancel(cause: CancellationException? = null)

如果使用缺省调用,则会创建一个默认的CancellationException 实例(此处有完整代码)

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

因为协程的取消会抛出CancellationException ,所以我们可以利用这个机制来对协程的取消进行一些操作。关于如何操作的详细说明,请参见本文下面的“处理取消副作用”小节。
在底层,子协程的取消会通过抛出异常来通知父级,而父级根据取消的原因来确定是否需要处理异常。如果子协程是由于CancellationException 而被取消,那么父级就不需要再执行其他操作。

⚠️ 我们无法在一个已经取消了的协程作用域内再创建新协程

当使用androidx KTX库时,大多数情况我们不需要创建自己的作用域,因此我们也不负责取消它们。比如在ViewModel 中我们可以使用viewModelScope ,或者当我们想启动一个与页面生命周期相关的协程时可以使用lifecycleScope viewModelScope lifecycleScope 都是可以在正确的时间可以被自动取消的协程作用域对象。例如,当ViewModel 被清除时,也会同时取消在 viewModelScope中启动的协程。

为什么我的协程没有停止

如果我们仅调用cancel()方法,并不意味着协程的工作就会立刻停止。如果协程正在执行一些比较繁重的计算,比如从多个文件中读取数据,则不会有任何东西可以让此协程自动停止。 让我们举个更简单的例子看看会发生什么。假设我们需要使用协程以每秒两次的速度打印"Hello",我们让协程运行一秒钟然后取消它;

import kotlinx.coroutines.*
 
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时,我们正在创建一个处于活动状态的新协程,我们让这个协程运行1000毫秒,现在我们看到了:

Hello 0
Hello 1
Hello 2

一旦调用了job.cancel(),协程会进入Cancelling 状态,但是之后我们仍然看看到了Hello3和Hello4被打印到了控制台上。只有协程在完成工作后,才会被移入 Cancelled 状态。
协程不会在调用job.cancel()时立即停止,所以我们需要修改代码并定期检查协程是否处于活动状态。

⚠️ 协程的取消是协作式的,是需要配合的。

使协程可被取消

我们需要确保所有协程都是与取消是协作的,因此需要定期或在任何开始长时间运行的工作之前检查是否有取消。例如,当我们从磁盘读取多个文件,在开始读取每个文件之前都应该检查协程是否已被取消,这样,当不再需要CPU时,就可以避免执行CPU密集型操作以减少消耗。

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

kotlinx.coroutines中的所有挂起函数都是可以被取消的,如withContext() , delay() 等。因此,如果我们使用它们中的任何一个,都不需要检查取消并立即停止或抛出CancellationException 。但如果不使用这些,为了使我们的协程可协作式取消,我们有两个选择:

  • 使用job.isActiveensureActive()来检查
  • 使用yield()

检查Job的活动状态

依然以上面的代码为例,第一种选择是在while(i < 5)处添加一个协程的状态检查

//因为我们在协程内部,所以我们可以访问job.isActive,
while (i < 5 && isActive)

这意味着工作应该只在协程处于活动状态时执行,同时一旦我们离开了while,如果想要执行其他操作,比如记录Job 是否被取消了,则可以添加一个检查!isActive。 协程库提供了另一个很有用的方法 —ensureActive(),它的实现是:

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

因为这个方法会在Job 处于不活跃时立即抛出异常,所以我们可以将其作为while循环中的第一个操作:

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

通过使用ensureActive()方法,我们可以避免自己实现isActive所需的if语句从而减少需要编写的样板代码,但是也失去了执行其他操作(比如日志记录)的灵活性。

使用yield()

如果我们想要执行的操作是 1)占用大量CPU资源,2)可能耗尽线程池,3)以及希望允许线程执行其他工作而不必向池中添加更多线程,应使用。yield()的第一个操作就是检查Job 是否完成,如果Job 完成,则通过抛出CancellationException 来退出协程yield()可以作为定期检查中的第一个函数,就像上文中的ensureActive()

译者注:使用yield()函数应注意,在大多数情况下,yield()会使当前协程暂时挂起以让其他运行在同一线程的协程执行,它提供了一种让多个需要长时间运行的任务公平占用线程的机制。特殊情况如下:1)如果当前协程的调度器为Dispatchers.Unconfined时,仅当有其他调度器同样是Dispatchers.Unconfined且已经形成Event-looper,当前协程才会挂起;2)如果当前协程的上下文中未指定协程调度器,那么yield()不会挂起当前协程。

Job.join vs Deferred.await cancellation

有两种方式可以等待协程的结果:从launch 返回的Job 实例可以调用join 方法,从async 返回的Deferred Job 的子类)可以调用await 方法。
Job.join()会挂起一个协程直到协程的工作完成。和Job.cancel()一起使用会根据我们的调用顺序产生结果:

  • 如果先调用和Job.cancel()然后调用Job.join(),协程将挂起直到Job 完成。
  • 如果先调用Job.join()然后调用和Job.cancel(),将不会产生任何效果,因为协程已经完成了。

当我们对协程的结果更感兴趣时,可以使用Deferred 。当协程完成时,结果会通过Deferred.await()返回。Deferred Job 的子类,所以它也是可被取消的。
对已经被取消的Deferred 调用await()会抛出JobCancellationException 异常。

val deferred = async { … }
deferred.cancel()
val result = deferred.await() // throws JobCancellationException!

因为await 的作用是挂起协程直到得到结果,由于协程已经被取消,因此无法再计算结果。因此,取消后再调用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!”)

可以在这里看看运行效果。
所以现在,当协程不再活跃时,while循环会中断,我们可以进行清理一些资源。

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 的状态,那么它就不能再次挂起。

⚠️ 处于取消状态的协程不能再次挂起!

为了能够再协程被取消时调用挂起函数,我们需要在一个不可取消的协程上下文中切换清理工作,这将允许代码挂起,并将协程保持在Cancelling 状态直至完成工作:

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      withContext(NonCancellable){
         delay(1000L) // 或者其他挂起函数 
         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 { 
          // 可以做一些清理工作
       }
   // 其余的实现
}

为了安全的享用结构化并发的好处并不做不必要的工作,我们需要确保我们协程是可取消的。
使用在JetPack中定义的CoroutineScopes viewModelScope lifecycleScope )可确保当作用域结束时内部的协程也会取消。如果我们要创建自己的CoroutineScopes ,应将它绑定到一个Job 并在需要时取消。
协程的取消时协作式的,所以在使用协程时要确保它的取消是惰性的以避免执行不必要的操作。