协程这名字这几年已经被说烂了,但每次在项目里看到崩溃日志和异常静默,还是能发现不少人对Job和SupervisorJob的理解只停留在“会用就行”。我甚至见过有人因为把SupervisorJob当成万能保险,最终在Android崩溃上报里看到整屏的CancellationException。这篇文章把我们排查过的、踩过的、重构过的协程上下文、Job、SupervisorJob三者之间的关系彻底讲清楚。既可以给刚入门的朋友扫盲,也可以当作老项目协程层重构前的自检清单。
1. 协程上下文到底在管理什么
很多人第一次接触CoroutineContext时,会把它类比成Java里的HashMap,或者以为是"传参用的环境变量"。这个理解没全错,但会带来很大误导。协程上下文不是一份方便读取的数据,它是协程运行时的“宪法”,决定了协程在哪个线程上跑、响应哪些键、归属于哪个任务,以及异常被谁捕获。搞清楚这个集合里装的东西,后续的Job和SupervisorJob才能有落脚点。
1.1 上下文不是“环境变量”,而是协程的“配置文件”
协程上下文本质上是一个按Key索引的集合。每个元素都实现了CoroutineContext.Element接口,而这个接口又继承自CoroutineContext。听起来有点绕,但你可以把它理解成一个不允许重复Key的Map,每个Key对应唯一一个元素,加到相同的Key时会覆盖旧值。
常见元素包括:
- Job:当前协程的任务句柄,负责生命周期、取消、等待;
- CoroutineDispatcher:协程执行的线程调度器,比如Dispatchers.Main、Dispatchers.IO;
- CoroutineName:给协程起名字,用于日志定位;
- CoroutineExceptionHandler:全局异常兜底处理器;
- ThreadContextElement:自定义上下文,比如把某个请求ID透传到子协程。
上下文合并用的加号运算符也有讲究。Dispatchers.IO + CoroutineName("syncTask")会返回一个包含两个元素的上下文集合;如果再加一个Job,比如parentJob + Dispatchers.IO,它并不是把parentJob复制进去,而是把parentJob作为新协程的父Job进行关联。这个区别会在后文详细展开。
实际开发中最容易踩的坑是:把上下文当作简单的“键值对”传参,以为子协程能通过上下文“读到”父协程的局部变量。这是错误的。上下文里的元素是可继承、可覆盖的配置项,不是业务数据仓库。业务数据要放到外部参数或ThreadLocal中,不要滥用上下文。
1.2 作用域与上下文的继承关系
协程启动时,新协程会从创建它的作用域(scope)继承上下文,再通过参数覆盖或新增元素。这里有个重要概念:结构化并发下的“继承”。如下代码:
val parentScope = CoroutineScope(Dispatchers.IO + CoroutineName("parent")) parentScope.launch { // 在这个协程内部再启动一个子协程 launch(Dispatchers.Default) { println(coroutineContext[CoroutineName]) // 输出 parent } }子协程只指定了Dispatcher,但CoroutineName被继承了下来。你可以理解为:上下文沿作用域链向下传递,每一层可以通过+新增元素,未覆盖的元素会原样保留。
作用域本身不带Job时,默认会创建一个独立的Job。例如:
val scope = CoroutineScope(Dispatchers.Default)此时scope内部隐含了一个Job,scope启动的所有协程都是这个Job的子协程。所以当你调用scope.cancel(),该作用域下的所有协程都会被取消。
协程作用域有几种常见来源:最常见的是CoroutineScope()自己创建,其次是lifecycleScope、viewModelScope这类框架自带作用域,以及coroutineScope {}、supervisorScope {}这类挂起函数创建的临时作用域。这几类作用域的差异,本质上还是它们内部持有的Job类型和上下文的差异。理解了这一点,后面再看SupervisorJob就不会觉得是在记API了。
2. Job:协程的生命周期与结构化并发
Job的概念用一个成语就能说明白:荣辱与共。它是父协程与子协程之间的"责任绑定"。当你启动一个协程时,实际上就创建了一个Job,并把它挂在父Job下。这个父子关系决定了取消流向和异常传播方向。很多线上bug的根源,就是开发者只看了Job的API名字,没搞懂它的状态机和职责。
2.1 Job的状态机与父子关系
Job不是简单的“运行/结束”二态,而是有完整状态机的。常见状态有:
- New:创建但尚未启动,比如用
CompletableDeferred或某些特殊方式创建Job时; - Active:执行中;
- Completing:等待子协程完成,这个状态对外表现为仍然Active;
- Completed:正常结束;
- Cancelling:执行取消流程中,此时协程可能还在执行不可中断的代码或资源回收;
- Cancelled:已取消。
用job.isActive.isCompleted.isCancelled这三个属性,可以判断大部分状态。需要特别注意的是isActive为false并不一定等于“取消”,也可能仍然处于New状态。所以在自定义协程控制时才需要通过ensureActive()主动检查取消状态,避免在取消后仍然做无用功。
父子关系的核心规则是:
- 父Job取消,所有子Job一并取消。
- 子Job异常,默认会取消父Job,从而取消所有兄弟Job。
- 父Job只有等待所有子Job完成之后,才能进入Completed状态。
- 父Job取消后,子Job不能再启动新的子协程。
这里有一个很多人忽略的细节:即使父协程代码执行完了,如果它还有存活的子协程,它依然会保持“Completing”状态,直到所有子协程都结束。这就是coroutineScope {}的语义——它保证闭包内的所有子协程全部完成才返回。如果子协程因为网络超时一直不返回,调用coroutineScope的函数就会一直被挂起。
从失控的“GlobalScope.launch”说起,它没有父Job,生命周期完全不受外部控制,使用不当很容易造成协程泄漏和异常逃逸。所以现在官方明确建议避免直接使用GlobalScope,而是用带作用域的结构化并发。
2.2 等待、取消与异常传播的关键行为
Job的三个关键操作对应三个关键场景:
join():挂起当前协程,直到目标Job完成或取消。注意join不是阻塞线程,而是挂起,所以可以在主线程调用。cancel():请求取消。取消是协作式的,协程内部需要响应挂起点或检查状态。cancelAndJoin():先取消然后等待其真正结束,适用于需要保证资源释放完成的场景。
取消的协作机制是新手最困惑的地方。你调用cancel()后,协程如果正在CPU密集计算且没有检查取消状态,它仍然会继续跑。必须在代码里调用ensureActive()或yield()等挂起函数,取消标志才可能被响应。而delay()、网络请求库的挂起函数这些本身就是可取消挂起点,所以它们多半会被立刻响应。
异常传播的方向比取消更值得警惕。当一个子协程抛出未捕获异常时,默认行为不是只有这个子协程自己失败,而是会连同父Job一起取消,然后异常继续沿着父链向上传播。如果一直没人处理,最终会到达作用域顶层的CoroutineExceptionHandler或者线程的未捕获异常处理器,导致整个进程崩溃。
举个例子:
fun main() = runBlocking { val parentJob = launch { launch { delay(100) throw RuntimeException("boom") } delay(500) println("parent still running") } parentJob.join() println("parent completed: ${parentJob.isCancelled}") }这段代码执行后,子协程抛出RuntimeException,父协程会被连带取消。你看到的输出不会出现“parent still running”,因为父协程在delay期间就被取消了。最终parentJob.isCancelled为true,异常会继续向上传播,期间如果没有捕获,runBlocking都会接收到这个异常并抛给外层。运行结果可能会让你意外:异常不是静默的,它甚至会直接影响整个外层作用域。
这就是普通Job的结构化并发规则:一荣俱荣,一损俱损。这个设计是为了避免“子协程失败,父协程却不知道,继续等待不可能的结果”。但对于某些业务场景,这个规则过于严格,我们把某个子任务失败等价于整个作用域失败并不合理。这时候就轮到SupervisorJob登场了。
3. SupervisorJob:怎么把子协程的异常“隔离”掉
SupervisorJob的中文翻译通常叫“监督者Job”。它的名字很直白:监督子协程,但不替子协程“扛罪”。与普通Job最大的区别是:在SupervisorJob作用域内,子协程的失败不会自动取消父Job,也不会取消兄弟协程。这一点对批量处理、多模块任务隔离的场景非常关键。
3.1 SupervisorJob与普通Job的核心差异
我最早使用SupervisorJob时,以为它是用来捕获异常的。后来发现完全不是。SupervisorJob改变的是“异常传播策略”,而不是“异常处理方式”。异常不会因为用了SupervisorJob就消失,也不会被自动吞掉。它改变的只是:当一个子协程抛出异常时,其他协程是否受到影响。
看这段对比代码:
fun childThrows() { val scope = CoroutineScope(SupervisorJob()) scope.launch { throw IllegalArgumentException("child failed") } scope.launch { delay(200) println("sibling still alive") } }在SupervisorJob作用域里,第一个子协程抛异常后,第二个子协程依然会在200毫秒后打印“sibling still alive”。但如果把SupervisorJob()换成Job(),第二个子协程会被连带取消,且打印不会发生。这就是二者最核心的差异。
不过要体会区别,不能只站在子协程视角。还要站在父Job视角看取消。如果用supervisorScope {}包裹两个子协程,在子协程A抛异常后,scope本身并不会退出,只有当所有子协程结束时,supervisorScope才会返回。这意味着你可以在一个作用域里同时跑多个独立任务,某个失败后,其他任务继续执行,最终根据结果统一处理。
有一个细节非常容易踩坑:SupervisorJob只对“直接子协程”有监督效果,对于子协程再启动的“孙协程”不会递归地隔离。比如:
val scope = CoroutineScope(SupervisorJob()) scope.launch { launch { throw RuntimeException("grand child fails") } }这段代码中,孙协程抛出异常,仍然会取消中间的那个子协程,进而冒泡到SupervisorJob。SupervisorJob会收到异常吗?不会,异常会在中间子协程层面被处理吗?也不会——它会导致中间子协程失败。最终结果依然是整个scope收到取消信号,因为子协程本身失败会传递给SupervisorJob吗?实际上SupervisorJob的direct child是中间协程,孙协程的失败导致中间协程取消,中间协程作为SupervisorJob的直接子协程被取消,但取消不等于失败,SupervisorJob不会因为子协程被取消而父取消。听起来很绕,但实际发生的是:孙协程抛出异常后,异常会导致其中间父Job取消,同时异常向上传播到中间Job的父Job,也就是SupervisorJob。因为SupervisorJob不会因为子异常取消自己,所以这个异常被“拦截”在了SupervisorJob这一层,最终由SupervisorJob的异常处理机制处理(比如CoroutineExceptionHandler)。这个机制既隔离了孙协程的失败对兄弟协程的影响,又保证了异常不会无限逃逸。
简单总结:SupervisorJob是“直接子协程失败不会连坐”,但异常仍然会向上传递到作用域边界,只是不会取消作用域和兄弟协程。
3.2 SupervisorJob的正确打开方式
很多人在代码里这么写:
val scope = CoroutineScope(Job()) scope.launch(SupervisorJob()) { // ... }这是完全错误的用法。把SupervisorJob传给单个launch,只会让这个launch生成的Job变成SupervisorJob,但它的父Job仍然是普通Job,起不到隔离兄弟协程的作用。正确做法是把SupervisorJob放进scope的上下文中,让整个作用域以它作为父Job:
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)或者直接在代码块内部使用supervisorScope:
suspend fun fetchSomeData() = supervisorScope { val a = async { fetchA() } val b = async { fetchB() } // 如果b失败了,a仍然可以正常完成 // 但你需要各自处理异常,否则异常会从结果中抛出 }supervisorScope是一个挂起函数,它创建了带SupervisorJob的临时作用域,并且等待所有子协程完成后返回。这种写法非常适合在已有协程里临时开启一组相互隔离的任务。
还有一个隐藏用法:CoroutineScope.coroutineContext中默认的Job是普通Job还是SupervisorJob,决定了这个scope的子协程失败是否会波及scope本身。框架提供的viewModelScope内部用的就是SupervisorJob,这也是为什么在ViewModel里多个协程同时启动时,某个协程异常不会把整个ViewModelScope直接取消,而是靠异常处理器兜底。
但注意了,viewModelScope的隔离性并不是无条件的。它确保证引用scope的直接子协程不会相互影响,但如果你在协程内使用coroutineScope、async启动结构性嵌套,则内部的异常传播会恢复成普通Job行为。
在隔离子协程失败后,异常最终落在哪里取决于顶层有没有设置CoroutineExceptionHandler。如果你只写了SupervisorJob,却不配置异常管理策略,那么未捕获的异常会在默认的异常处理器中打印,或者在测试环境下直接抛出,容易造成“看起来没事”但日志被打爆的现象。所以SupervisorJob不是免死金牌,它只是把“集体阵亡”改成“局部失败”,你仍要为每个子任务做好错误处理。
4. 实操实录:从踩坑到选型
理论讲完了,说几个真实项目里改过的问题。之前负责的某跨平台消息推送模块里,需要同时拉取用户配置、消息模板和未读统计三个接口。最初用普通Job的async并发拉取,结果模板接口偶发超时,导致整个拉取流程失败,未读统计和用户配置也一起取消,用户看到的UI直接空白。后来把scope改成SupervisorJob,这个问题立刻减少一大半,但新的坑又出现了:因为异常被隔离,async内部失败后deferred对象在调用await()时才会抛出异常,结果我们只处理了scope里统一异常,没有处理await的异常,依然导致崩溃。这就是典型的“把隔离误当成处理”。
4.1 实际场景:批量拉取列表的后台任务
更常见的场景是批量刷新列表。假设你在一个后台协程里循环处理一批id,用普通Job时,某个id处理失败会取消其它id的处理。这显然不合理。我会用SupervisorJob包住的CoroutineScope或supervisorScope,配合async或launch来隔离。
看下面的例子:
suspend fun refreshBatch(ids: List<String>): List<Result> = supervisorScope { val results = ids.map { id -> async(Dispatchers.IO) { runCatching { remoteApi.load(id) } .getOrElse { Result.Failure(it) } } } results.map { it.await() } }注意这里用了runCatching把每个id内部异常先转成Result,避免异常在await()时再次抛出。这个步骤非常重要。即使有了SupervisorJob的隔离,await()仍然会抛出异常,只是不会取消兄弟协程。为了拿到每个id的结果而不是中断整个函数,必须处理每个任务内部的异常。
如果不用async,而是用launch挨个执行,还需要给每个launch内部也包try/catch,或者在协程上下文里挂一个CoroutineExceptionHandler。我的习惯是:同一层级的任务相互独立时,优先保证每个任务都返回一个结果对象,而不是依赖异常通知。这样即使某个任务失败,后续逻辑也能拿到失败原因并给出部分成功提示。
这种模式最怕的是把SupervisorJob写在一半的位置。比如你写成这样:
val scope = CoroutineScope(GlobalScope.coroutineContext + SupervisorJob())实际上GlobalScope.coroutineContext包含一个独立的Job,你再用+叠加SupervisorJob,新作用域里到底用哪个Job呢?其实作用域里同一个元素只能有一个,叠加后原来的Job被覆盖了,看起来没问题。但如果你直接这么写,很容易让阅读代码的人误解scope的父Job是谁。不如明确一点:
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)更符合直觉。
4.2 错误处理与异常传播的真实教训
我见过最隐蔽的坑,是把SupervisorJob和普通Job混搭在同一个作用域继承链中。假设:
val parent = CoroutineScope(Job()) val child = CoroutineScope(SupervisorJob() + parent.coroutineContext)这个child作用域虽然声明了SupervisorJob,但它同时又继承了parent的普通Job作为父Job。parent取消时child照样取消,这没问题;但child的子协程失败时,如果没有遇到SupervisorJob,异常会传播到child的SupervisorJob并停止,不会继续向parent传播?实际上因为child的parent就是parentJob,一旦child的Job收到异常销毁,会不会向上取消parent?根据SupervisorJob的规则,子Job异常只影响子Job自己,不会取消SupervisorJob,因此不会传给parent。所以这种方式还算安全。但如果你在child里又用launch启动一个普通Job作为直接子协程,那么该子协程的父Job是child的SupervisorJob,异常传播到SupervisorJob后不会取消child。但是子协程本身失败,异常从子协程抛出,由child作用域捕获。如果child没有异常处理器,可能向上冒泡?实际上在CoroutineScope层,如果没设置CoroutineExceptionHandler,未捕获异常会按全局处理器处理,进程可能崩溃。所以在使用SupervisorJob时,一定要安排好“最外层的异常兜底”。
另一个常见的真实问题:Timber里只输出了"job cancelled"的日志,没有任何异常堆栈。后来排查发现,某同事在onDestroy里调用scope.cancel(),但有个子协程内部捕获了所有异常并吞掉,导致取消状态一直不能被确认,协程没真正结束,资源释放没执行。正确的做法是:想优雅停止后台任务时,不要随意supervise,也不要在finally里做耗时操作。finally块里用了挂起函数,会产生额外调度,甚至有StrictMode问题。
4.3 常见问题速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 子协程抛异常导致整个作用域协程全部取消 | 作用域使用普通Job,异常传播连坐 | 改SupervisorJob或supervisorScope隔离 |
| 用了SupervisorJob但异常仍然导致崩溃 | SupervisorJob隔离异常传播,不代表处理异常 | 为子协程分别try/catch,或设置顶层CoroutineExceptionHandler |
| 传给launch一个SupervisorJob,兄弟协程仍然被取消 | SupervisorJob必须放在作用域上下文,不能当作单个协程Job | 在CoroutineScope(SupervisorJob())中启动协程 |
| scope.cancel后,协程不完全停止 | 协程内部代码未检查取消状态,或无限循环没挂起 | 在循环里调用ensureActive() |
| async的await抛出异常会中断整体流程 | 即使SupervisorJob隔离了副作用,await仍会抛异常 | 用runCatching或内部try/catch包住委托逻辑 |
| 孙协程失败影响兄弟协程 | SupervisorJob只隔离直接子协程失败 | 在孙协程层级再包supervisorScope或自己处理异常 |
| join卡住不返回 | 父Job等待子Job完成,但有子Job一直未结束 | 检查子Job是否循环未退出;使用withTimeout或手动取消 |
| viewModelScope中的协程异常被自动忽略 | 实际上有默认处理器,不一定崩溃,但日志易漏 | 显式捕获或在协程体内做业务异常处理 |
这张表不能替代阅读源码,但基本覆盖了我遇到的80%问题。排查时优先画出协程层级图,看异常是在哪一个Job边界断开的,比盯着日志猜要高效得多。
5. 上下文、Job和SupervisorJob的配合套路
把单独的API全部串起来,才能写出不容易炸的协程层。我在项目里通常按照三层来组织:最外层作用域、中间业务协程、底层子任务。每一层选择什么样的Job和上下文,会直接影响整个链路的表现。
5.1 推荐的分层设计模式
第一层是接口层,比如Activity或ViewModel的顶层作用域。在Android平台我会沿用lifecycleScope或viewModelScope,因为它们已经内置了合适的Dispatchers和Job策略。在这些框架作用域内,我再根据是否需要隔离子任务,决定是否用supervisorScope。
第二层是业务并发层。如果一次需要并行请求多个不同的数据源,而它们之间没有强依赖,我会用async+supervisorScope,保证数据源之间互不拖累。如果有一个数据源失败,其他数据依然可以展示,这就是SupervisorJob在实战里的最佳体现。
第三层是底层重试或循环层。比如断线重连、分页拉取、批量写入。这些任务往往需要响应取消,又不能因为单条数据失败导致后续中断。我会在循环里使用try/catch,同时定期调用ensureActive(),让取消操作能及时生效。
一种我比较推荐的写法:
class SomeRepository { suspend fun loadDataBatch(ids: List<String>): List<Data> = supervisorScope { ids.map { id -> async { try { loadFromRemote(id) } catch (cancellationException: CancellationException) { throw cancellationException } catch (throwable: Throwable) { fallbackCache(id) ?: throw throwable } } }.map { it.await() } } }这里没有把每个异常都吞掉,而是对取消异常正常重抛,保证协程取消时不会因为捕获CancellationException而无法终止。对业务异常则做降级处理,拿不到远端数据就回退缓存,还是没有再抛出去。这样做的好处是:在SupervisorJob的隔离下,每个数据源独立成败,调用方总能拿到完整的结果列表,而不会因为某一个数据源失败导致整个列表拉取失败。
这种写法初见有些繁琐,但长期维护成本很低。它能避免两个极端:一是把所有异常都吞掉导致静默失败;二是完全不做局部处理导致连坐取消。
5.2 设计上下文时的几条硬性建议
第一,不要把业务数据放进CoroutineContext。前面已经说过,协程上下文是给协程基础设施用的。如果你想传递traceId、userId、或者用户语言偏好,请使用参数传递,或者在使用协程的场景里用ThreadLocal配合自定义Element,但一定要想清楚线程切换时透传的代价。
第二,除非确实需要,不要给每一个launch都单独指定Job。让作用域用一个统一的Job或SupervisorJob管理,否则协程树会非常混乱,因为你无法从外部统一取消某个模块下所有协程。正确的做法是:
val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) fun startTask() { moduleScope.launch { /* ... */ } }第三,设置CoroutineExceptionHandler时,不要指望它在子协程异常后自动“修复”作用域。它只是一个兜底日志或错误上报入口。业务恢复要靠结构设计,不能靠异常处理器。
第四,使用withContext切换线程时,它会把当前协程的上下文带过去,但Job保持不变。所以你在withContext(Dispatchers.IO)里看到的Job和外部是同一个Job。如果你在withContext块里启动子协程,它也会作为当前Job的子协程。了解这一点有助于排查“为什么线程都切了Job还是取消”这类问题。
5.3 扩展思路:自定义上下文元素
如果团队里有很多协程日志需要追踪,自定义一个Context元素比到处传参要顺手得多。实现方式很简单:继承AbstractCoroutineContextElement,定义SealedKey,然后把要透传的数据放在元素里。
这里不展开全部代码,但要提醒的是:自定义元素是强侵入的,它会沿着上下文继承,影响所有子协程。如果你只想在局部传递,使用完就移除,则需要在withContext块里显式指定一个不含该元素的新上下文,或者在你的元素里覆盖fold和minusKey,实现“自动失效”。很多人忽略这点,导致请求ID泄漏到所有后台协程,日志串号。
协作上下文是协程库中容易被低估的部分,但它决定了协程的归属、取消、异常范围。理解了上下文,你就知道为什么换个Job类型会产生截然不同的行为;理解了Job,你就知道取消和异常是怎么沿着协程树流动的;理解了SupervisorJob,你就能在“集体任务”和“独立子任务”之间自由切换设计方式。
这三点不是孤立知识,而是同一套结构化并发规则的不同侧面。写协程代码时,先回答三个问题:这个协程属于谁?谁负责取消它?失败后谁能感知?把这三个问题想清楚,再选择Job还是SupervisorJob,基本不会出大错。
我自己的体会是,协程问题90%都可以在动手前靠纸笔解决,真正难的不是API,而是主动去画那棵协程树。遇到诡异的取消和异常,先把父子关系画出来,把Job类型标清楚,再对照排查表找原因,会比反复试代码高效得多。