卷 III · 挂起CH 11深度 11/24

取消是协作的:它靠你主动去检查

job.cancel() 这个方法名骗了很多人。它不停止任何东西——它只是挂了一块牌子。至于什么时候真的停下来,完全取决于你的代码愿不愿意低头看一眼那块牌子。

协作式取消isActiveensureActiveNonCancellable

先看那个数字

下面这台引擎跑的是同一件活:压缩 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 的挂起函数delaywithContextjoin、Flow 的 emitcollectChannel 的收发……)——它们都自带检查
  • 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 那一行是很多人会漏掉的一行。漏掉之后,取消就只是「不要结果了」,请求还是会跑完。

⚠ 千万别 catch 掉 CancellationException

这是协程里最常见、也最难查的一个 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 离开组合 → 它的 LaunchedEffectrememberCoroutineScope 被取消
取消向下传父 composable 被移除 → 整棵子树的 effect 全被取消
finally 收尾DisposableEffectonDispose
「取消了但没停」「组件没了但监听器还在」——同一类 bug,同一个原因

第 17、18 章会把 Compose 这一侧完整地讲一遍。你会发现它就是这一章的同一套逻辑换了个名字。

⌗ 到你手上

这一章的一句话

cancel() 只是挂了块牌子。真正停下来要靠你的代码走到一个会看牌子的地方——所有 suspend 函数都会看,纯 CPU 循环一次都不看。而 catch (e: Exception) 会把牌子撕掉。

下一章:取消讲完了,还剩最后一个和协程有关的日常问题——这段活到底该跑在哪条线程上。主线程只有一条,一帧只有 16.667 毫秒,而下一章那台调度器会告诉你:一份 120 毫秒的活放错地方,正好掉 7 帧。