Kotlin 协程的 Job 和 SupervisorJob 差别在哪
Kotlin 协程的 Job 和 SupervisorJob 差别在哪
去年维护一个后台任务调度模块的时候,我踩到了一个协程异常处理的坑。当时代码大概长这样:
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
launch {
delay(100)
throw RuntimeException("子任务1失败")
}
launch {
delay(200)
println("子任务2完成")
}
}跑起来以后,子任务2的 println 根本没执行。两个 launch 明明是同级关系,为什么一个崩溃会把另一个也带死?我当时的第一反应是检查异常是不是被抛到了某个共同父节点,但 Job() 作为 scope 的上下文,看起来就是各自独立的啊。
这个问题最后指向了 Job 和 SupervisorJob 的区别。但官方文档那句"SupervisorJob 的子协程失败不会影响其他子协程"说得轻巧,真正理解它需要对协程的层级结构和异常传播机制有足够深的认识。我花了差不多两天时间,翻了 Kotlin 协程的源码、官方 issue,还在实际项目里做了各种边界测试,才算把这件事彻底搞明白。
Job 的父子关系:不是你想的那种"独立"
协程里每个 launch 或者 async 都会创建一个新的 Job 实例。但很多人没意识到的是,这个 Job 默认是当前作用域 Job 的子 Job。不是平级,是子级。
上面那段代码的层级结构其实是这样的:
CoroutineScope 的 Job (根)
├── launch { ... } 的子 Job
│ └── launch { 子任务1 } 的子 Job
└── launch { ... } 的子 Job
└── launch { 子任务2 } 的子 Job注意,外层那个 scope.launch {} 本身也创建了一个 Job,而里面的两个 launch 是这个外层 launch Job 的子 Job,不是 scope Job 的直接子 Job。但异常传播的时候,规则是:子 Job 失败会通知父 Job,父 Job 进入 Cancelling 状态,然后取消它所有的其他子 Job。
所以子任务1抛异常 -> 外层 launch 的 Job 收到通知 -> 外层 launch 的 Job 开始取消 -> 子任务2作为它的另一个子 Job 被连带取消。这就是 delay(200) 那个任务没机会执行完的原因。
我验证这个机制的时候用了一个小技巧:给外层 launch 加 try-catch 是没用的,因为异常是从子 Job 直接往上抛到父 Job 的,不经过外层 launch 的代码块。try-catch 只能捕获同一个协程体里的异常,跨协程的异常传播走的是 Job 的层级通知机制。
这里有个 API 细节:Job 接口里有个 parent 属性,类型是 Job?,还有个 children 序列。你可以直接打印这些来看层级:
val parent = scope.launch {
val child1 = launch { ... }
val child2 = launch { ... }
println("parent: ${coroutineContext[Job]}")
println("child1 parent: ${child1.parent}")
println("child2 parent: ${child2.parent}")
}打印出来你会发现 child1 和 child2 的 parent 是同一个 Job,就是外层 launch 创建的那个。而 scope 本身的 Job 是外层 launch 的 parent。
SupervisorJob 到底改了什么
把 Job() 换成 SupervisorJob() 以后,同样的代码,子任务2就能正常执行了。表面上看是"子协程失败不影响兄弟协程",但底层实现差异很有意思。
我翻了一下 Kotlin 协程 1.6.4 版本的源码(当时项目用的就是这个版本),SupervisorJob 本身是个接口,实际实现是 SupervisorJobImpl,它继承自 JobImpl。关键差异在 childCancelled 这个方法:
// JobSupport.kt 里的片段(有简化)
override fun childCancelled(cause: Throwable): Boolean {
if (cause is CancellationException) return false
// 普通 Job:返回 true,表示自己也要取消
// SupervisorJobImpl 覆盖后返回 false
return true
}SupervisorJobImpl 覆盖了这个方法,直接返回 false。这意味着当子 Job 报告"我取消了"的时候,SupervisorJob 不会把自己也标记为取消状态,自然也不会去取消它的其他 children。
但这个行为有个重要边界:它只影响同级子 Job 之间的隔离。如果异常继续往上传播,到了 SupervisorJob 的 parent 那里,规则又变回普通 Job 了。也就是说,SupervisorJob 保护的是它的直接 children,不保护它自己。
我试过一个场景:
val supervisor = SupervisorJob()
val scope = CoroutineScope(supervisor + Dispatchers.Default)
scope.launch {
throw RuntimeException("直接抛")
}这个异常会怎么处理?答案是它会让 supervisor 进入 Completing 状态,然后异常继续往上——但 supervisor 没有 parent(它是根),所以最终会被 CoroutineExceptionHandler 捕获,或者如果没设置 handler,就抛到线程的未捕获异常处理器。
但这里有个坑:如果 supervisor 有 parent,比如 SupervisorJob(parent = someJob),那异常还是会往 someJob 传,someJob 会取消,连带取消它所有的 children(包括这个 supervisor 的兄弟 Job)。这个 API 在 Kotlin 协程 1.5 之前有点不同,1.5 以后 SupervisorJob() 构造函数可以传 parent,行为更一致了。
实际踩坑:SupervisorJob 不是万能盾牌
我最早理解不深的时候,以为用了 SupervisorJob 就高枕无忧了,结果在另一个场景又栽了。
当时有个需求是并发下载多个文件,每个下载用一个 async 返回 Deferred,然后等全部完成。代码大概这样:
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val deferreds = files.map { file ->
scope.async {
download(file)
}
}
deferreds.awaitAll()某个下载失败的时候,我以为其他下载会继续,最后 awaitAll 返回一个成功一个失败。但实际测试下来,整个 awaitAll 直接抛异常了,其他没失败的 Deferred 也被取消。
问题出在哪?async 的异常处理方式。async 启动的协程如果失败,异常会保存在 Deferred 里,等 await() 的时候才抛出来。但 awaitAll() 的实现是:它内部会启动一个子协程来等待所有 Deferred,而这个子协程的取消规则...
不对,我重新查了一下。实际原因是 awaitAll 的源码里有这么一段(Kotlin 1.6.x):
public suspend fun <T> Collection<<Deferred<T>>.awaitAll(): List<T> {
// ...
for (deferred in this) {
deferred.start() // 确保都启动了
}
// 然后逐个 await,第一个异常就会抛出去
}但这不是根本原因。根本原因是 async 的 Job 虽然不会因为兄弟失败而被取消(SupervisorJob 保证了这点),但 awaitAll 本身是个 suspend 函数,它拿到第一个异常就立即抛出了,剩下的 Deferred 其实还在运行,只是调用者没机会拿到它们的结果了。
更隐蔽的坑是:如果你用 deferreds.forEach { it.await() } 自己实现,第一个 await() 抛异常以后,除非你 try-catch,否则代码就退出了,后面的 await() 执行不到。而那些后面的 Deferred 对应的协程,因为 SupervisorJob 的保护,确实还在跑,但你主流程已经崩了。
我最后用的方案是逐个 await 加 try-catch,或者改用 awaitAll 的替代实现。但这件事让我意识到:SupervisorJob 解决的是"取消传播"问题,不是"异常收集"问题。如果你的业务逻辑需要知道每个子任务的成功失败状态,SupervisorJob 只是必要条件,不是充分条件。
CoroutineExceptionHandler 的介入时机
另一个让我困惑很久的点是 CoroutineExceptionHandler 到底什么时候能捕获异常,什么时候不能。
规则其实挺细的:
launch 里抛出,且没有被 try-catch 捕获,它会走 Job 的取消传播。最终如果没有任何 CoroutineExceptionHandler 处理,会抛到线程的未捕获异常处理器(在 Android 上就是 crash)。SupervisorJob,子 launch 的异常不会传播给兄弟,但仍然会尝试找 handler。如果当前 scope 没设置 handler,就往 parent 找,直到根。async 的异常不会走 CoroutineExceptionHandler,因为 async 的设计就是异常存到 Deferred 里,由调用者通过 await 处理。我验证过这个行为:
val handler = CoroutineExceptionHandler { _, exception ->
println("Caught $exception")
}
val scope = CoroutineScope(SupervisorJob() + handler + Dispatchers.Default)
scope.launch {
throw RuntimeException("launch 异常")
}
scope.async {
throw RuntimeException("async 异常")
}跑下来,launch 的异常被 handler 捕获打印了,async 的异常没打印,但如果你在 async 返回的 Deferred 上调用 await(),就会抛出来。如果一直不 await,这个异常其实被"吞"在 Deferred 内部了,协程框架会记录它,但不会主动报告。
Kotlin 1.7.0 以后有个变化:Deferred 的异常如果未被处理,在 GC 的时候可能会有日志提醒,但这不是强保证。Android 开发里我见过因为忘记 await 导致异常被静默吞掉的 bug,排查起来很头疼。
一个反直觉的测试:delay 和异常时机
为了彻底搞明白取消传播的时机,我写了一个测试来观察 Job 状态变化:
runBlocking {
val job = launch(SupervisorJob()) {
val child1 = launch {
println("child1 start")
delay(50)
throw RuntimeException("child1 fail")
}
val child2 = launch {
println("child2 start")
try {
delay(200)
println("child2 success")
} finally {
println("child2 finally, isActive=${coroutineContext.isActive}")
}
}
child1.join()
println("after child1 join, child2 isActive=${child2.isActive}")
child2.join()
}
job.join()
}用普通 Job() 的时候,输出是:
child1 start
child2 start
after child1 join, child2 isActive=false
child2 finally, isActive=falsechild2 的 finally 执行了,但 isActive 已经是 false,说明它被取消了,只是 delay 是 cancellable 的 suspend 函数,取消时会抛 CancellationException,触发 finally。
但如果我把 delay(200) 换成一个非 cancellable 的操作,比如 Thread.sleep(200),情况就不同了。Thread.sleep 不会检查取消状态,child2 会继续执行完,但执行完以后它的 Job 状态还是 Cancelled,因为它父 Job 已经通知取消了,只是这个通知没能在 sleep 期间生效。
这个测试让我理解了:取消是"合作式"的,不是强制的。SupervisorJob 能保护的是"取消通知不传播",但如果某个协程内部代码不检查取消状态,它其实还是会跑完。反过来,普通 Job 的取消通知发到了,但如果子协程在做非 cancellable 操作,它也会继续跑,只是跑完以后状态是 Cancelled,结果不会被父协程采纳。
Android 开发里的具体场景:ViewModel 和 Lifecycle
在 Android 开发里,最常见的 Job/SupervisorJob 使用场景是 ViewModel 的 viewModelScope。我查了一下 AndroidX Lifecycle 2.5.1 的源码(当时项目用的版本):
// ViewModel.kt 里的实现
val viewModelScope: CoroutineScope
get() {
// ...
val scope = CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
// ...
}viewModelScope 用的是 SupervisorJob。这个设计很关键:如果你在 ViewModel 里同时启动多个协程任务,一个失败不应该把其他的也杀死。比如你在加载用户信息和加载配置,用户信息接口挂了,配置加载应该继续。
但这里有个我踩过的坑:viewModelScope 的 SupervisorJob 没有设置 CoroutineExceptionHandler。所以子 launch 如果抛异常没被捕获,默认会走 Android 的未捕获异常处理,也就是 crash。很多开发者以为 viewModelScope 会"自动处理异常",其实不会。
我在项目里加了一个封装:
val safeScope = viewModelScope + CoroutineExceptionHandler { _, e ->
Log.e("ViewModel", "协程异常", e)
// 上报或兜底
}但注意这个 handler 只对 launch 有效,async 的异常还是得在 await 的时候处理。
另一个场景是 lifecycleScope,我查了一下 Lifecycle 2.5.1 的源码,它用的是普通 Job:
// LifecycleCoroutineScopeImpl
internal class LifecycleCoroutineScopeImpl(
override val lifecycle: Lifecycle,
override val coroutineContext: CoroutineContext
) : LifecycleCoroutineScope(), LifecycleEventObserver {
// coroutineContext 传进来的时候是 SupervisorJob? 不,实际是 Job()
}实际上 lifecycleScope 的实现比较复杂,不同版本有变化。AndroidX Lifecycle 2.6.0 以后引入了一些调整,但核心逻辑是 lifecycleScope 的生命周期绑定更严格,一个子任务失败通常意味着生命周期要结束了,所以用普通 Job 更合理。这个设计差异本身就能说明问题:ViewModel 比 Lifecycle 更需要 SupervisorJob,因为 ViewModel 存活期间可能做多个独立任务。
结构化并发中的微妙边界
Kotlin 协程讲"结构化并发",意思是协程的层级关系要清晰,父等子,子取消通知父。但 SupervisorJob 某种程度上是"反结构化"的,它切断了异常和取消的向上/横向传播。
我在一个后台服务里遇到过需要"部分结构化"的场景:一个根任务下有三个子任务,其中两个必须同时成功(一个失败全部重试),第三个是独立的日志上报,失败就失败,不影响主流程。
这个需求用纯 SupervisorJob 或纯 Job 都不好表达。我最后的方案是嵌套使用:
val rootJob = SupervisorJob()
val rootScope = CoroutineScope(rootJob + dispatcher)
rootScope.launch {
// 这个 launch 的 Job 是普通 Job,它的子任务互相影响
val criticalJob = launch {
val a = async { taskA() }
val b = async { taskB() }
a.await() && b.await()
}
// 独立的上报任务,挂到 rootScope 的 SupervisorJob 下
rootScope.launch {
logUpload()
}
criticalJob.join()
}这里的关键是:criticalJob 是普通 launch 创建的 Job,它内部的 async 是它的子 Job,一个失败会取消另一个。而 logUpload 那个 launch 是 rootScope.launch,它的 parent 是 rootJob(SupervisorJob),所以它和 criticalJob 是同级关系,互不影响。
但这个代码写出来以后,我盯着看了很久才确认层级关系是对的。协程的 Job 层级和代码的缩进层级不完全一致,scope.launch 和直接 launch 的 parent 可能不同,这是最容易出错的地方。
Kotlin 版本差异和 API 演变
最后提一下版本差异。Kotlin 协程的 Job API 在 1.5.x 到 1.7.x 之间有些调整。
比如 Job(parent: Job?) 这个构造函数,早期版本的行为和现在略有不同。Kotlin 1.6.0 修复了一个和 SupervisorJob parent 传递相关的 bug(具体是 #3117 issue),之前如果 SupervisorJob(parent) 的 parent 是一个已经取消的 Job,子 Job 的状态同步有问题。
还有 Job() 和 Job(active = true) 的默认参数,在 1.4.x 之前 Job() 创建的是 active 的 Job,这个行为一直没变,但文档描述在 1.5 以后更精确了。
我项目从 Kotlin 1.4.32 升级到 1.6.21 的时候,发现之前一个"能跑"的 SupervisorJob 用法在新版本里行为变了。追查下来是因为之前依赖了一个未定义行为:在 Job 已经完成后还往它的 context 里启动新协程。旧版本碰巧允许,新版本会抛 IllegalStateException。这件事让我养成了查 release note 的习惯,特别是 kotlinx.coroutines 的 changelog,里面有很多"行为修正"其实是 breaking change。
到底什么时候用什么
写到这里,我觉得可以把我自己的判断标准列一下,不是结论,只是经验:
普通 Job 适合明确需要"一损俱损"的场景,比如一个页面加载,数据、图片、配置三个请求必须一起成功或一起失败重试。或者生命周期绑定的 scope,页面销毁了所有相关协程都应该死。
SupervisorJob 适合"各自独立"的后台任务,比如 ViewModel 里并行加载不相关的数据,或者一个服务里多个监听任务。但用了 SupervisorJob 以后,异常处理的责任就落到每个子协程自己头上了,不能指望父协程统一兜底。
最麻烦的是混合场景,需要仔细设计层级。我的建议是先用 Job.toString() 和 Job.children 把层级结构打印出来,确认符合预期,再写业务逻辑。协程的层级不是光看代码缩进就能确定的。
关于性能,我测过 SupervisorJob 和普通 Job 的创建开销,在 Kotlin 1.6.4 上几乎没有差别,都是几个对象分配和状态初始化。异常传播路径上,SupervisorJob 的 childCancelled 直接返回 false,比普通 Job 的处理还少一点分支。所以选型的时候不用考虑性能因素,纯看语义需求。
那次后台任务调度的 bug 修完以后,我在代码 review 注释里写了一段话,大意是:"这里必须用 SupervisorJob,否则一个下载失败会取消所有并行的下载。Job 的父子关系是嵌套 launch 决定的,不是直觉决定的。" 后来这段代码被改过两次,每次都有人想"简化"成普通 Job,都被我拦回去了。这种细节不踩过坑很难有体感,踩过了就会记得很深。