协程异常处理:try-catch、CoroutineExceptionHandler、结构化并发异常传播
协程的异常处理,说实话,比线程要复杂不少。我刚学协程那会儿,觉得不就是个try-catch嘛,结果线上出了好几次崩溃才意识到——嗯,这里面的坑挺深的。先理清三件事:try-catch局部捕获、CoroutineExceptionHandler全局兜底、结构化并发下的异常传播规则。弄明白这三个,排查协程泄漏才能有章可循。
一、try-catch:最直觉但最容易被误解的方式
在协程里写try-catch,你得知道它能管什么、管不到什么。看个例子:
// 没问题的
viewModelScope.launch {
try {
val result = api.fetchData()
// ...
} catch (e: Exception) {
Log.e("TAG", "网络请求失败", e)
}
}
这段代码没问题,因为异常发生在launch的协程体内部。但你试试把try-catch放在launch外面:
// 这样是没用的
try {
viewModelScope.launch {
throw RuntimeException("boom")
}
} catch (e: Exception) {
// 这里永远收不到异常
}
launch和async是立即返回的,里面的异常不会被外部的try-catch捕获。只有在你主动调用.await()时,async抛出的异常才会被转移出来。
我个人习惯,只要在协程体内调用了可能抛异常的函数,就用try-catch包起来。别偷懒。特别是网络请求、数据库操作这些IO密集型的调用。
async但忘了.await(),异常会被静默吞掉——除非设置了CoroutineExceptionHandler。我在项目中遇到过好几次,启动了一堆并发任务,结果某个子协程挂了,整个页面都卡着不动,日志里一点异常痕迹都没有……后来加了全局处理器才抓到。
二、CoroutineExceptionHandler:全局兜底,但有局限
如果你不想在每个协程体里都写try-catch,可以给协程上下文挂一个处理器:
val handler = CoroutineExceptionHandler { _, throwable ->
Log.e("Crash", "协程异常:${throwable.message}")
}
viewModelScope.launch(handler) {
// 可能抛异常
doRiskyWork()
}
不过要注意,CoroutineExceptionHandler只对未捕获的异常生效。什么意思呢?如果你已经在协程体里用try-catch吃了异常,那处理器就看不到了。另外,它也不能捕获async抛出的异常——因为async的异常要通过.await()才能传递。
| 使用场景 | try-catch | CoroutineExceptionHandler |
|---|---|---|
| 局部捕获 | ✅ 精确控制 | ❌ 只能全局 |
| async异常 | ✅ 在await处捕获 | ❌ 不生效 |
| 子协程传播的异常 | ✅ 可在父协程捕获 | ✅ 如果父协程未处理 |
| 调试阶段 | 需手动加 | ✅ 一次配置,全局打印 |
我在项目里一般这样搭配:生产环境使用CoroutineExceptionHandler做日志上报,同时关键业务逻辑里再用try-catch做用户友好的降级处理。两层防护,心里踏实。
三、结构化并发异常传播:父协程如何被“牵连”
这是协程最具特色的地方——也是最容易泄漏的地方。当一个子协程抛出异常时,异常会向上传播,把父协程也一起取消掉。反过来,父协程异常也会取消所有子协程。这叫双向传播。
举个例子:
viewModelScope.launch {
launch { // 子协程1
delay(100)
throw RuntimeException("child1 failed")
}
launch { // 子协程2
delay(300)
Log.d("TAG", "子协程2执行")
}
}
运行结果:子协程1抛异常后,子协程2还没来得及执行就被取消了。整个父协程也会被取消。如果你父协程后面还有代码,根本不会执行到。
四、流程图:异常传播的完整路径
下面这张图把上面说的三种机制串起来了。你可以边看图边回忆我刚才讲的几个案例。
五、避坑指南 & 个人经验
- 我曾经在写批量网络请求时,用
coroutineScope包了一层,结果其中一个子协程挂了,所有请求都被取消。后来改成了supervisorScope,子协程之间互不影响,父协程也不会被取消。 - 我建议你在每个协程体里都考虑一下:这个异常如果没人管,会不会导致整个协程树被取消?如果会,要么加try-catch,要么用
supervisorScope或SupervisorJob。 - 还有一个细节:
async搭配supervisorScope时,即使某个子协程挂了,.await()仍然会抛出异常,你需要单独捕获它。不能指望全局处理器帮你拦——它拦不住。
六、总结一下
协程异常处理,说白了就三句话:
- try-catch 是基础,谁用谁知道。
- CoroutineExceptionHandler 是全局兜底,但别指望它能处理async的异常。
- 结构化传播是双刃剑——用不好导致泄漏,用好了反而能快速清理资源。
记住一个原则:异常要么自己吃掉,要么往上抛,但不能无声无息。如果代码里出现了“异常被吞掉但协程还活着”的情况,那泄漏就离你不远了。
□ 每个
launch内部是否有try-catch或挂载了CoroutineExceptionHandler?□ 如果你用了
async,是否有地方.await()并捕获异常?□ 是否需要
supervisorScope来隔离子协程的异常传播?□ 日志里是否能看到所有未被捕获的异常?