
协程里的Context和Job很多人用了一年还是模模糊糊。知道怎么启动协程也知道怎么让代码跑起来但一旦问到“子协程被取消了父协程怎么办”“async挂起后Job状态是什么”“Dispatchers.Main在子线程里到底做了什么”就开始含糊其辞。这篇文章我不打算给你誊一遍文档。我把协程上下文CoroutineContext和Job这两样东西从设计思路到实际操作连带几个我踩过很久才绕出来的坑一次讲透。如果你已经在项目里写了几个月的协程代码但遇到上面的问题还是要翻源码的话这篇文章就是给你准备的。1. 协程上下文到底是个什么东西1.1 它不是“传参数用的Map”很多人刚开始接触CoroutineContext觉得这不就是个传递参数的大Map吗key对value往里面塞东西取出来用。这么理解方向是对的但会严重低估它的设计精密程度。CoroutineContext的真实身份是协程运行所需的一组上下文元素的持久化集合。它规定了协程运行在哪个线程、叫什么名字、携带什么用户信息、如何响应取消、如何管理父子关系。这几个职责是由不同的Element实现的但它们都被塞进同一个Context里统一管理。每一段协程代码在运行的时候编译器会把当前的Context沿着调用链一路传递。你启动一个协程时传入的Dispatchers.IO会连同父协程的Context拼在一起生成一套完整的“运行环境”。这套环境不仅决定了协程如何启动还会深深影响子协程的行为。fun main() runBlocking { val context Job() Dispatchers.IO CoroutineName(zhangfei) launch(context) { println(coroutineContext[CoroutineName]?.name) println(coroutineContext[Job]?.isActive) } }这里通过加号把三个元素合并成了一个Context。这就是CoroutineContext最冷门也最核心的特性它是一个可以支持“加号合并、减法过滤”的集合结构。1.2 加号合并背后的Element规则为什么Job()和Dispatchers.IO能直接用合并因为CoroutineContext.Element本身有两个语义它既是一个集合元素Element又是一个协程上下文CoroutineContext。元素的最小编码是key。key在Context里是唯一的标识。Dispatcher、Job、CoroutineName、CoroutineExceptionHandler每个对应一个key。当你用合并两个Context时如果遇到相同的key后者的元素会覆盖前者。这一点很关键。很多人愣在这儿val ctx1 Dispatchers.IO CoroutineName(first) val ctx2 ctx1 Dispatchers.Default请问ctx2的Dispatcher是IO还是Default答案是Default。因为合并时Dispatchers.Default覆盖了同key的Dispatchers.IO。听着简单但在复杂代码里这种“隐式覆盖”很容易制造莫名其妙的BUG。尤其是协程库内部的structured concurrency机制会不断对Context做合并和覆盖。// 打印上下文里所有元素 println(coroutineContext.toString()) // 输出: [JobImpl{Active}abc123, CoroutineName(first), Dispatchers.Default]这就是Context的完整面它不是一个哈希表而是一个带覆盖规则的持久化集合。日常开发中普通人拿它传参数资深程序员拿它控制协程生命周期。1.3 你真正需要记住的Context成员CoroutineContext里的Element就那么几个可以列成一张表来记清楚。元素key作用默认值JobJob.Key协程生命周期、父子结构Job() 新建CoroutineDispatcherContinuationInterceptor.Key线程调度策略继承父协程CoroutineNameCoroutineName.Key调试标识名EmptyCoroutineContextCoroutineExceptionHandlerCoroutineExceptionHandler.Key未捕获异常处理器无ThreadContextElement自定义key线程上下文传递如MDC无Dispatchers本质上是CoroutineDispatcher的静态实现它也是Context的成员。但有一个必须强调的点协程上下文的元素不只是给协程库内部用的。协程库允许你实现自己的ThreadContextElement在线程切换时自动携带一些数据。比如日志系统里的traceId就常被塞进这个元素里这样无论协程怎么切线程traceId都能跟着走。这一点在生产环境排错时特别有用后面我在常见问题里再展开说。2. 拆解Job协程的“生命棋”2.1 Job不是线程也不是任务Job在协程体系里就是协程的句柄、身份证和生命线。它不是线程不是任务队列而是协程生命周期状态的载体。协程同一个时刻只对应一个Job。反过来也一样。Job控制协程的启动、取消、结束状态并通过父子层级把取消信号自动传播。常规情况下一个协程里干三件事监控父协程的Job状态处理自己的状态流转管理子协程的Job列表用代码能直接验证这一点fun main() runBlocking { val outerJob coroutineContext[Job] val childJob launch { println(child job: $coroutineContext[Job]) } println(outer job: $outerJob) childJob.join() println(child is completed? ${childJob.isCompleted}) }从这些输出你能看到launch返回的Job和协程内部coroutineContext[Job]拿到的Job是同一个对象。所以通过外部持有的Job句柄你可以随时感知协程是死是活。2.2 Job状态机六种状态全解如果不理解Job的状态机你在排查协程问题时就是带着眼罩抓瞎。Job总共有六个状态New创建但未启动。只有使用CoroutineStart.LAZY时协程初始会停留在这个状态。Active正常执行中。Completing正在完成中等待子协程完成。Cancelling正在取消中等待子协程处理取消信号。Cancelled已取消。Completed已完成。这六种状态对于一个简单的协程不总是可见因为很多协程跑得太快瞬间就完成了。但如果你用带延迟的逻辑或LAZY模式所有状态都能被抓到。fun main() runBlocking { val job launch(start CoroutineStart.LAZY) { delay(1000) } println(刚创建: $job) // New job.start() println(启动后: $job) // Active // 等协程跑到delay期间 delay(100) println(delay期间: $job) // Active job.join() println(完成后: $job) // Completed }这里最关键的状态是Completing。Completing是一个“内部状态”你从外部观察Job时通常只会看到Active或者Completed。但在协程内部Completing表示协程主体已经跑完正在等所有子协程完成。这个阶段是structured concurrency能够成立的基础。2.3 Job的核心方法start、join、cancelJob对外提供三个核心方法start()——启动协程join()——挂起直到协程完成cancel()——请求取消协程join是挂起函数它专门用于等待协程结束。要注意join等待的是完全结束态势包括子协程全部完成。而cancel只是发起取消请求不等协程真正退出。它不挂起直接返回。如果你需要“发了取消请求后等它结束”就得用cancelAndJoin()它等价于调用cancel()然后join()。这种组合在日常业务里用得极多。fun main() runBlocking { val job launch { repeat(100) { println(处理数据 $it) delay(200) } } delay(500) job.cancelAndJoin() println(协程已取消并退出) }注意一个细节cancel()并不保证协程立即停下。协程必须跑到一个挂起点或者主动检查取消状态时才能响应取消。如果协程里面跑的是纯粹耗时CPU计算没有任何suspend调用那cancel只能干瞪眼。这是使用Job最容易被咬手的地方。事实上CPU密集任务配合协程取消还得靠ensureActive()配合。3. 结构化并发虽然看不见但决定你的代码稳不稳3.1 为什么子协程必须跟着父协程走协程库最天才的设计其实是结构化并发。它把“父子协程”绑成了同生共死的整体。我拿实际场景举例子。你打开一个界面一次性发起三个网络请求每个请求都通过launch启动一个子协程。如果用户中途关掉界面你希望这三个请求全部被取消否则就会发生泄漏。结构化并发就能做到这一点当父协程被取消所有子协程也被自动取消。这靠的不是魔法而是Job的父子层级。用launch启动的协程会默认从父协程的Context继承Job。父Job会维护一份子Job的注册表父协程进入Completing态时必须等所有子Job完成而父Job被cancel时会遍历所有子Job一并cancel。fun main() runBlocking(Dispatchers.IO) { val parentJob launch { launch { while (isActive) { println(子协程A运行中) delay(500) } } launch { while (isActive) { println(子协程B运行中) delay(500) } } } delay(1000) parentJob.cancelAndJoin() println(父协程取消后子协程还活着吗) }这代码会打印出父协程取消后子协程全部静默。因为A和B的Job都挂在parentJob下面父协程取消时它们一起被取消。3.2 打破父子绑定SupervisorJob的适用场景有绑定自然也要有解绑。遇到“父协程取消不想带着子协程一起走”的场景就要用SupervisorJob。最经典的场景是界面上有几个相互独立的组件每个组件各自请求数据。其中一个组件请求失败被取消不能影响其他组件继续加载数据。或者是一个并发任务中一个子任务失败退出不应导致其他子任务被连坐取消。fun main() runBlocking { val supervisor SupervisorJob() val parentJob launch(supervisor) { launch { delay(500) throw RuntimeException(子任务A崩了) } launch { repeat(5) { println(子任务B还在跑: $it) delay(200) } } } parentJob.join() }可以看到子任务A抛出异常后B仍然把整个循环跑完了。SupervisorJob就是给每个子Job发一个独立的失败域失败不会被向上传播。不过这里有个大坑如果用SupervisorJob()创建了一个孤立的Job它在异常处理时并不一定符合预期。两个元素SupervisorJob()默认没有绑定到父协程。coroutineScope与supervisorScope之间差异巨大。用supervisorScope { }内部再launch才是标准的Supervisor隔离方式。如果你只是盲目地在launch(supervisorJob Dispatchers.Default)里写代码很可能在用错Job的作用域。3.3 自定义Job时的上下文继承规则启动一个协程协程库会这样组装新协程的Context取出此处的CoroutineScope的coroutineContext。把自己作为新的Job元素加入替换掉Scope中的Job。把新组合的Context传给Coroutine创建函数。这就是为什么你写GlobalScope.launch { }时新协程的Job和Scope的Job没有任何父子关系。GlobalScope本质上是一个空壳不包含任何Job它的生命周期是全局性的不该用于业务代码。某些代码框架里开发人员会手动维护一个顶层的Job比如uiScope CoroutineScope(SupervisorJob() Dispatchers.Main)。这样界面销毁时调用uiScope.cancel()所有挂在这个Scope下的协程都会一同完结。这也是生产环境最标准的用法。4. 实操验证把Context和Job摸个底朝天4.1 通过代码观察父子状态传播理论讲再多都要落到操作。我带着大家做几个小实验用来验证上面每个结论。第一个实验验证父Job的Completing状态。把父协程体写成立即返回但内部挂了一个delay(1000)的子协程fun main() runBlocking { val job launch { launch { delay(1000) } println(父协程体执行完了但还有子协程没结束) } delay(100) println(父Job状态: $job) job.join() println(父Job最终状态: $job) }从输出能看到父Job在子协程还没结束之前状态一直是Active但父协程的代码体已经早跑了。等到子协程delay结束父Job才变更成Completed。这验证了Completing状态的存在从父协程代码体执行完成到子协程全部完成之间是一个特殊窗口期。这个窗口期正好是结构化并发保证的“所有协程工作全部结束”的语义。4.2 实现一个支持响应取消的耗时任务前面说过CPU密集型任务不响应cancel。但是真实业务中耗时任务往往需要被取消。解决办法是定期调用ensureActive()检查和取状态。fun main() runBlocking { val job launch(Dispatchers.Default) { var sum 0L for (i in 1..1_000_000) { coroutineContext.ensureActive() sum i } println(计算结果: $sum) } delay(10) job.cancelAndJoin() println(任务已取消) }ensureActive()是CoroutineScope的扩展函数内部检查coroutineContext[Job]?.isActive如果文档已经取消就抛出CancellationException。这样循环里的代码会在下一个迭代时立刻响应取消。但这个写法也有坑如果你在ensureActive()之后继续做了大量计算取消响应还是会延迟。更好的方案是在每个“计算单元”结束时检查一次而不是在循环开头检查。4.3 协程名字和Job在排查中的组合用法生产环境排查协程问题时光有线程名不够。建议每个协程都带上CoroutineName这样日志里能直接看到是哪个协程在打印。private val uiScope CoroutineScope(SupervisorJob() Dispatchers.Main) fun loadUser() { uiScope.launch(CoroutineName(loadUser)) { println(coroutineContext.toString()) // [CoroutineName(loadUser), StandaloneCoroutine{Active}..., Dispatchers.Main] val result fetchUser() renderUser(result) } }如果配合自定义的ThreadContextElement甚至可以在协程切线程时自动把traceId带入logs。这是最实用的技巧也是CoroutineContext作为“贯通数据”的核心价值。比如实现一个简单的traceId传递器class TraceIdElement( private val traceId: String? ) : ThreadContextElementString? { companion object Key : CoroutineContext.KeyTraceIdElement override val key Key override fun updateThreadContext(context: CoroutineContext): String? { val old currentTraceId.get() currentTraceId.set(traceId) return old } override fun restoreThreadContext(context: CoroutineContext, oldState: String?) { currentTraceId.set(oldState) } }这样协程启动时withContext(TraceIdElement(traceId))内部所有线程切换都会自动携带traceId日志追踪轻松一个数量级。每次我给别人讲到这个他们都惊讶为什么没用过。5. 踩坑合集常见问题与排查速查表5.1 父Job取消了子Job还活着这通常是你绕过了结构化并发有一种常见现象父协程被取消但某个子协程还在跑。这个现象的产生原因基本都是子协程不是从父协程的CoroutineScope里launch出来的而是用了GlobalScope.launch或者自己创建的CoroutineScope(Dispatchers.IO).launch也就是没有挂进父Job下。排查思路很简单。打日志把子协程的coroutineContext[Job]?.parent打印出来看看看看父Job是不是你预期那个。如果不是就要检查子协程启动时是从哪个Scope出来的。5.2 async/await出现异常为什么会导致整个作用域崩掉async的取消行为和launch有细微差别。launch的子协程抛异常时异常会直接导致父协程被取消除非用SupervisorJob。async则不一样它把异常藏在Deferred里只有调用await()时才会抛出。但是注意如果async启动后不等await异常在协程内部已经被视为未处理异常依然会向上报告给父协程。这种情况常发生在“fire and forget”式async启动中。处理办法是要么及时await要么在async块内try-catch要么使用SupervisorJob。代码示例fun testAsyncException() runBlocking { val scope CoroutineScope(SupervisorJob() Dispatchers.Default) val deferred scope.async { throw RuntimeException(boom) } delay(100) try { deferred.await() } catch (e: Exception) { println(捕获异常: ${e.message}) } }用SupervisorJob隔离失败域再配合await捕获异常才能保证一个失败的async不会拖垮整个Scope的其他协程。5.3 cancel后怎么区分协作取消与真正异常这是协程开发中最高频的疑惑点。协程被取消时CancellationException会被抛出它和普通异常完全不是一个含义CancellationException代表的是控制流正常转移不是程序错误。所以协程内部如果要捕获异常必须小心不要吞掉CancellationException否则会让协程的取消机制失效。正确写法是launch { try { doWork() } catch (e: CancellationException) { throw e // 重新抛出保证取消逻辑正确 } catch (e: Exception) { println(业务异常: ${e.message}) } }很多新手爱写catch (e: Exception) {}结果协程被取消时一切正常Job却永远停在Cancelling状态。排查半天最后发现是catch把CancellationException吞了。5.4 Job.join和cancelAndJoin挂起不返回的迷案又一个高频大坑调用join()或者cancelAndJoin()后当前协程一直挂起不返回。这个现象常见于协程体内部有不可取消的挂起或子协程数量一直没归零。最常见原因是子协程里没有正确处理取消它会捕获CancellationException并继续循环等待。比如在一个无限循环里写了launch { while (true) { try { delay(100) } catch (e: CancellationException) { // 吞掉异常后继续循环 } } }这样父协程调用cancelAndJoin()父Job虽然被标记为Cancelling但子Job永远无法完成所以join永远不会返回。解决方法是catch到CancellationException后立刻break或throw。5.5 一张速查表应对绝大多数Job问题现象原因解决方案子协程不随父取消子协程用了GlobalScope/独立Scope使用父Scope启动不要自己new Scope一个asyn – 整个 Scope崩async内部异常未处理SupervisorJob try/catchcancel没有停止CPU任务没有挂起点没有检查isActive循环内调用ensureActive()join一直不返回有子协程没有正确响应取消检查子协程是否吞了CancellationException异常在父协程消失子协程异常未处理使用CoroutineExceptionHandler或try/catch6. 进一步讨论从Job出发理解协程设计哲学写到这里其实已经能看到协程和线程是两个时代的设计思路了。线程是抢占式调度资源和生命周期都交给了操作系统开发者只能通过Thread对象做有限的控制。协程是协作式调度生命周期完全由代码自己管理Context和Job就是管理人手中的两件工具。Job定义了生命周期的协作契约父与子必须同生共死除非明确表示隔离。Context定义了运行环境跑在哪个线程、协程叫什么名、如何传递数据、如何被调试。这两者凑在一起让协程成为了一种极少数表达能力强的并发原语。从设计哲学往回看很多使用时的困惑都迎刃而解。为什么Google官方强调不要用GlobalScope因为它跳过了结构化并发的生命周期约束让协程变成了无根的野草。为什么要区分coroutineScope和supervisorScope因为并发的失败传播语义不一样业务上必须能区分“一个失败全体失败”和“独立失败”两种模式。为什么协程库为CoroutineContext实现了运算符因为它想让你像拼接数据一样组合运行环境而不是被一堆复杂的配置类把脑子搞乱。这些看似细节的设计背后全是为了让并发代码可控、可推断。7. 个人的几点实战补充用了这么多年协程最后补充几个只有实际写生产代码才会明白的经验。第一不要在Dispatchers.Main里做任何重量级操作哪怕你已经用了launch。Main线程的职责是刷新UI任何一个阻塞几十毫秒的操作都可能引起掉帧或者ANR。需要CPU密集计算或者网络请求时一定要切走。第二withContext和launch不要混着用。withContext是挂起函数它会等待内部协程完成后接着往下执行。它本身不会创建新的协程而是复用当前协程的Job。这种语义在“异步函数内部切线程”时非常可靠我不建议为了图省事用launch join来模拟。第三CoroutineExceptionHandler只能处理launch启动的协程异常处理不了async的异常。因为async的异常通过await传播而不是通过handler。所以很多人在顶层设置的全局handler根本没有收到async里面的异常。我在生产环境排查一个由代码分析的异常时发现某个异步模块用async发起请求但没人await它的结果在使用SupervisorJob的情况下异常迟迟不被处理最后到了全局handler才被发现。这种代码一旦上线异常信息已经失掉了请求上下文排查难度直接翻倍。后来我们强制团队要求只要用了async要么同步await要么用launch严格隔离。第四学会查看协程的调试Dump。开发环境下给运行参数加上-Dkotlinx.coroutines.debugon协程的线程名会带上协程名称和所在Job编号易读性直接上升一个等级。如果你配了CoroutineName排查问题时能直接定位到是哪个请求链路里的协程崩了。这个参数对日常开发调试帮助巨大非常建议加到默认测试配置里。第五关于自定义ThreadContextElement如果你没做日志排查系统不需要它一旦你有全链路日志系统这个东西能让你的脏活累活减少一大半。比如MDC的traceId传递用ThreadContextElement在协程切线程时自动带上比在每一个函数里手动传参要优雅太多。我个人的体会是协程这门技术看教程只能看懂个框架真正理解透还是得靠写、靠跑、靠翻源码。有段时间我为了搞明白Job的Completing和Cancelling的分支流转把源码从suspendCancellableCoroutine一路追到JobSupport的内部状态机跑了几十次带日志的小Demo才把六种状态和父子传播关系彻底焊死在脑子里。现在再看到项目里协程相关的Bug基本一眼就能判断问题出在哪一层。这种能力就是靠理论加反复实操堆出来的。