2、协程取消机制:协作式取消、取消异常(CancellationException
协程的取消,不是你想象中那种“咔”一下剪断的方式。它更像个礼貌的提醒:“嘿,该停了”,然后协程自己找个地方收尾。这就是所谓的协作式取消——必须由协程自己配合才能生效。
核心要点:协程不会被强制杀死。它只在挂起点(suspend function)或主动检查取消状态时,才会响应取消。
为什么是协作式,而不是暴力中断?
我刚开始用协程时也有这个疑问。Java里 Thread.stop() 有多危险大家都知道——锁没释放,资源没关闭,数据一致性直接崩掉。协程的协作式取消就是要避免这种悲剧。它给了开发者一个安全清理的机会:关闭文件、释放内存、提交事务,全在取消异常被抛出前搞定。
说白了,协程把取消的控制权交给了你。你可以在任何时机决定“我响应取消”,而不是被操作系统一刀切。
取消的检测点有哪些?
协程在以下位置会自动检查取消状态并抛出 CancellationException:
- 调用任何
suspend函数(比如delay(),withContext(),await()) - 显式调用
ensureActive()或yield() - 检查
isActive属性并自行返回
注意:如果一个协程里全是纯 CPU 计算(比如一个死循环做数学运算),没有调用任何挂起函数,那我告诉你——它永远也不会取消成功。这是泄漏的典型来源之一。
我个人的习惯:在长时间计算循环里,每隔几百次迭代就调用一次 yield() 或 ensureActive(),保证取消能被检测到。
CancellationException:别把它当普通异常
当协程检测到取消标记时,会抛出 kotlinx.coroutines.CancellationException。这个异常很特殊:
| 属性 | 说明 |
|---|---|
| 继承关系 | 继承自 Exception,但一般不被上层捕获 |
| 传播行为 | 沿协程层次向上传递,直到被处理或到达顶层 |
| 捕获后果 | 如果被 try-catch 吞掉,协程将无法正常取消 |
| 重新抛出 | 在 finally 块里必须重新抛出,否则取消失效 |
我曾经踩过一个坑:在协程的 finally 块里调用了 withContext(Dispatchers.IO) 去做清理。结果 withContext 内部又检测到取消标记,再次抛出 CancellationException,而我忘了重新抛出,导致协程“死而复生”继续执行——泄漏就是这么来的。
千万注意:千万不要在 catch 或 finally 里直接捕获 CancellationException 然后什么都不做。如果你必须捕获,请立即重新抛出:
coroutineScope.launch {
try {
// 业务逻辑
} catch (e: CancellationException) {
// 做必要的清理
throw e // 必须重新抛出
}
}
如何编写充分可取消的协程?
- 所有挂起调用都视为取消点——不需要额外处理。
- 纯计算代码段:每轮循环加
ensureActive()检查。 - 资源清理代码:用
try { ... } finally { ... }包住,finally内不要调用可取消的挂起函数——除非你重新抛出。 - 避免使用
runBlocking当事务协程:它会阻塞线程并忽略取消。
// ✅ 正确做法:每轮循环检查取消
suspend fun processLargeList(list: List<Item>) = withContext(Dispatchers.Default) {
for (item in list) {
ensureActive() // 或 yield()
// 处理 item
}
}
// ❌ 错误做法:忘了检查,协程永远取消不了
suspend fun badProcess() {
var i = 0
while (i < 1_000_000) {
// 没有 suspend 调用,也没有 ensureActive
i++
}
}
协程取消与Flow数据流泄漏的关系
回到《Flow数据流取消时机不当泄漏》这个大主题。协程取消机制是根基。如果 flow.collect 所在的协程取消了,但 collect 内部的代码没有检测到取消,就会一直发射数据——即使外层已经不再消费。这就是我们常说的“下游取消了,上游还在跑”。
常见场景:
- 在
collect的 lambda 里做了耗时操作,但没有调用任何挂起函数 - 用
catch捕获了CancellationException并忽略 - 在
flowOn切换的上下文中,取消标记没有正确传播
下面这张图概括了协程协作式取消的整个流程,以及泄漏可能发生的位置。
现场避坑指南
我曾经排查过一个诡异的泄漏:用户退出页面后,后台的 Flow 还在疯狂发射网络请求。追了整整一天,最后发现协程 collect 里的 catch 块长这样:
flow.collect { value ->
try {
// 处理
} catch (e: Exception) {
// 打印日志 😱
Log.e("TAG", "error", e)
}
}
你看出问题了吗?CancellationException 也是 Exception 的子类,直接被 catch (e: Exception) 抓走了!日志打印之后异常没了,协程以为自己“正常结束”,但实际上取消机制已经失效,后续的挂起操作不再响应取消——Flow 发射器还在循环里跑得欢。
我的建议:要么只捕获具体业务异常(如 IOException),要么捕获后判断类型并重新抛出:
catch (e: Exception) {
if (e is CancellationException) throw e
// 其他处理
}
总结一下关键认知
- 协程取消是协作式的,不是强制的
- 取消标记只在挂起点和主动检查时生效
CancellationException必须被重新抛出,否则取消失败- 泄漏的本质:协程虽然收到了取消指令,但代码路径里没有响应,导致下游任务持续运行
理解了这些,再去定位 Flow 数据流泄漏时,你就知道该往哪个方向查了:检查 collect 代码块里有没有吞掉 CancellationException,有没有在耗时计算中忘记调用 ensureActive()。下一招,我们直接上实战泄漏定位工具。不过那是后面的事。