[译] 协程中的取消和异常(第 2 部分)- 协程中的取消

原文:https://medium.com/androiddevelopers/cancellation-in-coroutines-aa6b90163629


在开发中,就像在生活中一样,我们知道避免做多余的工作很重要,因为这会浪费内存和精力。这个原则也适用于协程。您需要确保控制协程的生命周期,并在不再需要时将其取消——这就是结构化并发所代表的含义。继续阅读以了解协程取消的来龙去脉。

调用取消

启动多个协程时,跟踪它们或单独取消每个协程可能会很痛苦。相反,我们可以依靠取消启动协程的整个 scope,因为这将取消它所有创建的子协程:

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

取消 scope 会取消其子项

有时您可能只需要取消一个协程,可能是作为对用户输入的反应。调用 job1.cancel 确保只有特定的协程被取消并且所有其他兄弟不受影响:

// 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()

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

协程通过抛出一个特殊的异常处理取消:CancellationException。如果您想提供有关取消原因的更多详细信息,您可以在调用 .cancel 时提供一个 CancellationException 实例,因为这是完整的方法签名:

fun cancel(cause: CancellationException? = null)

如果您不提供自己的 CancellationException 实例,CancellationException 则会创建一个默认实例(完整代码在此):

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

由于 CancellationException 被抛出,那么你就可以使用这个机制来处理协程取消。有关如何执行此操作的更多信息,请参阅下面的处理取消副作用部分。

在幕后,子作业通过异常来通知其父作业它被取消。父级使用取消的 cause 来确定是否需要处理异常。如果子项因 CancellationException 被取消,则父项不需要其他任何操作。

⚠️一旦取消 scope,您将无法在取消的 scope 内启动新的协程。

如果您使用 androidx KTX 库,则在大多数情况下不需要创建自己的 scope,因此您不负责取消它们。如果您在一个的 ViewModel 内的 scope 工作,使用 viewModelScope,或者,如果您想启动与一个生命周期 scope 相关联的协程,您将使用 lifecycleScope。viewModelScope 和 lifecycleScope 都是在适当的时候被取消的 CoroutineScope 对象。例如,当 ViewModel 被清除时,它会取消在其 scope 内启动的协程。

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

如果我们只是调用 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 时,我们正在创建一个处于活动状态的新协程。我们让协程运行 1000ms。所以现在我们看到打印:

Hello 0
Hello 1
Hello 2

一旦 job.cancel 被调用,我们的协程就会进入取消状态。但是,我们看到 Hello 3 和 Hello 4 被打印到终端。只有在工作完成后,协程才会进入Canceled 状态。

当调用取消时,协程工作不会停止。相反,我们需要修改我们的代码并定期检查协程是否处于活动状态。

取消协程代码需要配合!

使您的协程工作可取消

您需要确保您正在实现的所有协程工作都与取消配合,因此您需要定期或在开始任何长时间运行的工作之前检查取消。例如,如果您正在从磁盘读取多个文件,则在开始读取每个文件之前,请检查协程是否被取消。像这样,当不再需要时,您可以避免执行 CPU 密集型工作。

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

kotlinx.coroutines 的所有的挂起函数都是可以取消的:withContext,delay 等等。所以,如果你正在使用其中任何一个,你不需要检查取消和停止执行或抛出 CancellationException。但是,如果您不使用它们,为了使您的协程代码具有协作性,我们有两个选择:

  • 检查 job.isActive 或 ensureActive()
  • 使用 yield()让其他工作发生

检查作业的活动状态

一个选项是在 while(i<5) 中为协程状态添加另一个检查:

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

这意味着我们的工作应该只在协程处于活动状态时执行。这也意味着一旦我们离开了一段时间,如果我们想要做一些其他的操作,比如记录作业是否被取消,我们可以添加一个检查 !isActive 并在那里执行我们的操作。

Coroutines 库提供了另一种有用的方法 - ensureActive()。它的实现是:

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

因为如果作业不是活跃的,这个方法会立即抛出,我们可以把它作为我们在 while 循环中做的第一件事:

while (i < 5) {
    ensureActive()}

通过使用 ensureActive,您可以避免自己实现 isActive 所需要的 if 语句,减少了需要编写的样板代码量,但失去了执行任何其他操作(如日志记录)的灵活性。

使用 yield() 让其他工作发生

如果您正在做的工作是

  1. CPU 繁重
  2. 可能会耗尽线程池
  3. 您希望允许线程执行其他工作而不必向池中添加更多线程

那么使用yield()。yield 完成的第一个操作将检查是否完成,如果作业已经完成,则通过抛出 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 的作用是挂起协程,直到计算出结果;由于协程被取消,结果无法计算。因此,在取消后调用 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
    }
}
// the coroutine work is completed so we can cleanup
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!)

但是,如果我们需要执行的清理工作正在挂起,上面的代码将不再起作用,因为一旦协程处于取消状态,它就不能再挂起。

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

为了能够在协程被取消时调用挂起函数,我们需要把清理工作切换到 NonCancellable CoroutineContext。这将允许代码挂起并将协程保持在 Canceling 状态,直到工作完成。

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中定义的 CoroutineScopes:viewModelScope 或者 lifecycleScope,在其 scope 完成时取消其工作。如果您正在创建自己的 CoroutineScope,请确保将其绑定到作业并在需要时调用取消。

协程代码的取消需要协作,因此请确保更新代码以检查取消是否为惰性,并避免做不必要的工作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值