结构化并发:一棵会连坐的树
「一个子协程失败,把毫不相干的兄弟一起干掉」——第一次见到的人通常会觉得这太粗暴了。它确实粗暴,而且是故意的:这是用一个明确的、可预测的行为,去换掉一整类你永远查不出来的泄漏。
它要解决的问题
没有结构化并发的世界长这样(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) | 点了「点赞」失败了,不该把「加载列表」也干掉 |
最后一行很重要:viewModelScope 和 lifecycleScope 内部用的都是 SupervisorJob。所以你在 ViewModel 里直接 launch 出来的那些协程,本来就是互不连坐的。连坐只发生在你自己写的 coroutineScope { } 内部。
两个常见的误解:
// ❌ 以为这样能让内层的兄弟互不影响
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 的语义是「我给你一个将来会有结果的东西」,异常是那个结果的一部分。它攒着,等你来取。
由此产生两条实用规则:
async出来的Deferred必须有人await。不 await 的话,异常就静静地待在那儿——不崩、不打日志、不通知任何人。- 只在需要「拿结果」的时候用
async。只是想并行跑一件不要返回值的事,用launch——它的异常会立刻往上冒。
还有一个更细的点:async 的异常会同时做两件事——攒起来等 await,并且取消它的父作用域(除非父是 supervisor)。所以你会看到「明明 catch 了 await,别的协程还是被取消了」。想真正隔离,得让那个 async 跑在 supervisorScope 里。
一棵 Job 树上,只有三条规则:
- 取消向下传——父取消,所有后代取消
- 失败向上传——孩子失败,父取消(
supervisorScope在这一层挡住) - 等待向下看——父一定活到最后一个孩子结束
你遇到的每一个「协程行为怪怪的」,都能被这三条解释。
那些 scope 分别是什么
| 是什么 | 什么时候被取消 | 连坐吗 | |
|---|---|---|---|
viewModelScope | ViewModel 的扩展属性 | onCleared()(ViewModel 被清掉) | 不(SupervisorJob) |
lifecycleScope | LifecycleOwner 的扩展属性 | Lifecycle 到 DESTROYED | 不(SupervisorJob) |
rememberCoroutineScope() | 绑在 Composition 上 | 这段 composable 离开组合 | 不 |
coroutineScope { } | 一个挂起函数,不是属性 | 调用它的协程被取消时 | 会 |
GlobalScope | 没有父的顶层作用域 | 永远不会 | — |
最后一行是唯一一个逃出结构化并发的口子,所以它被标了 @DelicateCoroutinesApi。在 Android App 里你基本上永远不该用它——你要的那个「比页面活得久」的东西,答案是 WorkManager 或者一个 Application 级别的、你自己持有并且知道怎么取消的 scope,不是 GlobalScope。
两个能立刻做的检查:
1. 全项目搜 GlobalScope
有几个?每一个都问:它什么时候停?
答不上来的,就是一个泄漏。
2. 全项目搜 async
每一个都问:这个 Deferred 有人 await 吗?
没人 await 的 async,等于把异常扔进了黑洞。
(通常这种地方本来就该写 launch)
3. 顺手看一眼 CoroutineExceptionHandler
用了 supervisorScope 却没装 handler、
孩子里也没 try-catch 的地方,是潜在的崩溃点。
这个思想正在统一好几门语言:Java 21 的 Structured Concurrency、Swift 的 Task Group、Python 的 TaskGroup、Go 的 errgroup。《同时》第 20 章把四家放在一起对照过——它们的共同点是同一句话:不允许你启动一个没有归属的任务。
这一章的一句话
取消向下传,失败向上传,父一定等到最后一个孩子。supervisorScope 只在「失败向上」这一条上挡一层,而且挡住不等于处理了。
下一章:这一章一直在说「取消」,但有一件事一直没说——取消到底是怎么生效的。答案会让你有点不舒服:它并不能强制停下任何东西。下一章有一台真的取消引擎,会告诉你「发出取消之后,它还跑了多少毫秒」。