炉 IV · 浇铸CH 15进度 15/26

结构化并发:Job 树与协作式取消

上一章的状态机不是孤立运行的 —— 它挂在一棵树上。这棵树是协程模型里最被低估的部分:它解释了为什么一个子协程失败会把兄弟们全拖死、为什么页面关闭时所有请求会自动作废、以及为什么你的 while(true) 循环取消不掉。「结构化并发」这个词听起来很学术,但它的含义特别朴素:每个协程都有父亲,没有孤儿;父亲死了,孩子全都跟着收尾。把树画出来,这一章的所有问题都会自己回答自己。

Job 树协作式取消supervisorScopeNonCancellable

先玩一遍这棵树

三个按钮:主动取消一个子协程、让一个孙协程抛异常、关闭整个页面。先切换 coroutineScope,把三个按钮都点一遍,再切到 supervisorScope 点第二个 —— 差别就是这一章的全部:

规则只有三条

◆ 锭 · Job 树的三条铁律

① 取消向下传播。取消一个 Job,它的所有后代一起取消。兄弟和父亲不受影响。

② 失败向上传播。一个子协程异常失败(不是被取消),异常会冒泡到父 Job;父 Job 判定自己失败,于是取消它剩下的所有孩子,然后继续向上冒泡。—— 这就是「一个失败,全家阵亡」。

③ 父协程等所有孩子结束才算结束。coroutineScope { } 会挂起,直到里面启动的每一个协程都完成。没有孤儿,没有泄漏。

规则 ② 常被当成 bug,其实是设计:如果加载用户资料的三个并行请求里有一个失败了,剩下两个继续跑完有什么意义?你已经没法渲染完整页面了 —— 让它们继续,只会浪费流量、并且给你一个半截的、更难处理的状态

规则 ③ 是「结构化」这个词的真正含义。对比一下线程:new Thread().start() 之后,那个线程和你再无关系 —— 它是个孤儿,没人负责它的结束,没人能取消它,它可能在 Activity 销毁后还在跑并往一个已死的 View 上写数据。协程从根上消灭了这种可能。

什么时候用 supervisorScope

supervisorScope(以及 SupervisorJob)关掉的是规则 ②:子协程的失败不再向上传播,兄弟们继续跑。

场景用哪个为什么
并行加载 3 个接口,缺一个就没法渲染coroutineScope一个失败,其余的白跑 —— 让它们一起死,快速失败
首页有 5 个独立卡片,一个挂了不该拖垮整页supervisorScope失败隔离在那个卡片里,其余照常显示
ViewModel 里的 viewModelScope已经是 SupervisorJobGoogle 替你选好了:一次 launch 失败不该让整个 ViewModel 的其他协程全废
⚠ 炉渣 · supervisorScope 的代价:异常会静静地消失

失败不向上传播,意味着没有人会替你处理那个异常。子协程里抛出的异常如果没被 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 上你其实一直在享受这棵树

作用域什么时候取消用它做什么
viewModelScopeViewModel 的 onCleared()(页面真正结束时,不是屏幕旋转)绝大多数业务协程
lifecycleScopeActivity/Fragment 销毁时和 UI 生命周期绑定的东西
repeatOnLifecycle(STARTED) { }每次进入后台就取消,回到前台重启收集 Flow 的正确姿势(第 19 章)
rememberCoroutineScope()Composable 离开组合树时在 Compose 里响应事件启动协程
GlobalScope永不取消别用。它是「结构化并发」的反面 —— 一个没有父亲的孤儿,泄漏的标准配方

「页面关闭 → 所有网络请求自动作废 → 没有回调打到已销毁的 View」—— 这在 Java + 回调的年代是需要手写一堆 isDestroyed 检查和 weakReference 才能勉强做到的事。现在它是白送的,因为你的协程挂在一棵有主人的树上。

▸ 旧模具 · 这是 RxJava 也没解决好的问题

RxJava 的 CompositeDisposable 是同一个问题的手工解法:你必须记得把每个订阅 add 进去,必须记得onDestroyclear()。忘一个,就泄漏一个。

协程的结构化并发把这件事变成了语言/库层面的默认行为:你想泄漏都得费点劲(得专门去用 GlobalScope)。「正确的事情是默认发生的」—— 这是好设计的标志。

✚ 动手锻打

1. GlobalScope。每一处都是一个潜在泄漏。换成合适的作用域(viewModelScope),或者如果它真的需要「活过页面」(比如上传任务),那它该是 WorkManager 的活,不是协程的。

2. catch (e: Exception)runCatching,检查它们有没有包住 suspend 调用。每一处都在吞取消信号。这是本章最值钱的一条 —— 我打赌你能在项目里找到至少三处。

3. 找一个 CPU 密集的循环(图片处理、大列表排序、加密),检查它有没有 ensureActive()。没有的话,用户退出页面它还在烧 CPU。

4. 找一处 finally 里的清理代码,看它是不是 suspend 的。是的话,它从来没有真正执行过 —— 给它包上 withContext(NonCancellable)

下一章:调度器。Dispatchers.IO 的那 64 个线程从哪来,以及为什么 suspend 函数应该「自己管好自己的线程」。