[译]Coroutines的取消和异常处理(Part 2)

1,790 阅读4分钟

原文作者 :Florina Muntenescu

原文地址: Cancellation and Exceptions in Coroutines (Part 2)

译者 : 京平城

Calling cancel

当launching多个coroutines的时候,一个个的取消它们将会十分麻烦。我们可以通过调用scope的cancel()方法来取消所有的子coroutines:

// assume we have a scope defined for this layer of the app
val job1 = scope.launch { … }
val job2 = scope.launch { … }
scope.cancel()

Cancelling the scope cancels its children
但有的时候,你可能希望取消其中的一个coroutine而不要影响到其它的子coroutines:

// assume we have a scope defined for this layer of the app
val job1 = scope.launch { … }
val job2 = scope.launch { … }
// First coroutine will be cancelled and the other one won’t be affected
job1.cancel()

A cancelled child doesn’t affect other siblings

Coroutines的取消机制是通过抛出一个特殊的异常:CancellationException。 你可以在cancel()方法中传入一个CancellationException实例来记录详细的异常信息:

fun cancel(cause: CancellationException? = null)

如果你不传入一个CancellationException实例的话,那么cancel()方法默认也会创建一个CancellationException实例(详细代码):

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

你可以通过捕获CancellationException来处理coroutine的取消。
child job的取消将通过抛出这个exception来通知它的parent job。Parent job通过判断取消的原因来决定是否需要处理这个异常,如果child job的抛出的是CancellationException,那么parent job不需要做出额外的处理。

⚠️Once you cancel a scope, you won’t be able to launch new coroutines in the cancelled scope.

在Android开发中,如果你已经使用了KTX扩展包,那么你不需要创建自定义的CoroutineScope,也不需要手动去处理coroutines的取消。
举例来说,当你使用ViewModel的时候,我们提供了ViewModel的扩展属性viewModelScope
当你想创建一个具有生命周期感知能力的scope的时候,可以使用lifecycleScope
viewModelScopelifecycleScope都会在正确的时机自动cancel还在执行中的coroutines。具体可以参考[译]Coroutines in Android ViewModelScope篇

译者注:有一种特殊情况,你可能不希望coroutines被自动cancel,比如要发送日志给后端的服务器,又或者一些重要的操作一定需要执行完成,那么可以使用GlobalScope.launch {},但是要注意它的生命周期和你的应用一样长,使用的时候需要非常谨慎,防止内存泄露。
另外本文后面也介绍了另外一种在coroutines被cancelled的情况下,继续执行可挂起代码的方法。

Why isn’t my coroutine work stopping?

单单只是调用了cancel方法,并不意味着你的coroutines就突然停止执行剩余代码了。比如当你批量读取文件的时候,调用cancel方法,你的coroutine是不会立即停止工作的。

译者注:这个和Java中Thread的中断机制是一样的,调用Thread的interrupt()方法的同时,还需要配合检查isInterrupted()来优雅的处理线程的退出。
让我们来看一个例子:

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!")
}

输入结果是:

Hello 0
Hello 1
Hello 2
Cancel!
Done!
Hello 3
Hello 4

都已经执行了job.cancel(),为什么Hello 3和Hello 4还是打印了呢? 其实当job.cancel()调用的时候,虽然coroutine的状态变成Cancelling了,但是剩余代码还是会继续执行,直到所有的工作完成。

Cancellation of coroutine code needs to be cooperative!

Cancellation of coroutine code needs to be cooperative!

coroutine的退出必须是协作式的,你需要定期的去检查coroutine的状态:

val job = launch {
    for(file in files) {
        // TODO 在这里检查是否Job已经被cancel, 是否需要执行剩余代码
        readFile(file)
    }
}

kotlinx.coroutines包下的所有suspend方法都是可取消的:比如withContext, delay等。
当你使用这些可取消方法的时候,你不需要检查coroutines是否已经被cancel了又或者抛出CancellationException了。但是,如果你没有使用这些方法,你需要自己去处理coroutines的取消:

  • 检查状态job.isActive或者ensureActive()
  • 或者使用yield()方法 译者注:yield方法中会调用context.checkCompletion()ensureActive()yield()都会检查Job的state是否还是active的,如果不是会抛出CancellationException来终止coroutines:
public fun Job.ensureActive(): Unit {
    if (!isActive) throw getCancellationException()
}

internal fun CoroutineContext.checkCompletion() {
    val job = get(Job)
    if (job != null && !job.isActive) throw job.getCancellationException()
}

Checking for job’s active state

回到刚才的例子,我们可以通过在while循环中访问job的isActive属性(译者注:isActive是CoroutineScope的一个扩展属性,该扩展属性直接返回了Job的isActive属性)来检查coroutine是否已经被取消:

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

这样while循环内的代码只有当job仍然active的时候才会执行,并且我们可以在while循环结束后通过!isActive条件判断来执行一些自定义的任务,比如记录Job被cancelled的日志。
还有一种做法是在while循环内使用帮助方法ensureActive()

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

这样做的好处是不需要直接访问isActive属性(译者注:封装性更好,方法内封装了具体实现,如果将来修改了实现也不需要修改代码),ensureActive()方法会在Job被cancelled的时候立刻抛出CancellationException,来保证剩余的代码不会被执行。
但是使用ensureActive()的话,在某些情况下会失去代码控制上的灵活性,比如在which循环结束的时候通过!isActive条件判断来记录Job的取消日志。

Let other work happen using yield()

什么情况下我们应该使用yield()方法呢?

  1. 繁重的CPU计算任务。
  2. 有可能会把thread pool的资源耗尽的任务。
  3. 希望thread pool中的线程能更好的协作而不只是通过添加更多的线程到thread pool中的时候。 在这种情况下你可以使用yield()方法来周期性的检查Job的状态,就像刚才例子中的ensureActive()方法一样。

Job.join vs Deferred.await cancellation

获取coroutine返回值的方法有两种:

  1. job.join()方法
  2. async{}.wait()方法,async{}返回的是一个Deferred类型的Job

job.join()方法会暂停coroutine直到工作完成,当它和job.cancel一起使用的时候会产生如下效果:

  • 先调用job.cancel后调用job.join(),那么挂起的job不会被执行完成。 译者注: 这里原文似乎有问题,原文是If you’re calling job.cancel then job.join, the coroutine will suspend until the job is completed.
    但是job.join()执行的时候会判断Job的状态是否是active的,如果不是就直接return了,所以 如果job.join()放在job.cancel后面的话,那么Job就是直接不执行后面代码了。
// join的相关实现在JobSupport.kt中
public final override suspend fun join() {
    if (!joinInternal()) { // fast-path no wait
        coroutineContext.checkCompletion()
        return // do not suspend
    }
    return joinSuspend() // slow-path wait
}
  • 先调用job.join()后调用job.cancel的话,那么cancel无效,因为coroutine已经执行完毕了。

下面分析一下通过async方法创建的Deferred类型的Job和cancel()方法一起使用的效果:

val deferred = async { … }
deferred.cancel()
val result = deferred.await() // throws JobCancellationException!
  • 先调用cancel()方法后调用await()方法会直接抛出异常:JobCancellationException: Job was cancelled
  • 先调用await()方法后调用cancel()方法,什么事都不会发生,因为coroutine已经执行完毕了。

Handling cancellation side effects

如果你想在coroutine取消后执行某些操作:
比如关闭某些正在使用的资源,记录取消日志又或者执行一些cleanup的代码等等。

Check for !isActive

你可以周期性的检查isActive属性,一旦跳出了while循环,就执行clean up操作:

while (i < 5 && isActive) {
    // print a message twice a second
    if (…) {
        println(“Hello ${i++}”)
        nextPrintTime += 500L
    }
}
// the coroutine work is completed so we can cleanup
println(“Clean up!”)

Try catch finally

因为coroutine被取消的时候会抛出CancellationException,那么我们可以捕获这个异常,并且在finally块中执行clean up操作:

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

要注意的是一旦coroutine处于取消的状态,那么接下去要执行的代码就不会被挂起了(suspend)。
A coroutine in the cancelling state is not able to suspend!
如果在clean up操作中,你想继续执行可以被挂起的代码,那么你可以使用NonCancellable这个CoroutineContext
它将维持coroutine的取消状态直到被挂起的的代码执行完毕:

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 and invokeOnCancellation

使用suspendCancellableCoroutinecontinuation.invokeOnCancellation来处理coroutines的回调(译者注:类似于给coroutines的生命周期发生变化的时候添加回调函数)

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

译者注suspendCancellableCoroutine是可以通过job.cancel()来取消的。

总结: 取消coroutines是门学问,请尽量使用viewModelScope或者lifecycleScope