4、协程的取消与超时:取消协程的机制、isActive与ensureActive、withTimeout与withTimeoutOrNull、不可取消的协程
协程的取消与超时,是日常开发中绕不开的话题。说实话,我刚接触协程那会儿,觉得取消不就是调个 cancel() 嘛,有什么好讲的?直到我在一个图片加载库中踩了坑——协程取消了,但子任务还在跑,内存泄漏了。嗯,从那以后,我才认真研究起取消的底层机制。
这一节,我们就来彻底搞懂协程取消的来龙去脉。我会结合自己的实战经验,把那些容易踩的坑一个一个指出来。
取消协程的机制:协作式取消
首先,你得明白一个核心概念:协程的取消是协作式的。什么意思?说白了,就是协程不会因为你调了 cancel() 就立刻停下来。它需要协程内部的代码主动配合,检查一下「我是不是被取消了?」然后自己决定要不要停止。
为什么会这样?因为协程本质上是一个轻量级线程,它运行在你的代码块里。如果代码块里是一个死循环,或者一个阻塞操作,外部是无法强行中断它的。你想想看,如果强行中断,资源没释放、状态不一致,那不就乱套了吗?
来看一个最简单的例子:
val job = scope.launch {
repeat(1000) { i ->
println("正在执行第 $i 次")
delay(500) // delay 是一个可取消的挂起函数
}
}
delay(2000)
job.cancel() // 取消协程
job.join() // 等待协程结束
println("协程已取消")
这里 delay() 是一个可取消的挂起函数。当协程被取消时,delay() 会抛出一个 CancellationException,协程就优雅地退出了。但如果你把 delay() 换成 Thread.sleep(),那取消就失效了——因为 Thread.sleep() 不配合协程的取消机制。
我曾经在一个网络请求库中,用
Thread.sleep() 模拟延迟,结果协程取消后,线程还在睡。最后导致协程泄漏,内存暴涨。记住:在协程中,永远不要用线程阻塞操作。用 delay(),用 withContext(Dispatchers.IO),就是别用 Thread.sleep()。
isActive 与 ensureActive:手动检查取消状态
如果你的协程里没有调用任何可取消的挂起函数(比如一个纯计算任务),那协程取消就形同虚设。这时候,你需要手动检查取消状态。
Kotlin 提供了两个工具:isActive 和 ensureActive。
- isActive:一个布尔属性,返回当前协程是否处于活跃状态。你可以用它来在循环中做条件判断。
- ensureActive:一个函数,如果协程已被取消,它会立即抛出
CancellationException。
我个人习惯在耗时循环中,每隔几次迭代就检查一下 isActive。这样既保证了响应性,又不会因为频繁检查而影响性能。
scope.launch {
for (i in 0 until 10_000) {
// 每 100 次检查一次取消状态
if (i % 100 == 0) {
ensureActive() // 如果已取消,这里会抛异常
}
// 模拟耗时计算
computeHeavyTask(i)
}
}
你也可以用 isActive 做更精细的控制:
scope.launch {
while (isActive) {
// 只有协程活跃时才执行
doSomeWork()
}
}
在复杂的协程中,我倾向于用
ensureActive() 而不是 isActive。因为 ensureActive() 会直接抛出异常,让协程立即退出,而 isActive 还需要你自己写 break 或 return。少写一行代码,少一个 bug 的可能。
withTimeout 与 withTimeoutOrNull:给协程加个闹钟
超时,说白了就是「如果这个任务在规定时间内没完成,就别等了」。这在网络请求、数据库查询等场景中非常常见。
Kotlin 提供了两个函数:
- withTimeout:超时后抛出
TimeoutCancellationException(它是CancellationException的子类)。 - withTimeoutOrNull:超时后返回
null,而不是抛异常。
我个人更推荐 withTimeoutOrNull,因为它更安全。你想想看,抛异常意味着你要写 try-catch,而返回 null 只需要一个空检查。代码更简洁,心智负担更小。
// 使用 withTimeout,需要捕获异常
try {
withTimeout(1000) {
fetchDataFromNetwork()
}
} catch (e: TimeoutCancellationException) {
println("请求超时了")
}
// 使用 withTimeoutOrNull,优雅处理
val result = withTimeoutOrNull(1000) {
fetchDataFromNetwork()
}
if (result == null) {
println("请求超时了,返回 null")
}
这里有个细节要注意:withTimeout 抛出的异常是 CancellationException 的子类。而 CancellationException 在协程中有特殊地位——它通常不会被父协程捕获,而是直接向上传播。所以,如果你在 try-catch 里捕获了 Exception,可能抓不到它。
我曾经在项目中用
withTimeout 包裹了一个数据库查询,然后在外层写了 try-catch(Exception)。结果超时后,异常没被捕获,直接导致协程崩溃。后来才发现,TimeoutCancellationException 继承自 CancellationException,而 CancellationException 不是 Exception 的子类。记住:捕获超时异常,要用 catch (e: TimeoutCancellationException),或者直接用 withTimeoutOrNull。
不可取消的协程:withContext(NonCancellable)
有些场景下,你希望某段代码「打死也不能被取消」。比如:你正在写入缓存、关闭文件、释放资源。如果协程被取消了,这些操作没执行完,数据就损坏了。
这时候,可以用 withContext(NonCancellable) 来包裹这段代码。它会创建一个新的协程上下文,这个上下文忽略取消信号。
scope.launch {
try {
// 可取消的操作
fetchData()
} catch (e: CancellationException) {
// 协程被取消了,但我们需要确保资源释放
withContext(NonCancellable) {
// 这段代码不可取消
releaseResources()
clearCache()
log("资源已释放")
}
// 重新抛出取消异常,让协程真正结束
throw e
}
}
注意,NonCancellable 不是让你滥用它的。它应该只用在「必须执行完」的临界区。如果你把整个协程都包上 NonCancellable,那取消机制就形同虚设了。
我一般在
finally 块中使用 NonCancellable。因为 finally 块本身不会因为协程取消而跳过,但如果 finally 里调用了可取消的挂起函数(比如 delay),那它还是会被取消。用 NonCancellable 包裹,就能保证这些清理操作「一条道走到黑」。
知识体系总览
下面这张图,帮你把这一节的核心逻辑串起来:
总结一下
这一节的内容,说白了就四件事:
- 取消是协作式的——别指望调了
cancel()就万事大吉,代码里要主动检查。 - 用
isActive或ensureActive手动检查——纯计算任务里,这是你的救命稻草。 - 超时用
withTimeoutOrNull——少写 try-catch,代码更清爽。 - 不可取消用
NonCancellable——只用在资源释放等关键路径上,别滥用。
嗯,这些机制看起来简单,但用错了地方,后果很严重。我见过太多因为取消没处理好导致的内存泄漏、ANR 甚至数据损坏。希望你能把这些原则刻在脑子里,写协程代码时,多问自己一句:「如果用户突然退出,我的协程能优雅地停下来吗?」
- 协程取消是协作式的,不是强制的
- 在循环或耗时操作中,务必检查
isActive 或调用 ensureActive- 优先使用
withTimeoutOrNull 而非 withTimeout-
NonCancellable 只用于不可中断的临界区
公众号:蓝海资料掘金营,微信deep3321