我将翻译三篇介绍协程的 取消 和 异常处理 相关的文章,三篇文章是层层递进的关系。翻译过程中我将尽量忠实于原文。当然,由于水平有限,不能保证完全的翻译正确。如果您发现翻译有错误之处,欢迎在评论里指出。我也将贴出每篇翻译的原文。
- 第一篇:《协程:第一件事》(原文: Coroutines: first things first)
- 第二篇:《协程的取消》(原文:Cancellation in coroutines)
- 第三篇:《协程的异常》(原文:Exceptions in coroutines)
这是第二篇。
文章目录
在开发中,就如在生活中一样,我们都知道避免做多余的事情很重要,因为它会浪费 memory
和 energy
。这一原则也适用于协程。你需要确保自己能控制协程的生命周期,并在不再需要的时候取消它——这就是结构化并发所代表的。请继续阅读,探寻协程取消的来龙去脉。
调用 cancel 方法
当启动多个协程时,追踪或者一个个地取消它们是一件很痛苦的事情。但是,我们可以取消启动协程的整个 scope
,这样我们就能取消在它里面启动的所有子协程。
//假设我们已经定义了一个 scope
val job1 = scope.launch { ... }
val job2 = scope.launch { ... }
scope.cancel()
取消
scope
会取消它的子协程
有时候,你可能只需要取消一个协程,比如说响应用户的输入。调用 job1.cancel
确保只有特定的协程被取消,其它所有的同级别协程不会受影响:
val job1 = scope.launch { ... }
val job2 = scope.launch { ... }
//协程job1会被取消,而其它的不会受影响
job1.cancel()
取消一个子协程不会影响同级别的其它协程
协程通过抛一个特殊的异常(CancellationException
)来处理取消。如果你想为取消的原因提供更多细节,你可以在调用 cancel
方法的时候提供一个 CancellationException
实例,以下是 cancel
方法的完整签名:
fun cancel(cause: CancellationException? = null)
如果你不提供自己的 CancellationException
实例,一个默认的CancellationException
实例将被创建,如下:
public override fun cancel(cause: CancellationException?) {
cancelInternal(cause ?: defaultCancellationException())
}
因为CancellationException
被抛出,你能通过该机制来处理协程的取消。更多关于如何取消的内容请阅读后面的章节 处理取消的副作用。
在协程底层,子 job
通过异常来通知父节点它的取消。父节点使用取消的 cause
来判断是否处理这个异常。如果子任务是因为 CancellationException
被取消,那么父节点就不必做额外的动作。
一旦你取消一个
scope
,你将无法在被取消的scope
里发起新的协程
如果你正在使用 android KTX
库,在大多数情况下你无需创建自己的 scope
,因此也不必负责取消它们。如果你想在 ViewModel
的 scope
中工作,可以使用 viewModelScope
。或者,如果你想启动一个与生命周期的 scope
绑定的协程,则可以使用 lifecycleScope
。viewModelScope
和 lifecycleScope
都是会在适当的时候被取消的 CoroutineScope
对象。例如,当 ViewModel
被清理的时候(其 clear()
方法将被调用),它会取消在它的 scope
中启动的协程。
为什么我的协程没有停止?
如果我们只是调用 cancel
方法,并不表示协程的工作会立即停止。如果你正在执行一些相对重型的计算工作,比如读取多个文件,那么没有什么能自动停止正在执行的代码。
让我们用一个更简单的例子来看看会发生什么。比如我们需要在协程里每秒打印两次 "Hello"
,我们让这个协程运行一秒钟,然后取消掉它。看下面的代码:
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) {
// 每秒打印两次消息
if (System.currentTimeMillis() >= nextPrintTime) {
println("Hello ${i++}")
nextPrintTime += 500L
}
}
}
delay(1000L)
println("Cancel!")
job.cancel()
println("Done!")
}
让我们一步一步来看看会发生什么。当调用 launch
的时候,我们创建了一个状态为 Activie
的新协程。我们让该协程运行1000毫秒,因此我们会看到如下的打印输出:
Hello 0
Hello 1
Hello 2
一旦 job.cancel()
被调用,我们的协程就会进入 Cancelling
状态。但随后,我们会看到 Hello 3
和 Hello 4
也被打印到终端。只有当任务完成之后,协程才会变为 Cancelled
状态。
上面代码的完整打印如下:
Hello 0
Hello 1
Hello 2
Cancel!
Done!
Hello 3
Hello 4
协程的工作并不会在调用 cancel
方法的时候立即停止。相反,我们需要修改自己的代码,并且周期性地检查协程是否处于 Active
状态。
协程取消的代码需要是可协作的!
使你的协程工作可以取消
你需要确保自己实现的所有协程工作都可以协作取消,因此,你需要周期性地、或者在开始长时间运行任务前进行检查。例如,如果你需要从磁盘中读取多份文件,在读取每份文件之前,应该检查协程是否已被取消。这样你就能避免执行多余的CPU密集型工作。
val job = launch {
for(file in files) {
//读文件之前,检查协程是否处于活跃状态
if(!isActive){
break
}
readFile(file)
}
}
来自 kotlinx.coroutines
的所有挂起方法都是可取消的:比如 withContext
,delay
等。因此,当你使用它们中的任意一个方法时,你不必进行取消检查并停止执行,或者抛出 CancellationException
。但是,如果你不使用它们,自己写协程代码的时候我们有两个选择:
- 调用
job.isActive()
或者ensureActive()
进行检查 - 调用
yield()
方法,让其它工作进行
检查Job的活跃状态
在前面的 while(i < 5)
中,可以添加对协程状态的检查:
while (i < 5 && isActive)
这意味着我们的工作只在协程处于 Active
状态时才执行。这也意味着一旦出了 while
循环,如果我们想做其它操作,比如打印 job
是否被取消,我们可以添加一个检查 !isActive
并执行我们的工作。
协程库提供了另一个有用的方法 ensureActivie()
。它的实现如下:
fun Job.ensureActive(): Unit {
if (!isActive) {
throw getCancellationException()
}
}
因为这个方法会在 job
处于非 Active
状态时立即抛异常,我们可以在 while
循环中首先调用它:
while( i < 5) {
ensureActive()
...
}
使用 ensureActive()
方法,你可以避免自己实现 isActive
所需要的 if
语句,减少你需要写的模板代码量,但是,也会丢失执行其它操作的灵活性,比如记录日志。
使用yield()让其它工作进行
如果你正在执行的工作:1) CPU占用高,2) 也许会耗尽线程池,3) 你想让线程做其它工作,而不必向线程池添加更多线程,那么就使用 yield()
。yield()
做的第一项操作是进行完成检查,如果 job
已经执行完成,那么就会通过抛 CancellationException
退出该协程。和前面的 ensureActive()
一样,yield()
可以作为周期性检查的第一个方法。
Job.join vs Deferred.await 取消
有两种方式等待协程的结果:通过 launch
返回的 job
可以调用 join
,通过 async
返回的 Deferred
(另一种形式的 Job
)可以调用 await
。
Job.join
会挂起当前协程直到它的工作执行完成。和 job.cancel
搭配使用,它会有如下表现:
- 如果你先调用
job.cancel
,然后调用job.join
,当前协程会挂起直到job
完成。 - 在
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 was cancelled
。
相反,如果你在 deferred.await
之后调用 deferred.cancel
,什么事都不会发生,因为协程已经完成了。
处理取消的副作用
假设你想在协程取消的时候执行一些特殊的操作:关闭你可能正在使用的任何资源,记录取消信息,或者你想执行其它的清理代码。我们有几种方法可以做到这一点:
检查 !isActive
如果你在使用 isActive
做周期性检查,那么一旦跳出 while
循环,你就可以清理资源。我们前面的代码可以更新如下:
while(i < 5 && isActive){
// 每秒打印两次消息
if (...) {
println("Hello ${i++}")
nextPrintTime += 500L
}
}
//协程的工作完成了,可以开始清理工作
println("Clean up!")
现在,当协程不再 Active
时候,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
状态的协程不能挂起!
为了能在协程被取消的时候调用挂起方法,我们需要把清理工作切换到不可取消的 CoroutineContext
中执行。这将允许代码挂起,并使协程保持为 Cancelling
状态,直到工作完成。
val job = launch {
try {
work()
} catch (e: CancellationException) {
println(“Work cancelled!”)
} finally {
withContext(NonCancellable) {
delay(1000L) //
println(“Clean up!”)
}
}}
delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)
suspendCancellableCoroutine 和 invokeOnCancellation
如果你使用 suspendCoroutine
方法将回调转换成协程,那么最好使用 suspendCancellableCoroutine
方法替代它。取消时要做的工作可以用 continuation.invokeOnCancellation
来实现:
suspend fun work() {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
// 清理工作
}
}
}
为了实现结构化并发的好处,确保我们没有做多余的工作,你需要确保自己的代码是可取消的。
使用在 JetPack
里面定义好的 CoroutineScope
:viewModelScope
或者 lifecycleScope
, 它们会在 scope
完成的时候取消任务。如果你使用自己创建的 CoroutineScope
,确保你把它与 job
绑定并在需要的时候取消它。
协程取消的代码需要是可协作的,因此确保你的代码有取消检查,以避免做多余的工作。