协程中的取消和异常 | 异常处理详解

在平常的开发中,咱们都知道应该避免没必要要的任务处理来节省设备的内存空间和电量的使用——这一原则在协程中一样适用。您须要控制好协程的生命周期,在不须要使用的时候将它取消,这也是结构化并发所倡导的,继续阅读本文来了解有关协程取消的前因后果。html

⚠️ 为了可以更好地理解本文所讲的内容,建议您首先阅读本系列中的第一篇文章: 协程中的取消和异常 | 核心概念介绍android

调用 cancel 方法

当启动多个协程时,不管是追踪协程状态,仍是单独取消各个协程,都是件让人头疼的事情。不过,咱们能够经过直接取消协程启动所涉及的整个做用域 (scope) 来解决这个问题,由于这样能够取消全部已建立的子协程。git

// 假设咱们已经定义了一个做用域

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

scope.cancel()

取消做用域会取消它的子协程github

有时候,您也许仅仅须要取消其中某一个协程,好比用户输入了某个事件,做为回应要取消某个进行中的任务。以下代码所示,调用 job1.cancel 会确保只会取消跟 job1 相关的特定协程,而不会影响其他兄弟协程继续工做。多线程

// 假设咱们已经定义了一个做用域

val job1 = scope.launch { … }
val job2 = scope.launch { … }
 
// 第一个协程将会被取消,而另外一个则不受任何影响
job1.cancel()

被取消的子协程并不会影响其他兄弟协程并发

协程经过抛出一个特殊的异常 CancellationException 来处理取消操做。在调用 .cancel 时您能够传入一个 CancellationException 实例来提供更多关于本次取消的详细信息,该方法的签名以下:async

fun cancel(cause: CancellationException? = null)

若是您不构建新的 CancellationException 实例将其做为参数传入的话,会建立一个默认的 CancellationException (请查看 完整代码)。ide

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

一旦抛出了 CancellationException 异常,您即可以使用这一机制来处理协程的取消。有关如何执行此操做的更多信息,请参考下面的处理取消的反作用一节。函数

在底层实现中,子协程会经过抛出异常的方式将取消的状况通知到它的父级。父协程经过传入的取消缘由来决定是否来处理该异常。若是子协程由于 CancellationException 而被取消,对于它的父级来讲是不须要进行其他额外操做的。post

不能在已取消的做用域中再次启动新的协程

若是您使用的是 androidx KTX 库的话,在大部分状况下都不须要建立本身的做用域,因此也就不须要负责取消它们。若是您是在 ViewModel 的做用域中进行操做,请使用 viewModelScope.viewModelScope:kotlinx.coroutines.CoroutineScope),或者若是在生命周期相关的做用域中启动协程,那就应该使用 [lifecycleScope](https://developer.android.goo...
)。viewModelScope 和 lifecycleScope 都是 CoroutineScope 对象,它们都会在适当的时间点被取消。例如,当 ViewModel 被清除时,在其做用域内启动的协程也会被一块儿取消。

为何协程处理的任务没有中止?

若是咱们仅是调用了 cancel 方法,并不意味着协程所处理的任务也会中止。若是您使用协程处理了一些相对较为繁重的工做,好比读取多个文件,那么您的代码不会自动就中止此任务的进行。

让咱们举一个更简单的例子看看会发生什么。假设咱们须要使用协程来每秒打印两次 "Hello"。咱们先让协程运行一秒,而后将其取消。其中一个版本实现以下所示:

咱们一步一步来看发生了什么。当调用 launch 方法时,咱们建立了一个活跃 (active) 状态的协程。紧接着咱们让协程运行了 1,000 毫秒,打印出来的结果以下:

Hello 0
Hello 1
Hello 2

当 job.cancel 方法被调用后,咱们的协程转变为取消中 (cancelling) 的状态。可是紧接着咱们发现 Hello 3 和 Hello 4 打印到了命令行中。当协程处理的任务结束后,协程又转变为了已取消 (cancelled) 状态。

协程所处理的任务不会仅仅在调用 cancel 方法时就中止,相反,咱们须要修改代码来按期检查协程是否处于活跃状态。

让您的协程能够被取消

您须要确保全部使用协程处理任务的代码实现都是协做式的,也就是说它们都配合协程取消作了处理,所以您能够在任务处理期间按期检查协程是否已被取消,或者在处理耗时任务以前就检查当前协程是否已取消。例如,若是您从磁盘中获取了多个文件,在开始读取文件内容以前,先检查协程是否被取消了。相似这样的处理方式,您能够避免处理没必要要的 CPU 密集型任务。

val job = launch {
    for(file in files) {
        // TODO 检查协程是否被取消
        readFile(file)
    }
}

全部 kotlinx.coroutines 中的挂起函数 (withContext, delay 等) 都是可取消的。若是您使用它们中的任一个函数,都不须要检查协程是否已取消,而后中止任务执行,或是抛出 CancellationException 异常。可是,若是没有使用这些函数,为了让您的代码可以配合协程取消,可使用如下两种方法:

  • 检查 job.isActive 或者使用 ensureActive()
  • 使用 yield() 来让其余任务进行

检查 job 的活跃状态

先看一下第一种方法,在咱们的 while(i<5) 循环中添加对于协程状态的检查:

// 由于处于 launch 的代码块中,能够访问到 job.isActive 属性
while (i < 5 && isActive)

这样意味着咱们的任务只会在协程处于活跃的状态下执行。一样,这也意味着在 while 循环以外,咱们若还想处理别的行为,好比在 job 被取消后打日志出来,那就能够检查 !isActive 而后再继续进行相应的处理。

Coroutine 的代码库中还提供了另外一个颇有用的方法 —— ensureActive(),它的实现以下:

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

若是 job 处于非活跃状态,这个方法会当即抛出异常,咱们能够在 while 循环开始就使用这个方法。

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

经过使用 ensureActive 方法,您能够避免使用 if 语句来检查 isActive 状态,这样能够减小样板代码的使用量,可是相应地也失去了处理相似于日志打印这种行为的灵活性。

使用 yield() 函数运行其余任务

若是要处理的任务属于 1) CPU 密集型,2) 可能会耗尽线程池资源,3) 须要在不向线程池中添加更多线程的前提下容许线程处理其余任务,那么请使用 yield()。若是 job 已经完成,由 yield 所处理的首要任务将会是检查任务的完成状态,完成的话则直接经过抛出 CancellationException 来退出协程。yield 能够做为按期检查所调用的第一个函数,例如上面提到的 ensureActive() 方法。

Job.join 🆚 Deferred.await cancellation

等待协程处理结果有两种方法: 来自 launch 的 job 能够调用 join 方法,由 async 返回的 Deferred (其中一种 job 类型) 能够调用 await 方法。

Job.join 会挂起协程,直到任务处理完成。与 job.cancel 一块儿使用时,会按照如下方式进行:

  • 若是您调用  job.cancel 以后再调用 job.join,那么协程会在任务处理完成以前一直处于挂起状态;
  • 在 job.join 以后调用 job.cancel 没有什么影响,由于 job 已经完成了。

若是您关心协程处理结果,那么应该使用 Deferred。当协程完成后,结果会由 Deferred.await 返回。Deferred 是 Job 的其中一种类型,它一样能够被取消。

在已取消的 deferred 上调用 await 会抛出 JobCancellationException 异常。

val deferred = async { … }

deferred.cancel()
val result = deferred.await() // 抛出 JobCancellationException 异常

为何会拿到这个异常呢?await 的角色是负责在协程处理结果出来以前一直将协程挂起,由于若是协程被取消了那么协程就不会继续进行计算,也就不会有结果产生。所以,在协程取消后调用 await 会抛出 JobCancellationException 异常: 由于 Job 已被取消。

另外一方面,若是您在 deferred.cancel 以后调用 deferred.await 不会有任何状况发生,由于协程已经处理结束。

处理协程取消的反作用

假设您要在协程取消后执行某个特定的操做,好比关闭可能正在使用的资源,或者是针对取消须要进行日志打印,又或者是执行其他的一些清理代码。咱们有好几种方法能够作到这一点:

检查 !isActive

若是您按期地进行 isActive 的检查,那么一旦您跳出 while 循环,就能够进行资源的清理。以前的代码能够更新至以下版本:

while (i < 5 && isActive) {
    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!”)

可是,一旦咱们须要执行的清理工做也挂起了,那上述代码就不可以继续工做了,由于一旦协程处于取消中状态,它将不能再转为挂起 (suspend) 状态。您能够查看 完整代码

处于取消中状态的协程不可以挂起

当协程被取消后须要调用挂起函数,咱们须要将清理任务的代码放置于 NonCancellable CoroutineContext 中。这样会挂起运行中的代码,并保持协程的取消中状态直到任务处理完成。

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: viewModelScope 或者 lifecycleScope 中定义的 CoroutineScopes,它们在 scope 完成后就会取消它们处理的任务。若是要建立本身的 CoroutineScope,请确保将其与 job 绑定并在须要时调用 cancel。

协程代码的取消须要是协做式的,所以请将代码更新为对协程的取消操做以延后的方式进行检查,并避免没必要要的操做。

如今,你们了解了本系列的第一部分 协程的一些基本概念、第二部分协程的取消,在接下来的文章中,咱们将继续深刻探讨学习第三部分异常处理,感兴趣的读者请继续关注咱们的更新。

相关文章
相关标签/搜索