一句话总结:
协程的异常处理是其“父级责任制”设计哲学的体现。默认情况下,子协程的失败会触发父级的“连带责任”,导致整个任务族群被取消。我们可以通过三道防线——try-catch(本地解决)、supervisorScope(隔离责任)、CoroutineExceptionHandler(最终上报)——来精细化地管理这套责任体系。
一、核心原则:“父级责任制”与失败的传递
理解协程异常,首先要理解结构化并发的核心:父协程(Job)对其所有子协程的生命周期负责。
-
默认的Job - 连带责任
当一个子协程失败时,它会通知父Job。父Job认为“我的一个孩子失败了,意味着我的整个任务都失败了”,于是它会立即取消自己以及所有其他的子协程。这是一种**“快速失败”(Fail-Fast)**的策略,确保了任务状态的一致性。
// Job: 一个孩子失败,整个家庭解散 val parentJob = Job() val scope = CoroutineScope(parentJob) scope.launch { // 子协程1 delay(100) println("子协程1 成功") } scope.launch { // 子协程2 throw Exception("子协程2 失败") } // 结果: 子协程2的失败会取消parentJob,进而取消子协程1。子协程1的打印不会执行。
二、异常处理的“三道防线”
面对异常,我们可以像构建防火墙一样,设置三道防线。
第一道防线:try-catch — 本地处理,自给自足
这是最直接、最高优先级的处理方式。将可能抛出异常的代码块用try-catch包裹,异常就会被完全“消化”在当前协程内部,不会向上传播,也不会影响任何其他协程。
scope.launch {
try {
// ... 可能会失败的操作 ...
throw Exception("局部问题")
} catch (e: Exception) {
println("问题已在本地解决: $e")
}
}
// 这个协程的失败被内部消化,不会影响scope中的其他协程。
第二道防线:supervisorScope — 隔离“责任”,避免“ collateral damage”
当你需要并发执行多个互相独立的任务,且不希望其中一个的失败影响其他任务时,应使用supervisorScope。
// supervisorScope: 孩子A犯错,不影响孩子B
scope.launch {
supervisorScope {
launch {
throw Exception("孩子A的任务失败")
}
launch {
delay(100)
println("孩子B的任务不受影响,继续执行") // ✅ 这行会打印
}
}
}
supervisorScope 与 SupervisorJob 的关键区别:
supervisorScope是首选的、更安全的方式,它继承了外部作用域的上下文,只为内部创建一个临时的“监督”环境。CoroutineScope(SupervisorJob())会创建一个全新的、独立的顶级作用域,需要手动管理其生命周期,更容易出错。- 重要:
supervisorScope只阻止了兄弟间的取消,但异常本身依然会向上传播,需要上层的CoroutineExceptionHandler来捕获。
第三道防线:CoroutineExceptionHandler — 最后的“安全网”
这是一个安装在CoroutineScope上下文中的“全局”处理器,用于捕获所有未被try-catch处理的异常。它主要用于日志记录、错误上报和全局性的恢复逻辑,而不是用于精细的业务逻辑控制。
val handler = CoroutineExceptionHandler { context, exception ->
println("捕获到未处理的异常: $exception in $context")
}
val scope = CoroutineScope(Job() + handler)
scope.launch {
throw Exception("这个异常将被handler捕获")
}
注意:CoroutineExceptionHandler只对顶级协程(直接由scope.launch创建)或supervisorScope的子协程中未捕获的异常有效。普通子协程的异常会直接传递给父协程导致其取消,而不是先触发handler。
三、async与await的“延迟炸弹”
使用async启动的协程,其行为有所不同:
- 如果
async块内部发生异常,它不会立即抛出,而是被“封装”在返回的Deferred对象中。 - 只有当你调用
deferred.await()时,这个被封装的异常才会被重新抛出。
val deferred = scope.async {
throw Exception("我是一个延迟炸弹")
}
// 如果不调用await(),这个异常可能永远不会被发现,如同“静默失败”。
try {
deferred.await() // 在这里,异常才会被引爆
} catch (e: Exception) {
println("拆除炸弹: $e")
}
最佳实践:所有async的调用,都应该被try-catch包裹,或者确保其父协程能妥善处理await时可能抛出的异常。
四、决策流程:我该用哪种策略?
-
这个异常我能在本地恢复吗?
- 是 -> 使用
try-catch。 - 否 -> 进入下一步。
- 是 -> 使用
-
这是一组并发任务,一个失败了,其他还需要继续吗?
- 是(任务互相独立) -> 将它们包裹在
supervisorScope中。 - 否(任务互相依赖,all-or-nothing) -> 使用默认的
launch,让它们自动失败。
- 是(任务互相独立) -> 将它们包裹在
-
对于那些无法恢复、需要上报的“最终”异常,我需要一个统一的记录点吗?
- 是 -> 在你的根
CoroutineScope中安装一个CoroutineExceptionHandler。
- 是 -> 在你的根
通过这套“责任制”和“三道防线”的模型,你可以构建出既符合协程设计哲学,又极度健壮和可维护的并发代码。