
不久之前,我写过两篇文章,
核心是讲解的协程中取消,如果再配合上讲解异常的两篇文章,
我觉得在协程结束方面(无论是正常结束还是异常结束),在座的各位,应该已经成为了专家。
我同事也是这么想的,看完这些文章,他说:原来也没有这么难,现在的我,强的一逼!
我默不作声,抛出了另一个问题,问这位新晋的协程专家:
Kotlin
fun main() = runBlocking {
val job1 = launch {
delay(500)
throw CancellationException("Cancel job1") // 这里抛出了异常
}
val job2 = launch {
delay(1000L)
println("job 2 done") // 这句话会执行吗?
}
println("All done")
}
例 1
job2 会被取消吗?
不妨再乱一点儿
如果你从结构化并发,异常提升的角度来看,job2 会被取消,因为 job1 中异常提升导致姐妹协程 job2 也被取消了。
OK,这个问题先想到这里,我们按下不表,来看第二个例子:
Kotlin
fun main() = runBlocking {
val job1 = launch {
delay(1000)
println("job 1 done")
}
val job2 = launch {
delay(1000L)
println("job 2 done") // 这句话会执行吗?
}
delay(500)
job1.cancel() // 这里取消了 job1
println("All done")
}
例 2
我同事斩钉截铁地告诉我,job2 不会被取消!而 job1 会被取消。
没错,整个任务都会按照我们理解的那样合理地运行起来,因为我只取消了 job1。
好的,再来一个:
Kotlin
// ...
val job1 = launch {
runCatching { // 这里我把 delay 用 catch 包裹起来了
delay(1000)
}
println("job 1 done")
}
// ...
例 3
我同事稍微有点犯难,你这有用吗?
嘿,我说,还真有用!
上面的代码输出是这样的:
plain
All done
job 1 done
job 2 done
正式起航
开发就像生活一样,我们都清楚没必要做多余的工作 ------ 这只会浪费内存和资源,这个原则同样适用于协程。
你需要确保自己能掌控协程的生命周期,在它不再被需要时及时取消 ------ 这正是结构化并发的核心要义。
scope.cancel
我们给一个新的例子:
Kotlin
scope.launch { }
scope.launch { }
scope.cancel()
当启动多个协程时,如果你觉得逐一跟踪它们或单独取消每个协程会十分繁琐。
我们不妨直接取消承载这些协程的整个 scope,如此一来,该作用域下创建的所有子协程都会被一并取消。
但是这样做其实是有风险的,我们一般不推荐直接取消 scope,因为一旦 scope 取消之后,后续再次创建协程,直接就会被取消掉,被取消的 scope 不会再执行任何协程。
如果在开发中使用标准的协程相关库,在绝大多数场景下都无需自行创建协程作用域,自然也不用负责取消这些作用域。
如果你在 ViewModel 的作用域内开发,可直接使用 viewModelScope;而若想启动与生命周期作用域绑定的协程,则可以使用 lifecycleScope。
viewModelScope 和 lifecycleScope 均为 CoroutineScope 类型的对象,且会在恰当的时机自动取消。例如,当 ViewModel 被销毁时,其作用域内启动的所有协程都会被一并取消。
viewModelScope 我们在 这篇文章 中详细探讨过。
job.cancel
有时你可能只需取消某个特定的协程------比如响应用户输入时。调用 job.cancel() 方法可确保只有该指定协程被取消,其余同级协程均不受影响。
这也正是 例2 的做法。
这样做的好处就是 ------ 被取消的子协程不会对其他同级协程造成影响。
取消是如何执行的
协程通过抛出一种特殊的异常 CancellationException 来处理取消操作。
若你想补充取消原因的更多细节,可在调用 .cancel() 方法时传入 CancellationException 的实例------该方法的完整签名如下:
kotlin
fun cancel(cause: CancellationException? = null)
如果没有提供 CancellationException 实例,系统会自动创建一个默认的 CancellationException(完整代码如下):
kotlin
public override fun cancel(cause: CancellationException?) {
cancelInternal(cause ?: defaultCancellationException())
}
正因为协程取消时会抛出 CancellationException,你才能借助这一机制来处理协程取消逻辑。
底层实现上,子协程会通过该异常通知其父协程自身已被取消。父协程会根据取消操作的原因判断是否需要处理该异常:若子协程因 CancellationException 被取消,则父协程无需执行任何额外操作。
恍然大悟
到这里,前面三个例子的答案已经出现了。
例 1 中的 throw CancellationException("Cancel job1") 这个写法,和 例 2 中的 job1.cancel() 别无二致。
而 例 3 ,因为我们使用了 catch,后续代码是会执行的。
这也是为什么我觉得 Kotlin 协程的取消设计的不好的原因,因为协程取消的判定太依赖 CancellationException------它只是万千异常中的一个特殊 case。
我打个比方,这种感觉就像你在学习加法运算的时候,老师告诉你在 +3 的时候需要特殊处理,+3 处理成减法。这种感觉实在是太奇怪了。
当然,如果你熟知协程取消的内部逻辑,那么这些抱怨也就不成问题了。
为什么有时候我的协程并没有取消
仅仅调用 cancel() 方法,并不意味着协程任务会立刻停止。如果你正在执行一些相对繁重的计算操作(比如从多个文件中读取数据),系统并不会自动终止这些代码的运行。
我们用一个更简单的例子来看看具体现象:假设需要通过协程实现每秒钟打印两次「Hello」,我们让协程运行 1 秒后再取消它:
Kotlin
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!")
}
这段代码输出如下:
plain
Hello 0
Hello 1
Hello 2
Cancel!
Done!
Hello 3
Hello 4
我们一步步拆解实际发生的过程:
调用 launch() 方法时,会创建一个处于活跃状态的新协程。我们让这个协程运行 1000 毫秒,此时会看到控制台打印出:
plain
Hello 0
Hello 1
Hello 2
调用 job.cancel() 后,我们的协程会进入取消中状态。但此时你会发现,终端仍会打印出后续日志,只有当任务完全执行完毕后,协程才会进入已取消状态。
plain
Cancel!
Done!
Hello 3
Hello 4
调用 cancel() 方法并不会直接终止协程任务。相反,我们需要修改代码,定期检查协程是否仍处于活跃状态。
可取消的任务
你需要确保自己实现的所有协程任务都能配合取消操作,因此需定期检查取消状态,或在执行任何耗时任务前先做取消检查。
再次提醒:协程的取消是协作的。需要互相配合、开发者编写额外代码的。
例如,若你要从磁盘读取多个文件,在开始读取每个文件前,都应检查协程是否已被取消。这样做能避免在任务已无必要执行时,仍消耗大量 CPU 资源。
kotlin
val job = launch {
for(file in files) {
// TODO 检查取消状态
readFile(file)
}
}
标准的协程库中所有挂起函数均支持取消:比如 withContext、delay 等。
因此只要使用了这些函数,你无需手动检查取消状态、终止执行或抛出 CancellationException。
但如果未使用这些函数,要让协程代码支持取消,有两种方案可选:
- 检查
job.isActive或调用ensureActive() - 使用
yield()让出执行权,让其他任务得以执行
检查 Job 的活跃状态
一种方案是在 while(i < 5) 循环中,增加对协程状态的检查:
kotlin
// 因为我们在 launch 代码块内,所以可以直接访问 job.isActive
while (i < 5 && isActive)
这句话的意思是,只有当协程处于活跃状态时,我们的任务才会执行。
同时,这也意味着当退出循环后,如果我们想执行一些额外操作(比如记录 Job 是否被取消),可以通过检查 !isActive 来触发相应的行为。
ensureActive
协程库还提供了另一个实用方法------ensureActive()。它的实现逻辑如下:
kotlin
fun Job.ensureActive(): Unit {
if (!isActive) {
throw getCancellationException()
}
}
这个方法会在 Job 被取消时立即抛出异常 ,因此我们可以把它放在 while 循环的开头:
kotlin
while (i < 5) {
ensureActive()
// ...
}
使用 ensureActive() 可以避免手动编写 isActive 所需的 if 判断语句,减少样板代码的编写量。但代价是,你会失去执行日志记录等额外操作的灵活性(因为没有检查是否取消,而是直接抛出异常函数结束了)。
yield
如果你正在执行的任务满足以下条件:
- 是 CPU 密集型操作;
- 可能耗尽线程池;
- 你希望在不增加线程池线程数量的情况下,让当前线程能处理其他任务。
那么就可以使用 yield()。
yield() 执行的第一步是检查任务是否已完成,如果 Job 已经结束,它会通过抛出 CancellationException 来终止当前协程。
yield() 可以像前面提到的 ensureActive() 一样,作为周期性检查的第一个调用函数。
实际上,几乎任何一个良好编写 suspend 函数,都会在协程取消的时候抛出 CancellationException。
Job.join 与 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。
kotlin
val deferred = async { ... }
deferred.cancel()
val result = deferred.await() // 抛出 JobCancellationException!
之所以会抛出异常,是因为 await 的作用是挂起协程,直到结果计算完成;而如果协程已经被取消,结果就无法生成。
因此,在 cancel 之后调用 await 会触发 JobCancellationException,提示:Job was cancelled(任务已被取消)。
反过来,如果你在调用 deferred.await 之后再调用 deferred.cancel,则不会产生任何效果,因为此时协程已经执行完毕。
取消后的清理工作

假设你希望在协程被取消时执行一些特定操作:比如关闭正在使用的资源、记录取消事件,或者执行其他清理代码。我们有几种实现方式:
检查 !isActive 状态
如果你会周期性地检查 isActive 状态,那么当退出 while 循环后,就可以执行资源清理。我们可以把之前的代码更新为:
kotlin
while (i < 5 && isActive) {
// 每半秒打印一条消息
if (...) {
println("Hello ${i++}")
nextPrintTime += 500L
}
}
// 协程任务已完成,我们可以执行清理了
println("Clean up!")
现在,当协程取消时,while 循环就会退出,我们就可以执行清理操作了。
try-catch
我也称之为古法捕获------几乎在任何情况下,这个是最好用的
由于协程被取消时会抛出 CancellationException,我们可以将挂起任务包裹在 try/catch 代码块中,并在 finally 块里实现清理工作。
kotlin
val job = launch {
try {
work()
} catch (e: CancellationException) {
println("Work cancelled!")
} finally {
delay(1000L)
println("Clean up!")
}
}
delay(1000L)
println("Cancel!")
job.cancel()
println("Done!")
但如果我们需要执行的清理工作本身是挂起操作,上面的代码就无法正常运行了。例如上面这个示例中,delay(1000L) 也会理解取消,不会打印 Clean up!,
一旦协程进入"取消中"状态,它就无法再挂起了。
如果希望在协程被取消后仍能调用挂起函数,我们需要将清理工作切换到 NonCancellable 协程上下文中执行。这样可以保证代码能够正常挂起,并让协程保持在取消状态,直到所有清理工作完成。
kotlin
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 来实现:
kotlin
suspend fun work() {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
// 执行清理操作
}
// 剩余实现逻辑
}
}
为了充分利用结构化并发的优势,避免执行不必要的任务,你需要确保代码支持取消操作。
suspendCancellableCoroutine 是最常用的,也是最安全的,异步代码变成同步代码的方法。
建议
使用 Jetpack 中定义的 CoroutineScope,例如 viewModelScope 或 lifecycleScope,它们会在作用域结束时自动取消内部的所有任务。
如果需要创建自定义 CoroutineScope,请确保将其与一个 Job 绑定,并在需要时主动调用 cancel。
最后
我在 这篇文章 中提到过:针对 CancellationException 的正确做法应该是重新抛出,而不是吞掉。
为什么呢?
Kotlin
fun badCase() = runBlocking<Unit> {
val job1 = launch {
runCatching {
dowork()
}.onFailure {
if (it is CancellationException) {
println("Cancelled") // 用户无法感知取消操作,只知道任务完成了
}
}
println("job 1 done")
}
delay(100L)
job1.cancel()
}
suspend fun dowork() {
try {
delay(2000L)
println("dowork")
} catch (e: Exception) {
// catch 而非重新抛出
} finally {
println("clean up")
}
}
通过上面这个代码就能理解,如果用户需要知道取消操作(例如通知用户任务已经取消)但是你没有重新抛出这个异常的话,上层是无法知道这个任务是取消了还是正常完成了的。
希望这篇文章,对你有所帮助。