默认情况下,协程遵循一个严格的父子层级关系。在一个由标准 Job
构建的 CoroutineScope
中,异常传播遵循以下规则:
任何一个子协程的失败(即抛出未捕获的异常),都会立即导致其父 Job
被取消,进而导致父 Job
取消所有其他的子协程。
这种“全有或全无”的行为是结构化并发的关键特性,它能确保协程作用域内的所有任务作为一个整体,要么全部成功,要么在出现问题时一起被清理,避免了资源泄露。
示例:标准 Job
的级联失败
import kotlinx.coroutines.*
fun main() = runBlocking {
val scope = CoroutineScope(Job() + Dispatchers.IO)
val job1 = scope.launch {
println("任务1开始执行...")
delay(500)
println("任务1抛出异常!")
throw IllegalArgumentException("任务1失败")
}
val job2 = scope.launch {
try {
println("任务2开始执行...")
delay(1000) // 将会因为 job1 的失败而被取消
println("任务2执行完毕。 (此行不会被打印)")
} finally {
println("任务2在 finally 中被清理。")
}
}
joinAll(job1, job2)
}
/*
输出结果:
任务1开始执行...
任务2开始执行...
任务1抛出异常!
Exception in thread "main" java.lang.IllegalArgumentException: 任务1失败
任务2在 finally 中被清理。
*/
在上面的例子中,job1
的失败导致整个 scope
被取消,从而也取消了正在运行的 job2
。
在某些场景下,我们希望一个任务的失败不应该影响其他并行的兄弟任务。例如,在 ViewModel 中同时从网络和本地数据库加载数据,网络请求的失败不应该中断对本地数据库的读取。
SupervisorJob
正是为此设计的。它是一种特殊的 Job
,其异常传播规则如下:
SupervisorJob
的子协程所抛出的异常不会向上传播给其父协程,因此也不会导致父协程和兄弟协程被取消。
你可以通过将 SupervisorJob
实例传递给 CoroutineScope
来创建一个监督作用域。
// 创建一个具有监督能力的协程作用域
val supervisorScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
示例:SupervisorJob
隔离失败
我们将上一个例子中的 Job()
替换为 SupervisorJob()
:
import kotlinx.coroutines.*
fun main() = runBlocking {
// 使用 SupervisorJob()
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val job1 = scope.launch {
println("任务1开始执行...")
delay(500)
println("任务1抛出异常!")
throw IllegalArgumentException("任务1失败")
}
val job2 = scope.launch {
try {
println("任务2开始执行...")
delay(1000)
println("任务2执行完毕。") // 成功执行并打印
} finally {
println("任务2在 finally 中被清理。")
}
}
// 等待所有任务
job1.join()
job2.join()
}
/*
输出结果:
任务1开始执行...
任务2开始执行...
任务1抛出异常!
// 此处会打印异常堆栈,但不会影响 job2
任务2执行完毕。
任务2在 finally 中被清理。
*/
可以看到,job1
的失败不再影响 job2
,job2
得以成功执行完毕。
除了创建一个长生命周期的 CoroutineScope
,你还可以使用 supervisorScope { ... }
构建器来创建一个临时的监督作用域。这对于需要在一组操作中隔离失败,但不希望将这种行为扩展到整个父作用域的场景非常有用。
suspend fun performParallelTasks() = supervisorScope {
launch { /* 任务 A */ }
launch { /* 任务 B,即使任务 A 失败,任务 B 也不受影响 */ }
}
SupervisorJob
的监督特性仅对其直接子协程有效。如果一个子协程自身又创建了一个新的子作用域(使用标准的 Job
),那么在这个新的子作用域内,异常传播将恢复为默认的“一损俱损”行为。
这是一个非常常见的错误来源。
错误示例:
fun main() = runBlocking {
val scope = CoroutineScope(SupervisorJob())
// 在 supervisorScope 中启动一个新的协程
scope.launch { // 这个 launch 是 SupervisorJob 的直接子级
// 在内部再次 launch,这创建了一个新的默认 Job 子作用域
launch { // 这个 launch 是上一个 launch 的子级,不再受 SupervisorJob 直接监督
println("内部任务1开始...")
delay(500)
throw Exception("内部任务1失败")
}
launch {
println("内部任务2开始...")
delay(1000) // 会被取消
println("内部任务2完成。(不会被打印)")
}
}
delay(2000)
}
在上述代码中,“内部任务1”的失败会导致其父协程(第一个 launch
)被取消,从而也取消了它的兄弟“内部任务2”。SupervisorJob
无法“看到”并管理这些孙子辈协程的内部失败。
正确做法:
要让多个任务都受到监督,应该将它们作为监督作用域的直接子级来启动。
fun main() = runBlocking {
val scope = CoroutineScope(SupervisorJob())
// 将两个任务作为 scope 的直接子级启动
scope.launch {
println("任务1开始...")
delay(500)
throw Exception("任务1失败")
}
scope.launch {
println("任务2开始...")
delay(1000)
println("任务2完成。") // 成功执行
}
delay(2000)
}
特性 | Job |
SupervisorJob |
---|---|---|
失败传播 | 子协程失败会取消父协程和所有兄弟协程。 | 子协程失败不会影响父协程或兄弟协程。 |
适用场景 | 任务之间紧密耦合,需要作为一个整体成功或失败。 | 多个独立的、并行的任务,一个任务的失败不应影响其他任务。 |
关键 | 实现严格的结构化并发,确保整体性。 | 提供容错能力,隔离失败。 |