结构化并发:Job 树与协作式取消
上一章的状态机不是孤立运行的 —— 它挂在一棵树上。这棵树是协程模型里最被低估的部分:它解释了为什么一个子协程失败会把兄弟们全拖死、为什么页面关闭时所有请求会自动作废、以及为什么你的 while(true) 循环取消不掉。「结构化并发」这个词听起来很学术,但它的含义特别朴素:每个协程都有父亲,没有孤儿;父亲死了,孩子全都跟着收尾。把树画出来,这一章的所有问题都会自己回答自己。
先玩一遍这棵树
三个按钮:主动取消一个子协程、让一个孙协程抛异常、关闭整个页面。先切换 coroutineScope,把三个按钮都点一遍,再切到 supervisorScope 点第二个 —— 差别就是这一章的全部:
规则只有三条
① 取消向下传播。取消一个 Job,它的所有后代一起取消。兄弟和父亲不受影响。
② 失败向上传播。一个子协程异常失败(不是被取消),异常会冒泡到父 Job;父 Job 判定自己失败,于是取消它剩下的所有孩子,然后继续向上冒泡。—— 这就是「一个失败,全家阵亡」。
③ 父协程等所有孩子结束才算结束。coroutineScope { } 会挂起,直到里面启动的每一个协程都完成。没有孤儿,没有泄漏。
规则 ② 常被当成 bug,其实是设计:如果加载用户资料的三个并行请求里有一个失败了,剩下两个继续跑完有什么意义?你已经没法渲染完整页面了 —— 让它们继续,只会浪费流量、并且给你一个半截的、更难处理的状态。
规则 ③ 是「结构化」这个词的真正含义。对比一下线程:new Thread().start() 之后,那个线程和你再无关系 —— 它是个孤儿,没人负责它的结束,没人能取消它,它可能在 Activity 销毁后还在跑并往一个已死的 View 上写数据。协程从根上消灭了这种可能。
什么时候用 supervisorScope
supervisorScope(以及 SupervisorJob)关掉的是规则 ②:子协程的失败不再向上传播,兄弟们继续跑。
| 场景 | 用哪个 | 为什么 |
|---|---|---|
| 并行加载 3 个接口,缺一个就没法渲染 | coroutineScope | 一个失败,其余的白跑 —— 让它们一起死,快速失败 |
| 首页有 5 个独立卡片,一个挂了不该拖垮整页 | supervisorScope | 失败隔离在那个卡片里,其余照常显示 |
ViewModel 里的 viewModelScope | 已经是 SupervisorJob | Google 替你选好了:一次 launch 失败不该让整个 ViewModel 的其他协程全废 |
失败不向上传播,意味着没有人会替你处理那个异常。子协程里抛出的异常如果没被 catch,它会走到 CoroutineExceptionHandler —— 如果你没装,它就(在 Android 上)直接崩溃或者被默默吞掉。
用 supervisorScope 就必须自己管异常:每个子协程里 try/catch,或者给作用域装一个 handler(第 17 章讲清它该装在哪 —— 装错地方它就是个摆设)。
取消是协作式的(这是最常见的坑)
「取消一个协程」并不会强行终止它。它只做两件事:把 Job 的状态标记为已取消,然后在下一个挂起点抛出 CancellationException。
换句话说:协程必须自己配合,取消才会发生。不配合的代码,取消不掉:
// ✗ 取消不掉:一个纯 CPU 循环里没有任何挂起点
launch {
while (i < 1_000_000) { // 就算 job.cancel() 了,它照跑不误
heavyCompute(i++)
}
}
// ✓ 方案 A:用 isActive 主动检查
launch {
while (isActive && i < 1_000_000) { heavyCompute(i++) }
}
// ✓ 方案 B:ensureActive()(已取消就抛异常,语义更明确)
launch {
while (i < 1_000_000) {
ensureActive() // 取消了就在这里抛 CancellationException
heavyCompute(i++)
}
}
// ✓ 方案 C:yield()(顺便让出线程,给同调度器上的其他协程一个机会)
launch {
while (i < 1_000_000) { yield(); heavyCompute(i++) }
}
为什么 delay() 就能被取消?因为它是个挂起函数,而所有 kotlinx.coroutines 提供的挂起函数都内置了取消检查。而 Thread.sleep() 不是挂起函数 —— 它阻塞线程,对取消一无所知(第 14 章)。
规律:CPU 密集的循环、阻塞式的老 API、第三方 SDK 的同步调用 —— 这三类东西都取消不掉。包住它们的协程会「取消不了」,页面关了它还在跑。
取消异常的两条纪律
纪律一:CancellationException 必须放行
第 1 章 Q10 的那个坑,现在可以解释清楚了:
// ✗ 灾难写法
try {
api.fetch()
} catch (e: Exception) { // CancellationException 也是 Exception!
log(e) // 取消信号被吞掉,协程假装无事发生地继续跑
}
// ✗ 同样的灾难,换了个马甲
runCatching { api.fetch() }.getOrNull()
// ✓ 正确
try {
api.fetch()
} catch (e: CancellationException) {
throw e // 取消必须放行
} catch (e: Exception) {
log(e); null
}
为什么这么严重?因为协程的取消就是靠这个异常往上冒泡来完成的。你把它吞了,协程就认为自己没被取消 —— 于是它继续执行后面的代码、继续写数据库、继续往已经销毁的 View 上回调。而外面的 job.cancelAndJoin() 会一直等下去。
这个 bug 有个特征:只在「用户快速退出页面」时偶发,本地根本复现不了,只在线上崩溃日志里露出一角。
纪律二:清理工作要用 NonCancellable
协程被取消后,它已经处于 Cancelling 状态 —— 此时任何挂起调用都会立刻再次抛出 CancellationException。于是你的清理代码根本跑不完:
try {
api.stream()
} finally {
db.saveProgress() // ✗ 这是个 suspend 函数 —— 它会立刻抛异常,进度没保存
}
// ✓ 正确:给清理工作开一个「不可取消」的小岛
try {
api.stream()
} finally {
withContext(NonCancellable) {
db.saveProgress() // ✓ 这里面的挂起调用不会被取消
}
}
NonCancellable 的唯一正当用途就是这个(在 finally 里做必须完成的收尾:关闭连接、保存进度、释放资源)。看到它出现在别的地方,那大概率是有人在逃避取消,而不是在处理取消。
Android 上你其实一直在享受这棵树
| 作用域 | 什么时候取消 | 用它做什么 |
|---|---|---|
viewModelScope | ViewModel 的 onCleared()(页面真正结束时,不是屏幕旋转) | 绝大多数业务协程 |
lifecycleScope | Activity/Fragment 销毁时 | 和 UI 生命周期绑定的东西 |
repeatOnLifecycle(STARTED) { } | 每次进入后台就取消,回到前台重启 | 收集 Flow 的正确姿势(第 19 章) |
rememberCoroutineScope() | Composable 离开组合树时 | 在 Compose 里响应事件启动协程 |
GlobalScope | 永不取消 | 别用。它是「结构化并发」的反面 —— 一个没有父亲的孤儿,泄漏的标准配方 |
「页面关闭 → 所有网络请求自动作废 → 没有回调打到已销毁的 View」—— 这在 Java + 回调的年代是需要手写一堆 isDestroyed 检查和 weakReference 才能勉强做到的事。现在它是白送的,因为你的协程挂在一棵有主人的树上。
RxJava 的 CompositeDisposable 是同一个问题的手工解法:你必须记得把每个订阅 add 进去,必须记得在 onDestroy 里 clear()。忘一个,就泄漏一个。
协程的结构化并发把这件事变成了语言/库层面的默认行为:你想泄漏都得费点劲(得专门去用 GlobalScope)。「正确的事情是默认发生的」—— 这是好设计的标志。
1. 搜 GlobalScope。每一处都是一个潜在泄漏。换成合适的作用域(viewModelScope),或者如果它真的需要「活过页面」(比如上传任务),那它该是 WorkManager 的活,不是协程的。
2. 搜 catch (e: Exception) 和 runCatching,检查它们有没有包住 suspend 调用。每一处都在吞取消信号。这是本章最值钱的一条 —— 我打赌你能在项目里找到至少三处。
3. 找一个 CPU 密集的循环(图片处理、大列表排序、加密),检查它有没有 ensureActive()。没有的话,用户退出页面它还在烧 CPU。
4. 找一处 finally 里的清理代码,看它是不是 suspend 的。是的话,它从来没有真正执行过 —— 给它包上 withContext(NonCancellable)。
下一章:调度器。Dispatchers.IO 的那 64 个线程从哪来,以及为什么 suspend 函数应该「自己管好自己的线程」。