Kotlin:一棵会传播失败与取消的树
上一章讲的是一个协程怎么运作。但真实系统里协程从来不是孤立的:一个请求 fork 出三个子请求,其中一个失败了 —— 剩下两个该怎么办?用户退出了页面 —— 那五个还在飞的网络请求该怎么办?这些问题的答案,在 Kotlin 里不是「你写代码去处理」,而是「语言帮你处理了」。这就是结构化并发 —— Kotlin 对整个并发世界最大的一个贡献,现在正在被 Python、Java、Swift 一个个抄过去(第 20 章)。
Job 树:每个协程都有父亲
当你写 launch { } 或 async { },你创建的不只是一个协程,还是树上的一个节点:
viewModelScope (SupervisorJob)
│
└── launch { } ← Job A
│
├── async { fetchUser() } ← Job A1
├── async { fetchOrders() } ← Job A2
└── async { fetchCoupons() } ← Job A3
规则(记住这三条,你就懂了一切):
① 父 Job 被取消 → 所有子 Job 都被取消 (向下传播)
② 子 Job 失败 → 父 Job 被取消 → 兄弟全被取消 (向上传播,再向下)
③ 父 Job 会等所有子 Job 完成,自己才算完成 (不会有孤儿!)
「父作用域不会在子协程完成之前退出。」
这一条承诺,把「启动一个并发任务」变成了和调用一个函数一样有始有终的事:
suspend fun loadDashboard(): Dashboard = coroutineScope {
val user = async { api.getUser() }
val orders = async { api.getOrders() }
Dashboard(user.await(), orders.await())
}
// 这个函数返回的那一刻,保证:
// · 两个 async 要么都完成了
// · 要么都被取消了(如果有一个失败了)
// 【不可能】有任何协程还在后台偷偷跑。
对比 Go:go f() 之后,函数返回了,goroutine 还在跑,没人管它。(第 18 章会算这笔账。)
取消:它是协作式的(这是理解一切的钥匙)
没有人能强行杀死一个正在跑的协程。取消只是「设置一个标志位,然后请它自己停下来」。
val job = launch {
while (true) {
doWork() // ← 一个纯 CPU 的循环
}
}
delay(100)
job.cancel() // 设置 isActive = false
job.join() // 等它真的结束
// ❌ 但它永远不会结束!
// 因为这个循环里【没有任何挂起点】,取消检查发生在挂起点上。
// 这个协程会一直跑下去,占死一个线程。
三种让循环变得「可取消」的办法
// ① 检查 isActive
while (isActive) { // CoroutineScope.isActive
doWork()
}
// 取消后循环正常退出,不抛异常
// ② ensureActive() —— 取消了就抛 CancellationException
while (true) {
ensureActive()
doWork()
}
// ③ yield() —— 检查取消 + 顺便让出线程给别的协程
while (true) {
yield() // ← CPU 密集循环里最该用这个
doWork()
}
launch {
Thread.sleep(10_000) // ❌ 取消它?没门。
} // Thread.sleep 不知道协程是什么。
launch {
delay(10_000) // ✅ 可以取消 —— delay 是挂起函数,是取消点
}
launch {
blockingHttpCall() // ❌ 取消不了。线程卡在 socket read 上。
} // (取消标志设了,但没人去检查它)
取消只能在挂起点生效。阻塞调用没有挂起点,所以取消对它无效。
怎么办?如果你必须调阻塞的东西,把它的底层资源也一起关掉:
suspend fun download(url: String) = withContext(Dispatchers.IO) {
val call = okHttpClient.newCall(request)
try {
// 注册一个「协程被取消时」的回调
coroutineContext.job.invokeOnCompletion { call.cancel() }
call.execute() // 阻塞的,但被 cancel() 时会抛 IOException 提前返回
} finally { ... }
}
// 或者直接用支持协程的库(Retrofit 的 suspend 函数、Ktor)——
// 它们内部已经处理好了。
CancellationException 是特殊的
// 取消是靠抛一个 CancellationException 实现的。
// 它有一个特权:【它不会被当成「失败」】
launch {
try {
delay(1000)
} catch (e: CancellationException) {
// 被取消了
throw e // ← 必须重新抛出!否则取消就失效了
}
}
// ❌ 最经典的一个坑:
launch {
try {
delay(1000)
} catch (e: Exception) { // CancellationException 是 Exception 的子类!
log.error("出错了", e) // ← 你刚刚把取消信号吞了
} // 协程会「以为自己成功了」,继续往下跑
}
// ❌ 同一个坑的另一个面目:
launch {
val result = runCatching { api.call() } // runCatching 抓 Throwable!
// ^^^^^^^^^^^ 它会吞掉 CancellationException
result.onFailure { log.error(it) }
}
// ✅ 正确
launch {
try {
delay(1000)
} catch (e: CancellationException) {
throw e // 先放取消过去
} catch (e: Exception) {
log.error("真的出错了", e) // 再处理真正的错误
}
}
runCatching 捕获的是 Throwable —— 它会吞掉 CancellationException。
后果:你取消了一个协程,但它「若无其事」地继续执行 runCatching 之后的代码 —— 继续发网络请求、继续写数据库。取消失效了,而且没有任何报错。
Kotlin 官方也承认这是 runCatching 的一个设计缺陷(在协程语境下)。Kotlin 2.4 的 Rich Errors 正在从另一个方向解决这类问题。
解法:写一个协程安全的版本,然后在项目里禁用裸 runCatching:
inline fun <R> coRunCatching(block: () -> R): Result<R> =
try { Result.success(block()) }
catch (e: CancellationException) { throw e } // ← 放取消过去
catch (e: Throwable) { Result.failure(e) }
coroutineScope vs supervisorScope
这是最常被问、也最常用错的一对。区别只有一句话:子协程失败时,兄弟们要不要陪葬。
coroutineScope | supervisorScope | |
|---|---|---|
| 一个子协程失败了 | 取消所有兄弟,然后整个 scope 抛异常 | 兄弟们继续跑,失败的那个自己处理 |
| 什么时候用 | 「全都要,少一个就没意义」 例:加载一个页面需要 用户信息 + 订单 + 优惠券,缺一个页面就渲染不出来 → 那就一起失败,快速返回错误 |
「各自独立,一个挂了不影响别的」 例:仪表盘上五个互不相关的卡片,一个接口挂了,剩下四个照样显示 |
| 默认是哪个 | 这个。coroutineScope { }、withContext { }、runBlocking { } |
要显式声明。 (但注意: viewModelScope 和 lifecycleScope 内部用的是 SupervisorJob —— 所以一个 launch 挂了不会连累别的 launch。) |
// ✅ 全都要:一个失败 → 全部取消,立刻返回错误
suspend fun loadPage() = coroutineScope {
val user = async { api.getUser() }
val orders = async { api.getOrders() }
Page(user.await(), orders.await())
}
// ✅ 各自独立:一个失败 → 其他继续
suspend fun loadDashboard() = supervisorScope {
val cards = listOf("weather", "news", "stocks").map { type ->
async {
runCatching { api.getCard(type) } // 注意:每个自己 catch!
.getOrElse { ErrorCard(type) }
}
}
cards.awaitAll()
}
supervisorScope 只保证「兄弟不被取消」。但那个失败的 async,它的异常还在 Deferred 里等着你 —— await() 的时候还是会抛。
所以上面的例子里,每个 async 内部都自己 catch 了。如果你不 catch,awaitAll() 还是会炸。
规律:supervisorScope 管的是「取消的传播」,不是「异常的处理」。异常你还是得自己处理。
async 的异常,为什么迟到
这是协程里最反直觉的一课:
// ❌ 这个 try/catch 抓不到异常!
try {
val d = async { throw IOException("boom") }
} catch (e: IOException) {
// 永远不会执行到这里
}
// ✅ 异常在 await() 的时候才抛
try {
val d = async { throw IOException("boom") }
d.await() // ← 在这里抛
} catch (e: IOException) {
// ✓ 抓到了
}
launch | async | |
|---|---|---|
| 返回什么 | Job(没有结果) | Deferred<T>(一个 Future) |
| 异常怎么走 | 立刻抛给父 Job → 一路向上传播 → 最后交给 CoroutineExceptionHandler(或者让 app 崩) | 存进 Deferred 里,等你来 await()。你不 await,异常就一直躺着。 |
| CoroutineExceptionHandler | ✅ 有用 | ❌ 没用(异常是你自己 await 出来的,不是「未捕获」的) |
但有个重要的补充:在 coroutineScope(非 supervisor)里,async 的失败仍然会取消整个 scope —— 哪怕你从来没 await 它。因为「取消传播」和「异常抛出」是两件事。
// ❌ 装在子协程上 —— 完全无效
scope.launch {
launch(CoroutineExceptionHandler { _, e -> log(e) }) { // ← 无效!
throw RuntimeException()
}
}
// 异常会一路传给【父 Job】,子协程上的 handler 根本不会被调用。
// ✅ 装在根协程上(或者 scope 的 context 里)
val scope = CoroutineScope(
SupervisorJob() + Dispatchers.Main +
CoroutineExceptionHandler { _, e -> log(e) } // ✅ 这里才有效
)
规则:CoroutineExceptionHandler 只在「作用域的根」上生效。因为异常是一路向上冒的,只有到了顶才「无处可去」,才轮到 handler。
Flow:把「一个值」变成「一串值」
suspend fun 返回一个值。Flow 返回一串值 —— 而且它天生带背压。
fun prices(): Flow<Price> = flow {
while (true) {
emit(api.getPrice()) // emit 是 suspend 的!
delay(1000)
}
}
prices()
.map { it.toDisplay() }
.collect { println(it) } // 收集时才真的开始跑(冷流)
因为 emit() 是一个挂起函数。
如果下游的 collect 处理得慢,emit() 就挂起在那里,上游自然就发得慢。压力沿着管道自动往回传,一路传到源头。
你什么都不用做。这就是背压(第 21 章的主题)。
而三个操作符让你可以改变这个默认行为:
| 操作符 | 行为 | 用在哪 |
|---|---|---|
| (默认) | 上下游同步:emit 等 collect 处理完 | 安全,但慢 |
.buffer(n) | 中间加一个容量 n 的缓冲,上下游并发跑。缓冲满了,emit 还是会挂起(背压还在!) | 上下游都有耗时,想让它们重叠 |
.conflate() | 只保留最新的一个,中间的全丢。emit 永不挂起。 | UI 状态 —— 中间的状态渲染出来也没人看得见 |
.collectLatest { } | 新值来了,取消上一个还没处理完的 collect,重新开始 | 搜索框输入建议、只关心最新结果的场景 |
StateFlow:Android 的那个 5000
val uiState: StateFlow<UiState> = repository.observeData()
.map { UiState.Success(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000), // ← 那个神秘的 5000
initialValue = UiState.Loading
)
那个 5000 是在等屏幕旋转。
屏幕旋转时,Activity 被销毁重建,订阅者会短暂地降到 0。如果用的是 SharingStarted.Lazily 之外的「一没人订阅就停」策略,上游会立刻停掉,然后马上又被重新订阅 —— 白白重新请求一次网络。
WhileSubscribed(5000) 的意思是:订阅者归零后,再等 5 秒;如果 5 秒内有人重新订阅(旋转完成了),就什么也不做,继续用原来的上游。
| 是什么 | 用在哪 | |
|---|---|---|
Flow | 冷流 —— 每个 collector 触发一次独立的执行 | 数据源、网络请求、数据库查询。默认选它。 |
StateFlow | 热流,永远有一个「当前值」,天生 conflate,会去重(distinctUntilChanged) | UI 状态。这是它唯一的用途,也是最好的用途。 |
SharedFlow | 热流,没有「当前值」,可配重放数 | 一次性事件(弹 Toast、导航)—— 因为这些事件不该在旋转后重放。 |
Channel | 更底层的队列,每个元素只被一个消费者收到 | 生产者-消费者、fan-out。它更像 Go 的 channel(第 17 章)。 |
1. 写一个不可取消的协程(while(true) { doWork() }),然后 cancel() 它,看它继续跑。然后加上 ensureActive(),再试一次。
2. 全局搜 runCatching。每一个在协程里的,都是一个潜在的「取消被吞掉」的 bug。
3. 搜 catch (e: Exception)。同上 —— CancellationException 是 Exception 的子类。
4. 检查你的 CoroutineExceptionHandler 装在哪。如果它装在一个子 launch 上 —— 它是个摆设。
下一章:第三门语言。Go 没有 async,没有 suspend,没有染色 —— 它把整个问题塞进了 runtime。六幕看完 GMP 调度器,然后你会明白 conn.Read() 那个「假阻塞」是怎么做到的。