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

协程异常:async 的异常为什么迟到

这是协程里最反直觉的一章,也是第 1 章那道 Q7 的正解。原因是你的 try/catch 直觉来自调用栈:谁调用了会抛异常的代码,谁就能 catch 它。但协程的异常走的是另一条路 —— 它沿着Job 树往上冒泡(第 15 章那棵树)。这两条路不重合:你的 try 块在调用栈上包着 async { },却在 Job 树上和它是两个不同的节点。理解了「异常走的是树,不是栈」,这一章的所有怪事都会变直。

launch vs asyncDeferredCoroutineExceptionHandler并行分解

两个构建器,两条异常路径

launchasync
返回Job(没有结果)Deferred<T>(有结果)
异常怎么走立刻沿 Job 树向上冒泡 → 取消父作用域 → 最终交给 CoroutineExceptionHandler(没装就崩溃)被存进 Deferred 里,等你 await() 时才抛出来
怎么 catch在 launch 块内部 try/catchawait() 外面 try/catch
叫它「未处理异常」的时机立即如果你永远不 await,这个异常可能永远不会被抛出(但见下面的坑)

坑一:try/catch 包不住 launch

// ✗ 抓不到
try {
    viewModelScope.launch { api.fetch() }    // launch 立刻返回,异常是「以后」发生的
} catch (e: Exception) { }                   // 这里早就执行完了

// ✓ 正确:catch 写在 launch 里面
viewModelScope.launch {
    try { api.fetch() } catch (e: Exception) { showError(e) }
}

原因很朴素:launch「发射后不管」—— 它立刻返回一个 Job,你的 try 块在下一微秒就结束了,而协程体可能几百毫秒后才在另一个线程上抛出异常。那时候你的 catch 块早就不在栈上了。

坑二:async 的异常会迟到 —— 而且可能根本不来

viewModelScope.launch {
    try {
        val d = async { error("boom") }      // 立刻就炸了
        // ...
    } catch (e: Exception) {
        Log.e("x", "caught")                 // 不会打印
    }
}

异常被存进了 Deferred,等着你 await() 来取。你没取,catch 块自然什么也等不到。

但事情还没完 —— 这段代码的真正后果比「抓不到」更严重:async 创建的是 viewModelScope子协程,而子协程失败会沿着 Job 树向上传播(第 15 章规则 ②)。于是:

  • 异常一边被存进 Deferred 等你 await;
  • 一边把父作用域和它所有的兄弟协程全都取消掉。

你的 try/catch 拦不住第二条路径,因为它走的是树,不是栈。

◆ 锭 · 异常走 Job 树,不走调用栈

这是本章唯一需要记住的句子。所有的怪事都是它的推论:

  • 你的 try上包着 launch/async,但在上,它们是父子两个节点 —— 异常从子节点往上冒,不经过你的 catch 块
  • 唯一能让两条路重合的东西是 coroutineScope { }await():它们会挂起当前协程、等待、然后把异常重新抛在你的调用栈上 —— 这时 try/catch 才有效。

正解:并行分解的标准写法

suspend fun loadPage(): Page = coroutineScope {          // ← 关键:一个新的作用域
    val user = async { api.getUser() }
    val posts = async { api.getPosts() }
    Page(user.await(), posts.await())                   // 两个请求并行,都完成才返回
}

// 调用点:现在 try/catch 有效了
viewModelScope.launch {
    try {
        val page = loadPage()
    } catch (e: IOException) {
        showError(e)     // ✓ 抓得到
    }
}

为什么这次可以?因为 coroutineScope { } 是一个挂起函数:它会等到里面所有子协程结束,如果有子协程失败,它会取消其余的,然后把异常在它自己的调用点重新抛出。—— 于是异常回到了调用栈上,try/catch 恢复了作用。

这是所有「并行做几件事」代码的标准骨架,请把它背下来:suspend fun x() = coroutineScope { async + async + await }

三条配套的细节:

  • 不要在 coroutineScope 外面裸用 async —— 那是上面那个坑。async 应该总是活在一个 coroutineScope(或 supervisorScope)里,且总是被 await
  • 多个结果用 awaitAll()listOf(d1, d2, d3).awaitAll() —— 任何一个失败就立刻抛,比一个个 await 更快失败。
  • 「先 await 第一个再启动第二个」是个经典错误val a = async { }.await(); val b = async { }.await() —— 这样写完全是串行的,你白写了 async。先启动全部,再统一 await。

CoroutineExceptionHandler:装错地方就是个摆设

这个 handler 有一条严格的规则:它只在「根协程」上生效。

val handler = CoroutineExceptionHandler { _, e -> Log.e("app", "未捕获", e) }

// ✓ 生效:装在 scope 上(scope 里 launch 出来的都是根协程)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + handler)
scope.launch { error("boom") }        // → handler 收到

// ✓ 生效:装在根 launch 上
scope.launch(handler) { error("boom") }

// ✗ 摆设:装在协程上
scope.launch {
    launch(handler) { error("boom") }  // handler 被忽略!
    // 异常照样冒泡到父,由父的 handler(或崩溃)处理
}

// ✗ 摆设:async 根本不用 handler
scope.async(handler) { error("boom") } // 异常存进 Deferred,等 await —— handler 永远不会被调用

为什么?因为异常会一路冒泡到根(第 15 章),子协程上的 handler 在路上会被跳过 —— 只有走到头、发现「没人再管了」,才轮到根协程的 handler 兜底。

它是「最后一道防线」,不是「try/catch 的替代品」。业务错误该在业务处 catch;handler 只负责「不该发生但发生了」的兜底(记日志、上报崩溃)。

还有一条:CancellationException 是特殊公民

取消异常不会被当作「失败」:

  • 不会触发规则 ②(不向上传播失败、不会取消兄弟)—— 否则任何一次正常取消都会引发全家阵亡。
  • 不会走到 CoroutineExceptionHandler
  • 但它catch (e: Exception) 抓到 —— 这就是第 15 章那个吞掉取消信号的灾难的根源

再强调一遍这条纪律(它值得强调三遍):catch 里遇到 CancellationException 必须重新抛出。

⚠ 炉渣 · 多个子协程同时失败时,只有第一个「胜出」

Job 树上第一个失败的异常成为「主异常」,后续的异常会被塞进它的 suppressed 列表里。

后果:你的崩溃日志里可能只看到一个异常,而真正的元凶是被 suppress 掉的那个。排查这类问题时,记得看 e.suppressed(Android 的崩溃上报默认可能不展示它)。

一张判据表(贴在墙上那种)

你想…怎么写
启动一个「不需要结果」的后台任务scope.launch { try { ... } catch { ... } } —— catch 写在里面
并行做几件事,都要结果coroutineScope { val a = async{}; val b = async{}; f(a.await(), b.await()) }
并行做几件事,允许部分失败supervisorScope { } + 每个子协程内部各自 try/catch(或者用 runCatching,但记得放行 CancellationException)
兜底记录所有未捕获异常scope 的 context 上装 CoroutineExceptionHandler
清理资源(无论成功失败取消)finally { withContext(NonCancellable) { ... } }(第 15 章)
▸ 旧模具 · 这就是回调地狱真正难的地方

回想一下 Java 回调时代:一个异步操作失败了,怎么把错误传给「调用方」?—— 你得手动设计 onSuccess/onError 两个回调,然后在每一层手工转发错误。漏一层,错误就消失了;转发错了,错误就跑到了不相干的地方。

协程把「错误传播」变成了自动的(沿 Job 树),代价是你必须理解那棵树。这是一笔好交易,但它不是免费的 —— 而这一章就是这笔交易的账单。

✚ 动手锻打

1. 搜项目里的 async {。检查每一处:它在 coroutineScope 里吗?它被 await 了吗?裸奔的 async 是定时炸弹

2. 搜 try { 后面紧跟 launch 的代码 —— 那个 catch 是个摆设,一行都不会执行。

3. 搜 CoroutineExceptionHandler,检查它装的位置:是 scope 的 context 上,还是某个子 launch 上?后者是无效的。

4. 找一处「串行 await」(async{}.await() 连着写两次)—— 把它改成先启动后 await,然后测一下耗时。这类代码通常能白捡一倍的速度

下一章:Flow。它的背压是白送的(因为 emit 是 suspend 的),而 buffer / conflate / collectLatest 的差别,大到能决定你的 UI 是流畅还是卡顿。