幕 III · 三种活法CH 15进度 15/25

Kotlin:一棵会传播失败与取消的树

上一章讲的是一个协程怎么运作。但真实系统里协程从来不是孤立的:一个请求 fork 出三个子请求,其中一个失败了 —— 剩下两个该怎么办?用户退出了页面 —— 那五个还在飞的网络请求该怎么办?这些问题的答案,在 Kotlin 里不是「你写代码去处理」,而是「语言帮你处理了」。这就是结构化并发 —— Kotlin 对整个并发世界最大的一个贡献,现在正在被 Python、Java、Swift 一个个抄过去(第 20 章)。

Job 树协作式取消coroutineScopeSupervisorJobFlow 背压

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 在协程里是有毒的

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

这是最常被问、也最常用错的一对。区别只有一句话:子协程失败时,兄弟们要不要陪葬。

coroutineScopesupervisorScope
一个子协程失败了 取消所有兄弟,然后整个 scope 抛异常 兄弟们继续跑,失败的那个自己处理
什么时候用 「全都要,少一个就没意义」
例:加载一个页面需要 用户信息 + 订单 + 优惠券,缺一个页面就渲染不出来 → 那就一起失败,快速返回错误
「各自独立,一个挂了不影响别的」
例:仪表盘上五个互不相关的卡片,一个接口挂了,剩下四个照样显示
默认是哪个 这个。coroutineScope { }withContext { }runBlocking { } 要显式声明。
但注意:viewModelScopelifecycleScope 内部用的是 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,异常还是会咬你

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,异常走两条完全不同的路
launchasync
返回什么Job(没有结果)Deferred<T>(一个 Future)
异常怎么走立刻抛给父 Job → 一路向上传播 → 最后交给 CoroutineExceptionHandler(或者让 app 崩)存进 Deferred 里,等你来 await()
你不 await,异常就一直躺着。
CoroutineExceptionHandler✅ 有用❌ 没用(异常是你自己 await 出来的,不是「未捕获」的)

但有个重要的补充:在 coroutineScope(非 supervisor)里,async 的失败仍然会取消整个 scope —— 哪怕你从来没 await 它。因为「取消传播」和「异常抛出」是两件事。

⚠ 陷阱 · CoroutineExceptionHandler 装错地方就是个摆设
// ❌ 装在子协程上 —— 完全无效
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) }    // 收集时才真的开始跑(冷流)
◆ 定论 · Flow 的背压是白送的

因为 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 / Channel / StateFlow 该用哪个
是什么用在哪
Flow冷流 —— 每个 collector 触发一次独立的执行数据源、网络请求、数据库查询。默认选它。
StateFlow热流,永远有一个「当前值」,天生 conflate,会去重(distinctUntilChangedUI 状态。这是它唯一的用途,也是最好的用途。
SharedFlow热流,没有「当前值」,可配重放数一次性事件(弹 Toast、导航)—— 因为这些事件不该在旋转后重放
Channel更底层的队列,每个元素只被一个消费者收到生产者-消费者、fan-out。它更像 Go 的 channel(第 17 章)。
✚ 动手

1. 写一个不可取消的协程while(true) { doWork() }),然后 cancel() 它,看它继续跑。然后加上 ensureActive(),再试一次。

2. 全局搜 runCatching每一个在协程里的,都是一个潜在的「取消被吞掉」的 bug。

3. catch (e: Exception)同上 —— CancellationExceptionException 的子类。

4. 检查你的 CoroutineExceptionHandler 装在哪。如果它装在一个子 launch 上 —— 它是个摆设。

下一章:第三门语言。Go 没有 async,没有 suspend,没有染色 —— 它把整个问题塞进了 runtime。六幕看完 GMP 调度器,然后你会明白 conn.Read() 那个「假阻塞」是怎么做到的。