取消是协作的:它靠你主动去检查
job.cancel() 这个方法名骗了很多人。它不停止任何东西——它只是挂了一块牌子。至于什么时候真的停下来,完全取决于你的代码愿不愿意低头看一眼那块牌子。
先看那个数字
下面这台引擎跑的是同一件活:压缩 20 块图片,每块 50 毫秒,跑在后台线程上。在第 220 毫秒的时候,用户按了返回键,主线程发出 cancel()。
写法 取消发出于 实际停在 白跑了
① while (true) { 压一块 } 220.1 ms 1000.0 ms 779.9 ms
② while (isActive) { 压一块 } 220.1 ms 250.1 ms 30.0 ms
③ 每块之间 yield() 220.1 ms 250.3 ms 30.2 ms
第一行白跑了 780 毫秒,而且把 20 块全压完了——尽管在第 5 块的时候就已经没人要这个结果了。
更要命的是:页面早已销毁,ViewModel 早已 onCleared,但这段代码还占着一条后台线程,还持有着那个 Bitmap,还会在结束时往一个已经不存在的地方写结果。
cancel() 到底做了什么
job.cancel()
① 把 Job 的状态改成 Cancelling
② 递归地对所有子 Job 做同样的事
③ 如果这个协程正停在一个可取消的挂起点上,
就在那个挂起点上给它扔一个 CancellationException
④ 返回
—— 没有第 ⑤ 步。
关键在第 ③ 步的前提:正停在一个挂起点上。
上一章解释过为什么:挂起 = 函数 return 了,运行时手里拿着那个 Continuation。它可以选择「不 resume,改成 throw」。这是运行时唯一能插手的时刻。
而一段纯 CPU 的循环从来不 return,运行时一次机会都没有。没有挂起点 = 没有取消。
取消不是抢占。JVM 上没有任何安全的办法强行中断一段正在跑的代码(Thread.stop() 早就被废弃了,因为它会让对象停在不一致的中间状态)。
所以协程的取消是协作式的:运行时挂牌子,你的代码负责看牌子。
看牌子的方式有三种:
- 调用任何一个 kotlinx.coroutines 的挂起函数(
delay、withContext、join、Flow 的emit/collect、Channel的收发……)——它们都自带检查 ensureActive()——脏了就直接抛isActive——返回一个布尔值,你自己决定怎么办
三种写法怎么选
| 写法 | 取消时的行为 | 适合 |
|---|---|---|
while (isActive) { … } | 循环正常结束,函数往下走 | 需要做收尾(写入已完成的部分、关文件)的时候 |
ensureActive() | 抛 CancellationException,直接跳出去 | 大多数情况。语义最清楚:取消了就是取消了 |
yield() | 抛异常,而且顺便把线程让给别的协程 | 长循环里顺便照顾一下同调度器上的其他任务 |
三个都要放在循环体内部,而且放在真正干活的那一步之前或之后——放在循环外面等于没放。
// ✅ 典型写法
suspend fun compressAll(blocks: List<Block>) = withContext(Dispatchers.Default) {
for (b in blocks) {
ensureActive() // ← 每一块之前看一眼牌子
compress(b)
}
}
你可能已经在协作了,只是不知道
好消息:如果你的循环里有任何一次网络请求、数据库查询、delay、或者往 Flow 里 emit,你已经在协作了——因为这些全都是挂起函数,全都自带检查。
真正会出事的只有一类代码:纯 CPU 的长循环。
- 图片压缩、缩放、滤镜
- 大列表排序、分组、去重
- JSON 手写解析、大字符串拼接
- 加解密、哈希
- 调用一个阻塞式的第三方 SDK(它内部是同步的,你在
withContext(IO)里等它——这种连isActive都救不了)
最后一条值得展开:如果你调的是一个阻塞方法(比如老式的 HttpURLConnection.getInputStream()),取消不会打断它。协程会被标成 Cancelling,但那个方法要阻塞多久就阻塞多久。
处理办法是把「取消」翻译成那个 API 自己的中断机制:
suspend fun download(url: String): ByteArray = suspendCancellableCoroutine { cont ->
val call = okHttpClient.newCall(Request.Builder().url(url).build())
cont.invokeOnCancellation { call.cancel() } // ← 把取消转给 OkHttp
call.enqueue(object : Callback { … })
}
suspendCancellableCoroutine + invokeOnCancellation 是把任何回调式 API 接进协程的标准写法,而 invokeOnCancellation 那一行是很多人会漏掉的一行。漏掉之后,取消就只是「不要结果了」,请求还是会跑完。
这是协程里最常见、也最难查的一个 bug:
// ❌ 这段代码会让取消完全失效
try {
doSomething()
} catch (e: Exception) { // CancellationException 也是 Exception
log("出错了", e)
}
// 吞掉之后:协程认为自己被取消了,但异常没传出去,
// 于是它继续往下跑,而父作用域已经在等它结束了
三种正确写法:
// ① 只 catch 你真正认识的异常
catch (e: IOException) { … }
// ② catch 宽一点,但把取消放回去
catch (e: Exception) {
if (e is CancellationException) throw e
log("出错了", e)
}
// ③ 用 kotlinx 自带的:它内部就是 ②
catch (e: Exception) {
currentCoroutineContext().ensureActive()
log("出错了", e)
}
顺带一提:runCatching { } 也有同样的问题,它会吞掉 CancellationException。在协程里用 runCatching 要格外小心。
取消之后还要做的收尾
协程被取消之后,它就不能再挂起了——任何挂起函数都会立刻抛 CancellationException。这带来一个实际问题:
try {
uploadFile(file)
} finally {
deleteTempFile(file) // ❌ 如果这是个 suspend 函数,它会立刻抛
}
解法是 NonCancellable:
try {
uploadFile(file)
} finally {
withContext(NonCancellable) {
deleteTempFile(file) // ✅ 这一段取消不了,会跑完
}
}
这是 NonCancellable 唯一正当的用法:在 finally 里做必须完成的收尾。拿它去包一整段业务逻辑,等于把协程取消这套机制关掉了。
Compose 这边的对应物
这一章讲的全部内容,在 Compose 侧有一个一一对应的东西:
| 协程 | Compose |
|---|---|
job.cancel() | 这段 composable 离开组合 → 它的 LaunchedEffect/rememberCoroutineScope 被取消 |
| 取消向下传 | 父 composable 被移除 → 整棵子树的 effect 全被取消 |
finally 收尾 | DisposableEffect 的 onDispose |
| 「取消了但没停」 | 「组件没了但监听器还在」——同一类 bug,同一个原因 |
第 17、18 章会把 Compose 这一侧完整地讲一遍。你会发现它就是这一章的同一套逻辑换了个名字。
在项目里搜这三样,逐个确认:
while (true) 循环体里有挂起函数吗?没有就加 ensureActive()
for (… in 大集合) 同上,尤其是那种「几千项做点计算」的
catch (e: Exception) 有没有把 CancellationException 一起吞了
再搜一遍 suspendCoroutine:
如果不是 suspendCancellableCoroutine,先问一句为什么;
如果是,检查有没有写 invokeOnCancellation。
最后一条自检(这条最有效):
打开你的 App,进一个会发起长任务的页面,
任务跑到一半按返回键,看 Logcat 还在不在打日志。
还在打 —— 你就找到了一个。
这一章的一句话
cancel() 只是挂了块牌子。真正停下来要靠你的代码走到一个会看牌子的地方——所有 suspend 函数都会看,纯 CPU 循环一次都不看。而 catch (e: Exception) 会把牌子撕掉。
下一章:取消讲完了,还剩最后一个和协程有关的日常问题——这段活到底该跑在哪条线程上。主线程只有一条,一帧只有 16.667 毫秒,而下一章那台调度器会告诉你:一份 120 毫秒的活放错地方,正好掉 7 帧。