协程异常处理: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还没来得及执行就被取消了。整个父协程也会被取消。如果你父协程后面还有代码,根本不会执行到。

核心原理: 协程的异常传播依赖于Job层次结构。每个子协程通过继承父协程的Job来建立父子关系。一旦某个节点异常退出,整个子树都会被递归取消。

四、流程图:异常传播的完整路径

下面这张图把上面说的三种机制串起来了。你可以边看图边回忆我刚才讲的几个案例。

协程异常处理与传播机制 父协程 (launch) 子协程1 (抛出异常) 子协程2 (正常执行) 全局处理器 (可选) 未捕获 → 崩溃/上报 红色虚线表示异常传播路径:子协程1的异常 → 父协程 → 取消所有活跃子协程 如果没有 try-catch 或全局处理器捕获,异常会最终导致 JVM 崩溃或被 handler 上报 子协程2即使没抛异常,也会被父协程强制取消,这就是结构化并发的“连坐”机制

五、避坑指南 & 个人经验

  • 我曾经在写批量网络请求时,用coroutineScope包了一层,结果其中一个子协程挂了,所有请求都被取消。后来改成了supervisorScope,子协程之间互不影响,父协程也不会被取消。
  • 我建议你在每个协程体里都考虑一下:这个异常如果没人管,会不会导致整个协程树被取消?如果会,要么加try-catch,要么用supervisorScope或SupervisorJob。
  • 还有一个细节:async搭配supervisorScope时,即使某个子协程挂了,.await()仍然会抛出异常,你需要单独捕获它。不能指望全局处理器帮你拦——它拦不住。

六、总结一下

协程异常处理,说白了就三句话:

  1. try-catch 是基础,谁用谁知道。
  2. CoroutineExceptionHandler 是全局兜底,但别指望它能处理async的异常。
  3. 结构化传播是双刃剑——用不好导致泄漏,用好了反而能快速清理资源。

记住一个原则:异常要么自己吃掉,要么往上抛,但不能无声无息。如果代码里出现了“异常被吞掉但协程还活着”的情况,那泄漏就离你不远了。

自查清单:
□ 每个launch内部是否有try-catch或挂载了CoroutineExceptionHandler?
□ 如果你用了async,是否有地方.await()并捕获异常?
□ 是否需要supervisorScope来隔离子协程的异常传播?
□ 日志里是否能看到所有未被捕获的异常?