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

结构化并发:一棵会连坐的树

「一个子协程失败,把毫不相干的兄弟一起干掉」——第一次见到的人通常会觉得这太粗暴了。它确实粗暴,而且是故意的:这是用一个明确的、可预测的行为,去换掉一整类你永远查不出来的泄漏。

Job 树coroutineScopesupervisorScopeasync 的异常

它要解决的问题

没有结构化并发的世界长这样(Java 的线程池、JS 的裸 Promise、Go 的裸 goroutine):

你启动了一个后台任务。
然后呢?

 · 页面关了,它还在跑吗?          不知道
 · 它抛异常了,谁会知道?          没人
 · 我怎么等它结束?                自己记一个引用
 · 我怎么把它和它启动的那三个       自己维护一个列表
   子任务一起停掉?

这四个「自己想办法」加起来,就是每个大项目里都有的那几个内存泄漏和幽灵任务。

结构化并发的做法是:不给你「凭空启动一个任务」的能力。你只能在某个 CoroutineScope 里启动,启动出来的协程自动成为这个作用域的孩子。于是四个问题一次性有了答案:

父作用域取消   →  所有孩子取消
父作用域结束   →  一定等到所有孩子结束才算结束
孩子抛异常     →  往上冒泡到父作用域
孩子的孩子     →  一样,递归地成立

先看那棵树跑起来

三个模式的结果差别很大:

三个并行任务,其中「加载订单」在第 60 毫秒失败:

coroutineScope     5 个协程全部 Cancelled,整棵树在 60.5 ms 就结束了
supervisorScope    4 个 Completed,只有失败的那个 Cancelled,200.4 ms 结束
都不失败           5 个全部 Completed,200.4 ms 结束

第一行请特别看一眼「埋点上报」——它和这次失败没有任何关系,一样被取消了,而且是在它自己都还没跑完的时候。

为什么这是对的

因为在绝大多数场景下,一件事的一部分失败了,剩下几部分继续做没有意义

// 加载一个订单详情页
coroutineScope {
    launch { loadOrderHeader() }     // 三样都拿到才能显示
    launch { loadOrderItems() }
    launch { loadShippingInfo() }
}
// 商品列表拉失败了,你拿着表头和物流信息也没法渲染这个页面

让另外两个继续跑,除了浪费流量和电,还会让你的错误处理变复杂——你得判断「三个里面成功了几个」的各种组合。

默认失败就一起停,是把复杂度从「组合爆炸」降到了「一个 try-catch」。

那什么时候该用 supervisorScope

判据一句话:这几件事之间,一个失败会不会让另一个变得没意义?

场景用哪个为什么
页面要的三份数据,缺一份就渲染不了coroutineScope失败一个,其余的白拿
首页上五个互相独立的卡片supervisorScope推荐位挂了,不该让 banner 也不显示
批量上传 20 张图supervisorScope第 7 张失败不该让前 6 张白传
ViewModel 里所有独立的用户操作viewModelScope(它自带 SupervisorJob点了「点赞」失败了,不该把「加载列表」也干掉

最后一行很重要:viewModelScopelifecycleScope 内部用的都是 SupervisorJob。所以你在 ViewModel 里直接 launch 出来的那些协程,本来就是互不连坐的。连坐只发生在你自己写的 coroutineScope { } 内部。

⚠ supervisorScope 只挡一层,而且只挡「向上」

两个常见的误解:

// ❌ 以为这样能让内层的兄弟互不影响
supervisorScope {
    coroutineScope {              // ← 这一层还是会连坐
        launch { a() }
        launch { b() }            // b 失败 → a 也被取消
    }
}

// ❌ 以为 supervisorScope 里的异常会自己消失
supervisorScope {
    launch { throw Boom() }       // 不会连坐兄弟,但会崩
}                                 // 它会走到 CoroutineExceptionHandler,
                                  // 没有的话就是未捕获异常

「不往上传播」不等于「被处理了」。supervisorScope 就必须自己处理每个孩子的异常——要么每个 launch 里面 try-catch,要么给 scope 装一个 CoroutineExceptionHandler

async 的异常为什么「迟到」

这是协程里最容易出事的一个细节。看 demo 日志里那一行:「async 的异常不会立刻炸,它在 await 的时候才抛」

val a = async { boom() }      // 立刻开始跑,第 10 毫秒就抛了
delay(1000)                   // 这一秒里什么都没发生
val r = a.await()             // ← 异常在这一行才被扔出来

因为 async 的语义是「我给你一个将来会有结果的东西」,异常是那个结果的一部分。它攒着,等你来取。

由此产生两条实用规则:

  1. async 出来的 Deferred 必须有人 await。不 await 的话,异常就静静地待在那儿——不崩、不打日志、不通知任何人。
  2. 只在需要「拿结果」的时候用 async。只是想并行跑一件不要返回值的事,用 launch——它的异常会立刻往上冒。

还有一个更细的点:async 的异常会同时做两件事——攒起来等 await并且取消它的父作用域(除非父是 supervisor)。所以你会看到「明明 catch 了 await,别的协程还是被取消了」。想真正隔离,得让那个 async 跑在 supervisorScope 里。

◆ 这一章的核心

一棵 Job 树上,只有三条规则:

  • 取消向下传——父取消,所有后代取消
  • 失败向上传——孩子失败,父取消(supervisorScope 在这一层挡住)
  • 等待向下看——父一定活到最后一个孩子结束

你遇到的每一个「协程行为怪怪的」,都能被这三条解释。

那些 scope 分别是什么

是什么什么时候被取消连坐吗
viewModelScopeViewModel 的扩展属性onCleared()(ViewModel 被清掉)不(SupervisorJob)
lifecycleScopeLifecycleOwner 的扩展属性Lifecycle 到 DESTROYED不(SupervisorJob)
rememberCoroutineScope()绑在 Composition 上这段 composable 离开组合
coroutineScope { }一个挂起函数,不是属性调用它的协程被取消时
GlobalScope没有父的顶层作用域永远不会

最后一行是唯一一个逃出结构化并发的口子,所以它被标了 @DelicateCoroutinesApi在 Android App 里你基本上永远不该用它——你要的那个「比页面活得久」的东西,答案是 WorkManager 或者一个 Application 级别的、你自己持有并且知道怎么取消的 scope,不是 GlobalScope

⌗ 到你手上

两个能立刻做的检查:

这一章的一句话

取消向下传,失败向上传,父一定等到最后一个孩子。supervisorScope 只在「失败向上」这一条上挡一层,而且挡住不等于处理了。

下一章:这一章一直在说「取消」,但有一件事一直没说——取消到底是怎么生效的。答案会让你有点不舒服:它并不能强制停下任何东西。下一章有一台真的取消引擎,会告诉你「发出取消之后,它还跑了多少毫秒」。