Kotlin协程的取消

原文

作为开发者,我们通常会花费大量的时间来完善我们的应用程序。然而,当事情不尽如人意时,提供合适的用户体验也同样重要。一方面,看到应用程序崩溃对用户来说是一种很糟糕的体验;另一方面,当一个操作失败时,向用户显示正确的提示信息也是必不可少的。 正确的处理异常对用户如何看待我们的App尤为重要。在本文中,我们将解释异常时如何在协程中传播的,以及如何始终进行控制,包括处理异常的不同方法。

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

取消正在进行的协程

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

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

取消一个协程作用域将同时取消此作用域下的所有子协程。

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

被取消的子协程不会影响到其他同级的协程。

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

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

因为协程的取消会抛出CancellationException ,所以我们可以利用这个机制来对协程的取消进行一些操作。关于如何操作的详细说明,请参见本文下面的“处理取消副作用”小节。

在底层,子协程的取消会通过抛出异常来通知父级,而父级根据取消的原因来确定是否需要处理异常。如果子协程是由于CancellationException 而被取消,那么父级就不需要再执行其他操作。

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

Why isn’t my coroutine work stopping?

如果我们仅调用cancel()方法,并不意味着协程的工作就会立刻停止。如果协程正在执行一些比较繁重的计算,比如从多个文件中读取数据,则不会有任何东西可以让此协程自动停止。

让我们举个更简单的例子看看会发生什么。假设我们需要使用协程以每秒两次的速度打印"Hello",我们让协程运行一秒钟然后取消它;

让我们一步一步看看会发生什么。当调用launch时,我们正在创建一个处于活动状态的新协程,我们让这个协程运行1000毫秒,现在我们看到了:

一旦调用了job.cancel(),协程会进入Cancelling 状态,但是之后我们仍然看看到了Hello3和Hello4被打印到了控制台上。只有协程在完成工作后,才会被移入 Cancelled 状态。

协程不会在调用job.cancel()时立即停止,所以我们需要修改代码并定期检查协程是否处于活动状态。

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

使协程可被取消

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

  • 使用job.isActive或ensureActive()来检查

  • 使用yield()

检查Job的活动状态

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

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

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

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

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

使用yield()

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

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 异常。

因为await 的作用是挂起协程直到得到结果,由于协程已经被取消,因此无法再计算结果。因此,取消后再调用await 会导致JobCancellationException: Job was cancelled。

另一方面,如果在deferred.await()之后调用deferred.cancel()不会有任何效果,因为协程已经完成了。

处理取消的副作用

假设我们想在协程被取消时执行某个特定的操作比如关闭可能正在使用的任何资源,记录取消或执行其他清理代码,有几种方法可以做到这一点:

使用isActive检查

如果我们定期检查了isActive 的状态,那么一旦退出while循环,我们就可以做一些清理资源的工作,上面的代码可以更新为:

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

Try catch finally

由于协程被取消时会抛出一个CancellationException ,因此可以将协程包装到try/catch 和finally 块中,从而执行一些清理工作:

但是,如果我们需要执行的清理工作是需要挂起的,则上面的代码不再起作用,因为一旦协程处于Cancelling 的状态,那么它就不能再次挂起。

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

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

可以在此处实践上述代码。

suspendCancellableCoroutine and invokeOnCancellation

如果使用suspendCoroutine 方法将回调改成协程,那么最好使用suspendCancellableCoroutine 。可以使用continuation.invokeOnCancellation来完成取消工作:

为了安全的享用结构化并发的好处并不做不必要的工作,我们需要确保我们协程是可取消的。

使用在JetPack中定义的CoroutineScopes (viewModelScope 或 lifecycleScope )可确保当作用域结束时内部的协程也会取消。如果我们要创建自己的CoroutineScopes ,应将它绑定到一个Job 并在需要时取消。

协程的取消时协作式的,所以在使用协程时要确保它的取消是惰性的以避免执行不必要的操作。

最后更新于

这有帮助吗?