协程异常:async 的异常为什么迟到
这是协程里最反直觉的一章,也是第 1 章那道 Q7 的正解。原因是你的 try/catch 直觉来自调用栈:谁调用了会抛异常的代码,谁就能 catch 它。但协程的异常走的是另一条路 —— 它沿着Job 树往上冒泡(第 15 章那棵树)。这两条路不重合:你的 try 块在调用栈上包着 async { },却在 Job 树上和它是两个不同的节点。理解了「异常走的是树,不是栈」,这一章的所有怪事都会变直。
两个构建器,两条异常路径
launch | async | |
|---|---|---|
| 返回 | Job(没有结果) | Deferred<T>(有结果) |
| 异常怎么走 | 立刻沿 Job 树向上冒泡 → 取消父作用域 → 最终交给 CoroutineExceptionHandler(没装就崩溃) | 被存进 Deferred 里,等你 await() 时才抛出来 |
| 怎么 catch | 在 launch 块内部 try/catch | 在 await() 外面 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 拦不住第二条路径,因为它走的是树,不是栈。
这是本章唯一需要记住的句子。所有的怪事都是它的推论:
- 你的
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 是流畅还是卡顿。