Kotlin 协程的 Job 和 SupervisorJob 差别在哪

Kotlin 协程的 Job 和 SupervisorJob 差别在哪

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 的上下文,看起来就是各自独立的啊。


这个问题最后指向了 JobSupervisorJob 的区别。但官方文档那句"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 的保护,确实还在跑,但你主流程已经崩了。


我最后用的方案是逐个 awaittry-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=false

    child2 的 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 使用场景是 ViewModelviewModelScope。我查了一下 AndroidX Lifecycle 2.5.1 的源码(当时项目用的版本):


    // ViewModel.kt 里的实现
    val viewModelScope: CoroutineScope
        get() {
            // ...
            val scope = CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
            // ...
        }

    viewModelScope 用的是 SupervisorJob。这个设计很关键:如果你在 ViewModel 里同时启动多个协程任务,一个失败不应该把其他的也杀死。比如你在加载用户信息和加载配置,用户信息接口挂了,配置加载应该继续。


    但这里有个我踩过的坑:viewModelScopeSupervisorJob 没有设置 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 那个 launchrootScope.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 上几乎没有差别,都是几个对象分配和状态初始化。异常传播路径上,SupervisorJobchildCancelled 直接返回 false,比普通 Job 的处理还少一点分支。所以选型的时候不用考虑性能因素,纯看语义需求。


    那次后台任务调度的 bug 修完以后,我在代码 review 注释里写了一段话,大意是:"这里必须用 SupervisorJob,否则一个下载失败会取消所有并行的下载。Job 的父子关系是嵌套 launch 决定的,不是直觉决定的。" 后来这段代码被改过两次,每次都有人想"简化"成普通 Job,都被我拦回去了。这种细节不踩过坑很难有体感,踩过了就会记得很深。

    Kotlin 2.0 正式发布,迁移要注意什么 2026-05-26

    评论区