Kotlin 结构化并发的本质是构建协程间的「层级归属关系」,通过父子作用域的生命周期绑定与异常双向传递,实现并发任务的协同联动与自动收敛。最终将异步代码的控制流变得像同步代码一样清晰可预测。结构化并发在代码层面表现为三个具体的行为:
| 特性 | 描述 |
|---|---|
| 生命周期绑定 | 只要作用域结束(如 viewModelScope),内部所有协程立即停止。 |
| 等待子项完成 | 父协程的执行流会挂起,直到内部所有子任务(即使是并行的)都结束。 |
| 异常传播机制 | 异常会沿着树状结构双向传播,确保局部失败能被全局感知或统一处理。 |
✦ 通俗理解:Kotlin 结构化并发的本质是为异步任务建立了一套“责任制”。 它通过将子协程的生命周期强绑定在父作用域中,把原本散乱的异步逻辑约束为一棵有序的“家族树”。这确保了任务既能像同步代码一样精准收敛(无遗漏、无泄露),又能实现异常的自动感知与协同联动。它真正解决的问题,是让异步开发从“放任自流”回归到了“边界可控”。
1、一个协程对象
在 Kotlin 协程的学习路径中,结构化并发(Structured Concurrency) 是最核心也最难理解的内容。许多人在处理父子协程、异常传播和生命周期管理时感到困惑,其根本原因在于:我们没有在技术层面上明确,到底“什么是协程对象”?
本文将从 Java 线程模型类比出发,带你拆解协程中 Job 与 CoroutineScope 的职责,揭秘结构化并发的底层逻辑。
1.1 线程模型
在 Java 中,当我们谈论“一个线程”时,我们指代的是 Thread 类的实例。然而,Thread 实例并非线程本身,而是内核线程在 JVM 层面的抽象代理。它之所以被等同于线程,是因为它封装了对底层原生线程的生命周期管理、状态调度及交互控制。
- 状态查看:
thread.name、thread.isAlive。 - 交互控制:
thread.start()、thread.interrupt()、thread.stop()。
结论:在线程模型里,线程对象与线程执行实体是一一对应的,我们只要持有线程对象就可以去操作那个看不见、摸不着的线程。
那么,我们是不是有理由认为协程启动后返回的那个实体就是 “协程对象”?因为它可以去操作协程。比如,返回的 Job 对象支持以下操作:
- 父子协程:
job.parent、job.children。 - 状态查看:
job.isActive、job.isCancelled、job.isCompleted。 - 交互控制:
job.start()、job.cancel()、job.join()、job.cancelChildren()。
1.2 协程模型
在 Kotlin 协程中,调用 launch 会立即启动协程并返回一个 Job 对象,这不禁让人联想到线程(Thread)的构造方式:为何协程的 “句柄” 通常通过执行结果返回,而非像线程那样先确立 “身份” 再执行?事实上,协程也支持先创建 Job 对象再启动的模式:只需传入 CoroutineStart.LAZY 参数,launch 即会返回一个处于 “待启动” 状态的 Job,从而实现句柄与身份的提前确立。
1 | val scope = CoroutineScope(EmptyCoroutineContext) |
这一机制的背后原因在于 launch 函数内部的分支逻辑:根据传入的 start 参数,它会选择不同的执行路径。当检测到 LAZY 模式时,函数会跳过立即调度的步骤,直接返回一个尚未激活的 Job 实例,将执行权的控制交还给调用者。相关源码位置如下:
1 | /*** kotlinx.coroutines/Builders.common.kt ***/ |
源码分析表明,当检测到 LAZY 模式时,逻辑会进入 LazyStandaloneCoroutine 分支。从实现上看,LazyStandaloneCoroutine 继承自 StandaloneCoroutine,其核心差异在于构造时的激活状态。普通的 StandaloneCoroutine 在构造时若将 active 参数设为 true,会立即触发状态机的启动流程;而 LazyStandaloneCoroutine 通过将 active 设为 false,使协程初始处于 “未激活” 状态,从而跳过了即时调度,直到外部显式调用启动方法。
1 | /*** kotlinx.coroutines/Builders.common.kt ***/ |
另外,LazyStandaloneCoroutine 内部重写了父类的 onStart() 方法。它并没有像普通协程那样直接执行任务,而是准备好了 “启动的逻辑”,等待被调用。当用户后续调用 job.start() 时,它才会通过状态机转换,调用底层的 continuation.startCoroutineCancellable(this) 来真正触发协程的执行。
1 | /*** kotlinx.coroutines/Builders.common.kt ***/ |
综上所述,无论是从 Job 的创建时机还是其对协程生命周期的控制能力来看,Job 都可以被视为协程在用户空间中的抽象代理。 它不仅是协程状态的观测者,更是协程行为的控制手柄。简单的理解,一个 Job 实例就可以被当作一个协程对象。
你可能会问:既然 Job 已经可以被当成协程对象来管理⽣命周期了,为什么启动新协程(如在 launch 内部再次调用 launch)还需要通过 CoroutineScope?原因在于,Job 仅代表单一任务的身份与状态,而 CoroutineScope 则提供了创建新任务所需的环境与工厂能力。没有作用域,协程便失去了滋生的 “土壤”。一句话概括,CoroutineScope 是协程的 “上下⽂容器”,是协程树的根节点,是更顶层的管理者,它不仅拥有协程管理能⼒,还携带了协程运⾏所需的环境信息(如调度器 Dispatchers.IO)。
在深入探究 CoroutineScope 与 Job 的代码实现之前,我们先厘清它们在类型系统中的层次关系。从具体的协程实例到顶层的抽象契约,其关系链可梳理如下:
✦ 继承链:
LazyStandaloneCoroutine▸StandaloneCoroutine▸AbstractCoroutine▸Job | CoroutineScope
此处 AbstractCoroutine 同时实现了 Job 接口并充当了 CoroutineScope,构成了协程实体的核心骨架。相关源码位置如下:
1 | /*** kotlinx.coroutines/AbstractCoroutine.kt ***/ |
我们重新回到 launch 源码,你会发现其内部创建的 coroutine 对象对外当成了 Job 进行返回,对内当成了 CoroutineScope 进行了接收,也就是说,调用 launch { } 返回的 Job 和大括号内隐式的 CoroutineScope 本质上是同一个对象。
重新审视 launch 的源码,我们会发现其中的精妙设计:内部创建的协程实例 coroutine,在对外返回时被向上转型为 Job,而在内部执行协程体(block)时,则被用作 CoroutineScope 的接收者(Receiver)。这意味着,launch 调用所返回的 Job 对象,与协程体 {} 内部隐含的 this: CoroutineScope,在本质上是同一个实例。进一步证明了 CoroutineScope 也是一个协程对象。
1 | /*** kotlinx.coroutines/Builders.common.kt ***/ |
1.3 职责隔离
从上述的分析中我们知道了 launch 的返回值和接收者是同一个实例的事实。既然是同⼀个对象,为什么要设计两个接⼝,对外暴露 Job,对内提供 CoroutineScope 呢?其目的在于职责隔离。
- 对外(返回值)暴露 Job:只允许外部调⽤者进⾏流程控制(启动、等待、取消),⽽不希望外部随意在我的作⽤域内启动⼦任务。
- 对内(代码块)提供 CoroutineScope:允许开发者在协程内部利⽤已有的上下⽂继续开辟⼦协程,实现结构化并发。
1.4 综合结论
要真正掌握结构化并发,我们需要在不同场景下灵活切换视⻆:
- 视角一:将
launch/async返回的Job/Deferred对象视为协程对象,适用于管理生命周期和父子关系。 - 视角二:将协程体内的
CoroutineScope(即this)视为协程对象,因其拥有完整上下文和启动协程能力。 - 视角三:将
launch { }的大括号块本身看作一个“协程”,内部嵌套的launch { }即为子协程。此视角无技术实现意义,但在流程讨论中极为直观便捷。
⼀⾔以蔽之:协程对象是管理独⽴业务流的实体。在技术实现上,它是 Job 和 CoroutineScope 的结合体。 明确了这⼀点,你就能理解为什么⽗协程取消时⼦协程会集体“陪葬”,也能理解异常是如何在这⼀棵树上蔓延的。
2、协程父子关系
在 Kotlin 协程中,父子协程关系的建立并不取决于代码的嵌套结构,而是取决于启动协程时所使用的 CoroutineScope 中的 Job 对象。
2.1 核心纽带 Job 对象
在技术层面,每一个协程都对应一个 Job 对象。父子关系的本质就是 Job 树的构建:
- Job.children:父协程维护的一个集合,包含所有子协程的 Job。
- Job.parent:子协程持有的引用,指向其父协程的 Job。
当一个子协程启动时,它会将自己的 Job 注册到父协程的 children 中,从而实现双向绑定。
2.2 双向绑定源码分析
从源码角度分析,子协程与父协程 Job 的 “双向绑定” 主要发生在协程启动的初始化阶段。这一过程的核心在于 Job 接口的默认实现类 JobSupport。当我们调用 launch 或 async 时,底层会创建一个新的协程对象(如 StandaloneCoroutine),其内部包含了一个新的 Job 实例。
入口:
AbstractCoroutine的初始化所有的协程类最终都会继承自
AbstractCoroutine。在其构造函数中,会调用一个关键方法initParentJob()。1
2
3
4
5
6
7
8
9
10
11
12
13/*** kotlinx.coroutines/AbstractCoroutine.kt ***/
public abstract class AbstractCoroutine<in T>(
parentContext: CoroutineContext,
initParentJob: Boolean,
active: Boolean
) : JobSupport(active), Job, Continuation<T>, CoroutineScope {
init {
// 1. 传入父 Job,初始化父子关系
if (initParentJob) initParentJob(parentContext[Job])
}
}核心:
initParentJob的绑定逻辑initParentJob方法定义在JobSupport中。它负责将当前(子)Job 与上下文中的(父)Job 挂钩。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20/*** kotlinx.coroutines/JobSupport.kt ***/
protected fun initParentJob(parent: Job?) {
assert { parentHandle == null }
if (parent == null) {
parentHandle = NonDisposableHandle
return
}
parent.start() // 确保父 Job 是启动状态
// 2. 建立双向绑定的核心:attachChild
val handle = parent.attachChild(this)
parentHandle = handle
// 检查状态:如果子 Job 已经结束,则解绑
if (isCompleted) {
handle.dispose()
parentHandle = NonDisposableHandle
}
}双向绑定的实现细节
第一步:子向父注册(父持有子的引用)
在
parent.attachChild(this)内部,父 Job 会将子 Job 包装成一个ChildHandleNode,并存入其内部的队列中。1
2
3
4
5
6
7
8/*** kotlinx.coroutines/JobSupport.kt ***/
public final override fun attachChild(child: ChildJob): ChildHandle {
return invokeOnCompletion(
onCancelling = true,
handler = ChildHandleNode(child) // 将子 Job 封装进 Node
) as ChildHandle
}- 结果:父 Job 的
children序列(由handler链表组成)现在包含了这个子 Job。
第二步:父向子反馈(子持有父的句柄)
attachChild返回一个ChildHandle对象。子协程将其保存在自己的parentHandle成员变量中。- 结果:子 Job 拥有了父 Job 的引用。当子 Job 完成或取消时,它可以通过这个
parentHandle通知父 Job。
- 结果:父 Job 的
为什么要“双向”?
这种双向绑定机制是为了支持结构化并发的两大特性:
- 自上而下的取消传播:当父 Job 被取消时,它会遍历其
children队列,调用每个子 Job 的cancel。 - 自下而上的状态通知(结构化结束):当子 Job 进入完成状态时,它会通过
parentHandle调用父 Job 的方法。父 Job 会检查是否还有其他未完成的children。只有当所有子节点都报告“我做完了”,父 Job 才会真正进入Completed状态。
- 自上而下的取消传播:当父 Job 被取消时,它会遍历其
最后,父子协程双向绑定源码分析总结如下:
- 子 Job 启动:调用
initParentJob。 - 注册行为:通过
parent.attachChild(child)将自己挂载到父 Job 的链表。 - 句柄持有:子 Job 持有返回的
ChildHandle以便后续通信。
2.3 父子关系建立方式
A. 隐式确立(最常见)
当你通过 CoroutineScope.launch 或 async 启动协程,且没有手动更换 Context 时,父子关系会自动建立。
1 | val scope = CoroutineScope(EmptyCoroutineContext) |
原理:launch 是 CoroutineScope 的扩展函数。它会从接收者(Receiver)的 coroutineContext 中提取 Job,并将其作为新协程的父节点。
B. 显式确立(通过 Job 参数)
你可以在启动协程时,通过 CoroutineContext 手动指定父级:
1 | val scope = CoroutineScope(EmptyCoroutineContext) |
2.4 父子关系的“断裂”
很多初学者会疑惑:明明代码写在嵌套里,为什么父子关系没了?这通常是因为 「创建了新的独立作用域」 或 「内外使用了相同的作用域」。
1 | val scope = CoroutineScope(EmptyCoroutineContext) |
原因:CoroutineScope() 构造函数会创建一个全新的 Job。这个新 Job 与外部协程的 Job 之间没有逻辑联系,导致结构化并发失效。
如果在 launch 的代码块中,使用外部定义的 scope 对象(而不是 this)来启动协程,内部协程获取的是外部 scope 中的 Job,而不是当前协程的 Job。此时,两个协程由同一个父 Job 管理,形成兄弟关系。
1 | runBlocking { |
1 | outerJob.isActive = true |
2.5 结构化结束
确立父子协程关系是实现结构化并发(涵盖取消、异常处理及生命周期管理)的基础。其中,结构化结束机制确保了父协程的 Job 必须等待其所有子协程完成(Complete)后才能真正结束,即便父协程的业务代码已先一步执行完毕,也必须阻塞等待子任务的终结。然而,从执行视角来看,协程间的调度本质上是并行的,无论是处于同一层级的兄弟协程,还是具有父子关系的上下级协程,它们在底层线程上的运行都是并发进行的。下面通过一个例子来了解结构化结束:
1 | runBlocking { |
结构化结束的工程价值正在于这种 「并行执行、顺序等待」 的机制。例如在进行多任务初始化时,既希望各个初始化任务能并发执行以提升效率,又要求主线程能等待所有初始化完成后再继续。此时可以:
1 | runBlocking { |
3、结束线程
在协程大行其道的今天,很多人认为线程已经过时了。但事实上,线程作为操作系统调度的基本单位,其底层原理依然是我们构建高并发应用的基石。理解线程的生命周期管理,特别是如何安全地结束一个线程,不仅关乎程序的健壮性,也是理解协程取消机制的敲门砖。结束线程的逻辑主要分为两大类:强行结束与协作式取消。
- 强行结束(不推荐):调用
Thread.stop()方法。这就像在演出中途直接切断电源,虽然简单粗暴,但后果往往难以预料。 - 交互式取消(推荐):使用
Thread.interrupt()方法。这是一种礼貌的请求,它通知线程「外部希望你停止工作」,至于何时停止、如何清理现场,则由线程自身决定。
3.1 核心原理
强行终止线程(stop())是非常危险的。为了解决这个问题,Java 引入了 “交互式取消”(Interruption)机制。这种机制的核心思想是:“我不强迫你停止,但我发信号建议你停止,由你(子线程)自己决定在安全的时候结束。”
其核心原理是标志位模式,每个线程内部都有一个布尔类型的中断标志位(Interrupt Status)。Thread.interrupt() 的本质就是将这个标志位设置为 true。它涉及到三个核心方法:
thread.interrupt():给线程thread发送中断信号(将标志位置为true)。thread.isInterrupted():查询线程thread是否被中断(查看标志位)。Thread.interrupted():静态方法。查询当前线程是否中断,并清除中断标志位(将标志位重置为false)。
线程在接到「中断信号」时,通常处于两种状态之一:「运行中」 或 「阻塞中」。
3.1 运行中场景
如果线程正在执行复杂的计算逻辑,它不会自动停止。你必须在代码中通过 isInterrupted 手动检查中断状态,执行清理工作并决定线程退出的时机。
1 | fun main() = runBlocking { |
3.2 阻塞中场景
如果线程正在调用 sleep()、wait() 或 join() 等阻塞方法,它们会持续监测中断标志位。一旦发现标志位变为 true,这些方法会立即:
- 被唤醒(通过 JVM 内部信号)。
- 清除中断标志位(重置为 false)。
- 抛出
InterruptedException。
1 | fun main() = runBlocking { |
1 | Thread: 开始运行 |
另外,上面提到在抛出异常前会清除中断标志位。为什么 InterruptedException 会清除标志位? 这是 Java 设计中最精妙也最容易让人困惑的地方。JVM 强制清除标志位是为了将控制权完全交给开发者。
如果你捕获了异常,标志位却还是 true,那么后续调用的任何阻塞方法(如另一个 sleep)都会立即再次抛出异常,导致你无法在 catch 块中执行任何清理逻辑(比如发送告警请求或写入日志)。
1 | fun main() = runBlocking { |
捕获到 InterruptedException 后,最糟糕的做法是只打印一个堆栈信息(e.printStackTrace())就完事了。正确姿势是,如果你在 catch 里不打算立即结束线程,你应该调用 interrupt() 方法恢复中断状态,让调用栈更上层的逻辑知道发生了中断。
1 | fun main() = runBlocking { |
4、协程的取消
Kotlin 协程的取消是一个 「协作式」 的过程。它并不是暴力地直接杀掉正在运行的逻辑,而是通过改变状态、抛出特定异常以及开发者主动配合来实现的。其 API 的设计以及底层原理与线程有相似之处。
| 特性 | Java 线程 (Thread) | Kotlin 协程 (Job) |
|---|---|---|
| 触发函数 | interrupt() |
cancel() |
| 状态标识 | isInterrupted() |
isActive 或 job.isActive |
| 底层原理 | 设置中断标志位,由线程主动检查 | 修改 Job 状态,由协程主动检查 |
4.1 协程的取消流程
协程的取消大致可以分为四个阶段:发起阶段、检查阶段、处理阶段、结束阶段。以下是 Kotlin 协程取消的详细执行流程:
发起阶段:调用
Job.cancel()取消流程通常始于对
Job对象的cancel()函数调用。- 状态转换:一旦调用
cancel(),该 Job 的isActive属性会立即变为false,而isCancelled变为true。 - 递归传播:在结构化并发中,父协程取消会自动取消其所有子协程。取消信号会沿着 Job 树向下传播。
- 状态转换:一旦调用
检查阶段:交互式响应
协程并不会在
isActive变掉的一瞬间停下工作,它必须在代码运行到某个 “检查点” 时才能感知到自己被取消了。( 1 ) 挂起函数自动检查
大多数官方挂起函数(如
delay()、yield()、withContext())在执行前或挂起期间都会检查当前Job的状态。- 如果发现 Job 已被取消,挂起函数会立即抛出
CancellationException。
( 2 ) 手动检查计算密集型任务
如果协程内部是纯计算逻辑(没有调用挂起函数),它将无法感知取消。开发者需要手动插入检查逻辑:
- 方式 A:循环中使用
while (isActive)。 - 方式 B:在关键步骤调用
ensureActive(),该函数在取消时会直接抛出异常。
- 如果发现 Job 已被取消,挂起函数会立即抛出
处理阶段:异常冒泡与清理
当
CancellationException被抛出后,流程进入资源清理阶段:- 异常捕获:
CancellationException是一类特殊的异常。它不会导致程序崩溃,也不会被父协程当作“错误”处理,它仅仅被视为协程结束的信号。 - finally 块执行:这是清理资源的最佳位置(例如关闭文件流、数据库连接)。
1
2
3
4
5
6try {
// 业务逻辑
} finally {
// 无论是否取消都会执行
// 注意:如果在 finally 里还需要调用挂起函数,需使用 withContext(NonCancellable)
}- 异常捕获:
结束阶段:状态标记
当所有的清理代码执行完毕,且所有子协程都已停止后:
- 该协程的
isCompleted变为true。 - 父协程如果是在等待(如
join()),则会被唤醒继续执行。
- 该协程的
| 流程顺序 | 流程节点 | 响应动作 |
|---|---|---|
| 1 | 外部动作 | 调用 job.cancel()。 |
| 2 | 内部状态 | isActive -> false。 |
| 3 | 触发点 | 执行到 delay() 等挂起函数,或者开发者手动执行 ensureActive()。 |
| 4 | 抛出异常 | 产生 CancellationException。 |
| 5 | 资源回收 | 执行 finally 代码块。 |
| 6 | 彻底终止 | 协程进入 Completed 状态。 |
4.2 协作式取消
从 「协程的取消流程」 可知,协程的取消是协作式的。仅仅在外部调用 job.cancel() 并不能强制终止协程,必须在其内部进行主动响应。这与 Java 线程的 interrupt() 机制非常相似:如果线程内部不检查中断状态,它将无视中断信号继续运行。
以下代码演示了这种“取消失败”的场景。尽管我们在协程启动 1 秒后执行了取消操作,但由于循环内部没有检测取消状态的逻辑,该协程会坚持运行直到循环自然结束:
1 | fun main() = runBlocking { |
1 | 100000000 |
所以,我们在每次循环之前增加了协程活跃状态的检查逻辑来响应协程的取消。因为只要调用了 job.cancel(),该 Job 的 isActive 属性会立即变为 false,在下一次循环开始时,程序检测到状态变化,这时我们调用 return@launch 结束代码块的运行,以达到结束协程的目的。
1 | fun main() = runBlocking { |
虽然 return@launch 可以退出协程,但正确的做法是抛出 CancellationException 异常。为什么抛出异常更好?因为符合“结构化并发”的设计理念,协程的取消不仅仅是一个循环的终止,它往往涉及到一个层级树。
- 如果你使用
return@launch,协程框架认为这个协程是 “正常完成”。 - 如果你抛出
CancellationException,协程框架会识别出这是 “被取消了”。这对于父协程监控子协程的状态、或者在SupervisorJob环境下管理任务至关重要。
1 | fun main() = runBlocking { |
实际上,Kotlin 提供了一个便捷的扩展函数 ensureActive(),用于检查协程是否仍处于活跃状态,若已被取消,则自动抛出 CancellationException。源码如下:
1 | /*** kotlinx.coroutines/Job.kt ***/ |
所以,如果不需要执行清理逻辑,只是退出协程,可以直接使用 ensureActive()。
1 | fun main() = runBlocking { |
4.3 挂起函数的取消
在 Kotlin 协程中,delay()、withContext()、await() 等官方提供的挂起函数,其核心特性之一就是 “取消感应”。比如下面的代码,我们没有在协程内部检查状态,也没有显式的响应逻辑,但仍然可以在调用了 job.cancel() 后将协程取消。
1 | fun main() = runBlocking { |
这些等待性质的挂起函数对取消的响应可以概括为:在挂起点检查状态,并以抛出 CancellationException 的方式终止执行。当一个协程在执行到 delay() 时,它并不仅仅是“睡一会”,而是经历以下流程:
- 进入前检查:在正式挂起前,
delay会先检查当前协程的Job是否已经处于Cancelled状态。如果是,则不进入等待,直接抛出CancellationException。 - 挂起中监听:如果进入了挂起状态,
delay会将自己注册到Job的取消监听器中。一旦外部调用了job.cancel(),delay会被立即唤醒。 - 退出时抛异常:被唤醒后,它不会继续执行后续代码,而是立即抛出
CancellationException退出。
基于这一机制,我们可以利用 try-catch 或 try-finally 来捕获该异常,从而在协程退出前完成必要的资源清理。需要注意的是,在捕获 CancellationException 并处理完自定义逻辑后,建议将其重新抛出,以确保结构化并发能够正确传播取消信号。
1 | fun main() = runBlocking { |
等待属性的挂起函数为什么要这样设计呢?相比于你在循环中手动写 if (!isActive),delay 等函数的自动响应有以下优势:
- 及时释放资源:
delay不会阻塞线程。当它感应到取消时,它会释放底层持有的计时器资源,并允许协程框架回收该协程占用的内存,而不需要等到“睡眠时间”结束。 - 默认的协作性:由于官方库中几乎所有的挂起函数都是可取消的,这意味着只要你的代码逻辑中频繁调用了挂起函数(例如循环读取数据、请求网络),你的协程就天然具备了良好的取消响应能力,无需额外编写检查逻辑。
有时候会有一些特殊情况,我们希望即便协程被取消,某些代码(如清理逻辑)也必须执行完。此时,delay 等函数会失效。
- 在
finally块中:如果协程被取消,finally块中的delay会因为检测到取消状态而直接抛出异常,导致后续清理代码无法执行。 - 解决方案:使用
withContext(NonCancellable)。
1 | fun main() = runBlocking { |
4.4 结构化取消
在 Kotlin 协程中,结构化取消的流程遵循一套严密的 「双向通信」 机制。我们可以将其拆解为:信号向下传播(取消命令)和异常向上响应(确认与清理)。结构化取消流程包含两种情况:「标准流程的取消」 和 「异常触发的取消」 。
4.4.1 标准流程的取消
当父协程调用 cancel() 时,系统会启动以下内部流程:
父级状态变更:父协程的
Job进入 Cancelling(取消中)状态。信号向下传播:父级立即遍历所有活跃的子协程,并逐一调用它们的
cancel()。子级响应:
- 正在挂起的子协程(如在
delay处)会立即抛出CancellationException。 - 正在计算的子协程需要在下一个协作点(如调用
yield()或检查isActive)感知并退出。
- 正在挂起的子协程(如在
资源清理:子协程执行
finally块中的清理逻辑。父级确认:父协程会挂起等待,直到所有子协程彻底进入 Completed(完成)状态后,父协程才正式宣告 Cancelled。
4.4.2 异常触发的取消
如果不是手动调用 cancel(),而是某个子协程发生了非取消类异常,流程会有所不同:
子级失败:子协程 A 抛出异常。
向上汇报:子协程 A 将异常传递给父协程。
父级响应:父协程被该异常“击倒”,进入取消状态。
横向清场:父协程立即取消所有其他正在运行的兄弟协程(子协程 B、C 等)。
异常冒泡:在所有子级清理完毕后,父级继续向上传递异常。
注意: 使用
SupervisorJob或supervisorScope时,步骤 3 和 4 会被切断,即子级的失败不会波及父级和其他兄弟。
4.4.3 关键状态转换表
在结构化取消过程中,Job 的状态变化如下:
| 阶段 | isActive |
isCancelled |
isCompleted |
说明 |
|---|---|---|---|---|
| 正常运行 | true |
false |
false |
协程正在执行。 |
| 收到取消信号 | false |
true |
false |
正在等待子协程清理。 |
| 清理完成 | false |
true |
true |
彻底结束,资源已释放。 |
4.4.4 观察父子取消顺序
通过这段代码可以清晰观察到 「父级等待子级清理」 的结构化特征:
1 | fun main() = runBlocking { |
1 | Main : 流程开始 |
4.5 不可取消的
在 Kotlin 协程中,NonCancellable 是一个特殊的 Job,它唯一的使命是:让协程在被取消后,依然能够完成必须执行的挂起操作。 它是结构化并发中的一个「逃生舱」,专门处理那些不能被中途打断的收尾逻辑。
4.5.1 NonCancellable 的作用
在正常情况下,一旦协程被取消,所有的挂起函数(如 delay, withContext, await)都会立即抛出 CancellationException。
问题来了: 如果你的清理逻辑(finally 块)中包含这些挂起函数,它们会直接失效,导致你的清理工作只做了一半。 我们还是以「结构化取消」中的代码为例,在 finally 块中增加挂起函数 delay 来拖延清理工作:
1 | fun main() = runBlocking { |
1 | Main : 流程开始 |
运行代码,你会发现 「Child : 清理完毕」 并没有被打印。为什么 println 没执行?因为当父协程取消时,子协程已经处于 「Cancelling」 状态。在 finally 块中,如果你调用了像 delay 这样的挂起函数,它会:
- 检查当前的 Job 状态。
- 发现 Job 已经被取消。
- 再次抛出
CancellationException。
于是,delay 之后的 println("Child : 清理完毕") 就被中断了。
正确的解决方案:为了确保清理逻辑(包含挂起函数)能完整执行,必须将清理代码包装在 NonCancellable 上下文中。这会告诉协程:「即使我已经取消了,也请允许我完成这段最后的挂起操作」。修正后的代码:
1 | fun main() = runBlocking { |
1 | Main : 流程开始 |
4.5.2 NonCancellable 的本质
你可能会好奇,为什么在已取消的协程中使用 NonCancellable 包裹清理代码,原本会抛出异常的 delay 就能正常运行了?其实 NonCancellable 的原理并不神秘。我们可以做一个实验:将 NonCancellable 替换为一个普通的 Job(),你会发现两者的运行效果是一致的:
1 | withContext(Job()) { |
有没有种似曾相识的感觉?我们在「父子关系建立方式」章节的「显式确立(通过 Job 参数)」中,通过传入自定义 Job 的方式让隐式启动的子协程 childJob 脱离了 parentJob 的管理。实际上,不管是 withContext(Job()) 还是 withContext(NonCancellable) 本质上都是创建了一个全新的协程上下文。由于传入了新的 Job,它切断了与父协程之间的取消传播链路。
如果查看源码,你会发现 NonCancellable 是一个实现了 Job 接口的 object(单例)。
- 无父无子:它的
parent永远返回null,children永远返回emptySequence。 - 永远处于活跃状态:它的
isActive永远返回true,isCompleted永远返回false。 - 不接受取消信号:即便你尝试调用它的
cancel(),它也毫无反应。
1 | /*** kotlinx.coroutines/NonCancellable.kt ***/ |
既然 NonCancellable 可以切断与父协程之间的取消传播链路,为什么还要通过 withContext 来实现呢?因为,可以通过 withContext 实现上下文的临时「掉包」,进而欺骗后续的挂起函数。协程的每一个挂起函数(如 delay)在执行前,都会通过 coroutineContext[Job] 检查当前 Job 的状态。当你使用 withContext(NonCancellable) 时,底层发生了以下流程:
保存旧 Job:记录当前那个已经被取消(
isCancelled = true)的父 Job。替换新 Job:将协程上下文中的
Job元素临时替换为NonCancellable这个单例。绕过检查:后续的挂起函数(如
delay)去上下文中找 Job 时,找到的是NonCancellable。因为NonCancellable.isActive总是true,挂起函数便认为环境安全,正常执行。恢复现场:代码块执行完毕后,
withContext会将原本已取消的旧 Job 还原回去。
如果你从源码级视角看 withContext 的逻辑,它其实是在做 Context 合并:
1 | // 伪代码表示本质 |
当 delay 执行时:
1 | public suspend fun delay(timeMillis: Long) { |
同样,如果我们也需要在 finally 块中执行不可被取消的耗时收尾工作,我们也可以通过 Context 合并的方式来定义一个挂起函数:
1 | suspend fun cleanTask() = withContext(Dispatchers.IO + NonCancellable) { |
虽然 NonCancellable 暂时屏蔽了取消信号,但它并不会破坏「结构化并发」,依然保持了父子等待关系:
- 父级依然在等待:父协程在调用
cancelAndJoin()时,会挂起并等待子协程彻底转为Completed状态。 - 清理期限:由于
withContext(NonCancellable)依然在子协程的内部运行,父协程会老老实实地等它执行完。
本质上:它不是脱离了结构化并发,而是利用结构化并发中「父级必等待子级完成」的特性,为子协程争取了一段「不受干扰的清理时间」。
✦ 本质:NonCancellable 是一个特殊的、不可变的、始终活跃的 Job 存根(Stub)。它通过临时覆盖协程上下文中的 Job 节点,欺骗了后续的挂起函数,让它们在已经「起火」的协程树中,依然能平静地完成最后的清理工作。
4.5.3 NonCancellable 的应用
最后,我们来看看 NonCancellable 最具代表性的实战场景。在实际开发中,它通常用于那些 「必须保证原子性」 或 「状态必须同步」 的收尾工作。
场景一:断点续传中的文件状态保存
假设你在写一个下载器。当用户突然点击“取消”时,协程会立即停止,但你必须在退出前记录当前已下载的字节数,否则下次就要从零开始。
1
2
3
4
5
6
7
8
9
10
11
12
13val job = scope.launch(Dispatchers.IO) {
try {
downloadFile() // 耗时下载
} finally {
// 如果不用 NonCancellable,这里的 saveProgress(pos)
// 可能会因为内部有挂起操作而在取消时失败
withContext(NonCancellable) {
val currentPos = getDownloadedBytes()
saveProgressToDatabase(currentPos) // 这是一个挂起函数
println("已安全保存进度: $currentPos")
}
}
}场景二:释放跨进程资源 (Mutex / File Lock)
如果你在多个协程之间使用了
Mutex(协程锁)或文件锁,当协程被取消时,必须确保锁被正确释放。如果释放锁的过程涉及到挂起函数(比如通知远端服务器释放分布式锁),就必须保护起来。1
2
3
4
5
6
7
8
9val mutex = Mutex()
val job = scope.launch {
mutex.withLock {
// 执行同步业务
delay(10000)
}
// 注意:mutex.withLock 内部其实已经处理了异常释放
// 但如果你手动在 finally 中释放并有额外挂起逻辑,则需 NonCancellable
}场景三:向服务器上报“最后遗言”
在某些埋点需求中,我们需要在协程异常或取消时,告知服务器当前任务的状态(成功、失败还是被取消)。
1
2
3
4
5
6
7
8try {
performComplexTask()
} catch (e: CancellationException) {
withContext(NonCancellable) {
logAnalytics("task_cancelled") // 网络请求挂起函数
}
throw e // 记得重新抛出,维持结构化并发
}
4.5.4 NonCancellable 的陷阱
虽然 NonCancellable 很好用,但它是一把双刃剑。我们要防止 NonCancellable 滥用,避免掉入其陷阱:
无法再次取消:在
withContext(NonCancellable)块内部,协程不再响应任何cancel()信号。避免耗时操作:如果你在里面写了一个死循环或者极其耗时的网络请求,父协程(比如
ViewModel)会因为结构化取消的等待机制而被迫一直挂起,导致内存泄漏或 UI 卡死。黄金法则:
NonCancellable只应包含快速、必要的清理逻辑(通常在几百毫秒内)。
5、协程的异常
协程的 「取消流程」 与 「异常流程」 在底层共享同一套机制,但它们在行为、语义和影响上存在关键差异。理解这些异同点,是掌握协程结构化并发模型的核心。
一、相同点(共性)
| 维度 | 相同点 |
|---|---|
| 底层机制统一 | • 两者都基于 Job 的状态机(Active → Cancelling → Cancelled/CompletedExceptionally)。• 都会触发 cancel() 或等效的内部取消逻辑。• 核心分发函数均为 JobSupport.childCancelled(cause: Throwable)。 |
| 都会取消当前协程及其所有子协程 | • 无论是主动取消(job.cancel())还是抛出异常,当前协程及其子孙协程都会被递归取消。• 子协程收到的都是 CancellationException(即使父协程是因为普通异常失败)。 |
| 都支持结构化并发的生命周期管理 | • 取消和异常都会导致协程树的局部或全局终止,符合「作用域内资源自动清理」的原则。 |
二、不同点(区别)
| 维度 | 取消流程(Cancellation) |
异常流程(Exception) |
|---|---|---|
| 触发方式 | • 外部调用 job.cancel()• 内部抛出 CancellationException |
仅能通过协程内部抛出非 CancellationException 的异常如 IllegalStateException |
| 传播方向 | 单向向内 仅取消自身及子协程,不影响父协程 |
双向连通 取消自身 + 子协程 + 父协程链(直至根或被拦截) |
| 对父协程的影响 | 父协程继续正常执行 | 父协程被视为「逻辑容器已损坏」,必须连带取消 |
| 异常类型 | CancellationException(或其子类) |
任意 Throwable(非 CancellationException) |
| 是否暴露到线程 | 静默处理 不打印堆栈,不导致线程崩溃 |
穿透至线程 若未被捕获,将触发 UncaughtExceptionHandler,导致线程/应用崩溃 |
| 设计语义 | 可控中断 如超时、用户取消、资源释放 |
不可恢复错误 如编程错误、服务不可用、数据不一致 |
| 在 childCancelled 中的处理 | if (cause is CancellationException) return true → 不传播 |
走 cancelImpl(cause) → 调用 cancelParent(cause) → 传播 |
5.1 协程的异常流程

异常产生:协程内部抛出一个非
CancellationException的异常。异常捕获:底层的 Kotlin 编译器生成的代码会捕获异常,并调用
coroutine.resumeWith(Result.failure(e))。AbstractCoroutine的resumeWith接收到这个Result.failure。状态转换:调用
Result.toState()将Result.failure中的Throwable封装进CompletedExceptionally包装类中。更新状态:调用
makeCompletingOnce执行自旋锁逻辑,循环检查并更新状态。- 逻辑分流:调用
tryMakeCompleting分流进入tryMakeCompletingSlowPath慢速路径,正式进入异常传播流程。 - 慢速路径:在
tryMakeCompletingSlowPath内提升Finishing状态,确定「失败完成」的「根源异常」。 - 异常传播:将确定的根源异常传入
notifyCancelling(list: NodeList, cause: Throwable)开始异常传播。
- 逻辑分流:调用
异常传播:在
notifyCancelling内先向下取消所有子协程、然后尝试将取消信号向上传给父协程,向下取消和向上通知并行。- 向下取消子协程:遍历列表,找到
ChildHandleNode,调用node.invoke(e)即childJob.parentCancelled(job)取消所有子协程。 - 向上通知父协程:调用
cancelParent(cause)开始取消父协程。cancelParent会获取父Job的引用,并调用父Job的childCancelled(cause),回到第 5 步,形成递归。
- 向下取消子协程:遍历列表,找到
递归终止:递归在以下任一条件满足时停止:
- 遇到
SupervisorJob(childCancelled返回true)。 - 遇到
isScopedCoroutine(cancelParent返回true)。 - 到达根协程且无父
Job。
- 遇到
密封异常:递归终止后,协程进入
Finishing状态的阻塞观察期,等待子协程全部结束。将等待期间产生的「被抑制异常」通过addSuppressedExceptions附加到「根源异常」上,形成finalException并尝试将异常向上传给父协程(cancelParent(finalException)),如果父协程不处理「返回false,例如已经是顶层协程或SupervisorJob的子项」,则交由当前 Job 处理异常(handleJobException(finalException))。如果任一环节处理了异常,则标记为 handled,防止重复抛出。- 在根协程处,若异常仍未被
CoroutineExceptionHandler处理,它将逃逸至线程世界,导致应用崩溃。
- 在根协程处,若异常仍未被
状态终结:调用
completeStateFinalization。状态从Finishing正式 CAS 更新为CompletedExceptionally。状态移交:最终状态逐级返回至
makeCompletingOnce,随后回到resumeWith,协程生命周期彻底结束。
| 流程步骤 | 流程节点 | 响应动作 |
|---|---|---|
| 1 | 异常产生 | 协程内部抛出一个非 CancellationException 的异常。 |
| 2 | 异常捕获 | 底层的 Kotlin 编译器生成的代码会捕获异常,并调用 coroutine.resumeWith(Result.failure(e))。AbstractCoroutine 的 resumeWith 接收到这个 Result.failure。 |
| 3 | 状态转换 | 将 Throwable 封装进 CompletedExceptionally 包装类中。 |
| 4 | 抛出异常 | 产生 CancellationException。 |
| 5 | 资源回收 | 执行 finally 代码块。 |
| 6 | 彻底终止 | 协程进入 Completed 状态。 |
5.1.1 捕获异常
每当协程体(Block)执行结束(无论是正常返回还是抛出异常),都会回调 resumeWith。这是 AbstractCoroutine 捕获异常的唯一严谨入口:
1 | /*** kotlinx.coroutines/AbstractCoroutine.kt ***/ |
5.1.2 状态转换
这里的 result.toState() 是一个关键的扩展函数,它负责将 Kotlin 中标准的 Result 类型 「翻译」 为 JobSupport 状态机能理解的 Incomplete、CompletedExceptionally 或具体值。该函数完成了从 「业务逻辑结果」 到 「协程生命周期状态」 的关键转换:它将 Throwable 封装进 CompletedExceptionally 包装类中。这一步是状态机识别协程异常终结的必要条件,只有通过这种特定的包装形式,JobSupport 才能触发结构化并发中的核心流程,即 「异常冒泡」 与 「子协程的级联取消」。
1 | /*** kotlinx.coroutines/CompletionState.kt ***/ |
为了更清晰地理解其内部机制,我们可以将源码的紧凑写法还原为逻辑分明的结构:
1 | /** |
这两种写法在语义上是完全等价的,只是源码更符合 Kotlin 的惯用写法。在 Kotlin 标准库中,Result.getOrElse 的定义如下:
1 | public inline fun <R, T : R> Result<T>.getOrElse(onFailure: (exception: Throwable) -> R): R { |
5.1.3 更新状态
注意看,AbstractCoroutine 继承了 JobSupport。它调用的 makeCompletingOnce 就是在 JobSupport 中定义的:
1 | /*** kotlinx.coroutines/JobSupport.kt ***/ |
1 | /*** kotlinx.coroutines/JobSupport.kt ***/ |
1 | /*** kotlinx.coroutines/JobSupport.kt ***/ |
5.1.4 取消子协程
1 | /*** kotlinx.coroutines/JobSupport.kt ***/ |
5.1.5 通知父协程
1 | /*** kotlinx.coroutines/JobSupport.kt ***/ |
5.1.6 密封异常链
1 | private fun finalizeFinishingState(state: Finishing, proposedUpdate: Any?): Any? { |
5.1.7 触发异常处理器
1 | /*** kotlinx.coroutines/JobSupport.kt ***/ |
JobSupport 的子类通过重写 handleJobException 来定义异常的最终处理策略。其中,StandaloneCoroutine 重写了该方法,负责将未捕获的异常分发至 CoroutineExceptionHandler,若未配置处理器,则交由当前线程的未处理异常捕获器(UncaughtExceptionHandler)处理。
1 | private open class StandaloneCoroutine( |
1 | public fun handleCoroutineException(context: CoroutineContext, exception: Throwable) { |
5.2 协程的异常管理
上节提到,当协程中发生未捕获的异常时,该异常会沿着协程父子链向上冒泡,直至到达根协程,并最终由 CoroutineExceptionHandler 负责分发和处理。在 Kotlin 协程中,CoroutineExceptionHandler 是处理未捕获异常的最后一道防线。它类似于 Java 中的 Thread.UncaughtExceptionHandler。
5.2.1 线程的异常处理
与单线程同步代码不同,子线程中抛出的未捕获异常不会被主线程的 try-catch 块捕获,往往会导致线程意外终止甚至整个进程崩溃。为了确保系统的稳定性,提供了从局部到全局的三种主要异常处理机制。
局部处理:单线程
try-catch这是最直接的处理方式。在线程执行体(
Runnable)内部使用try-catch块包裹可能出问题的代码。- 特点:颗粒度最细,逻辑最清晰。
- 适用场景:当你明确知道某段逻辑可能报错,并希望在线程内部就地消化异常或进行重试时。
1
2
3
4
5
6
7
8
9
10fun main(): Unit = runBlocking {
val thread = Thread {
try {
throw RuntimeException("Error")
} catch (exception: Exception) {
println("捕获到异常: $exception")
}
}
thread.start()
}对象级别:
setUncaughtExceptionHandler如果你无法在
run方法内部捕获异常(例如使用了第三方库),或者希望将异常处理逻辑与业务逻辑解耦,可以为特定的线程实例设置异常处理器。- 机制:当线程因未捕获异常即将死亡时,JVM 会回调该处理器。
- 优势:允许在异常发生后进行最后的清理工作或日志记录。
1
2
3
4
5
6
7
8
9fun main(): Unit = runBlocking {
val thread = Thread {
throw RuntimeException("Error")
}
thread.uncaughtExceptionHandler = { _, exception ->
println("捕获到异常: $exception")
}
thread.start()
}全局守护:
setDefaultUncaughtExceptionHandler这是一个静态方法,用于设置系统中所有线程的默认异常处理器。
- 兜底机制:如果线程自身没有设置
uncaughtExceptionHandler,系统就会调用这个全局默认处理器。 - 应用场景:通常用于全量日志上报(如 Bugly 或 Sentry),确保没有任何一个异常成为漏网之鱼。
1
2
3
4
5
6
7
8
9fun main(): Unit = runBlocking {
Thread.setDefaultUncaughtExceptionHandler { _, exception ->
println("捕获到异常: $exception")
}
val thread = Thread {
throw RuntimeException("Error")
}
thread.start()
}- 兜底机制:如果线程自身没有设置
5.2.2 协程的异常处理
协程的异常处理与线程对象级别的异常处理类似,只针对单个根协程对象。但需要注意的是,CoroutineExceptionHandler 仅在根协程或 CoroutineScope 的上下文中注册时才会生效。设置在子协程上会被忽略,因为子协程会将异常转发给父级。以下分别展示了将异常处理器注册到根协程和作用域中的典型用法:
注册到根协程
1
2
3
4
5
6
7
8
9
10
11
12
13fun main(): Unit = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println("捕获到异常: $exception")
}
val scope = CoroutineScope(EmptyCoroutineContext)
val parent = scope.launch(handler) {
launch {
throw RuntimeException("Error")
}
}
parent.join()
}注册到作用域
1
2
3
4
5
6
7
8
9
10
11
12
13fun main(): Unit = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println("捕获到异常: $exception")
}
val scope = CoroutineScope(Job() + handler)
val parent = scope.launch {
launch {
throw RuntimeException("Error")
}
}
parent.join()
}
5.2.3 协程的异常外溢
我们在「协程的异常流程」章节的第 7 步「密封异常」中提到:如果异常在根协程中仍未被 CoroutineExceptionHandler 捕获,它将「逃逸」到其所在的线程,并作为未捕获异常抛出,进而可能导致应用崩溃。此时,可以通过为线程设置全局的未捕获异常处理器(Thread.setDefaultUncaughtExceptionHandler)来拦截这类逃逸的异常,从而避免程序非预期终止。
1 | fun main(): Unit = runBlocking { |
5.2.4 async 异常处理
在 Kotlin 协程中,async 对异常的处理方式与 launch 有很大不同。简单来说:launch 报错会立即抛出,而 async 报错会封装在 Deferred 对象中,直到你调用 .await() 时才暴露。
尽管上述规则成立,但在结构化并发的约束下,存在一个极易忽视的特性:异常向上传播。当你使用 async 时,异常的处理取决于它是「根协程」还是「子协程」。
情况 A:作为根协程(使用
CoroutineScope.async)如果你直接在作用域下启动
async,异常会被捕获并存储在返回的Deferred中。1
2
3
4
5
6
7
8
9
10
11
12
13
14/*
* 当 async 作为根协程(直接由 Scope 启动)时,它会封装异常并在 await 时抛出
*/
fun main(): Unit = runBlocking {
val scope = CoroutineScope(EmptyCoroutineContext)
val deferred = scope.async {
throw RuntimeException("Error")
}
try {
deferred.await() // 异常在这里抛出
} catch (e: RuntimeException) {
println("捕获到异常: $e")
}
}情况 B:作为子协程(在
coroutineScope内)这是最常见的情况。如果
async是另一个协程的子协程,一旦它抛出异常,不论你是否调用await(),它都会立即通知父协程,最终导致整个作用域失败。1
2
3
4
5
6
7
8
9
10
11
12/*
* 即便不写 await(),上层的 coroutineScope 也会被挂掉
*/
fun main(): Unit = runBlocking {
val scope = CoroutineScope(EmptyCoroutineContext)
val parent = scope.launch {
async {
throw RuntimeException("Error")
}
}
parent.join()
}情况 C:结构化并发(在
coroutineScope内)在结构化并发模型下,启动
async子协程,在另一个兄弟协程内调用await()。当async子协程抛出异常时,会触发双重传播机制:- 向上级联取消:异常会立即通知父协程,启动结构化取消流程,尝试取消所有兄弟协程。
- 消费点同步抛出:由于
await()是结果的消费入口,它会绕过取消信号,直接将async内部的原始异常同步抛给调用者。
下面的代码示例中,虽然父协程正在异步执行取消逻辑,但
deferred.await()处于直接挂起恢复状态,它获取原始异常的速度远快于父协程发出的CancellationException信号。因此,try-catch会优先捕获到RuntimeException。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17fun main(): Unit = runBlocking {
val scope = CoroutineScope(EmptyCoroutineContext)
val parent = scope.launch {
val deferred = async {
delay(1000)
throw RuntimeException("Error") // 1. 这里抛出异常,立即通知父作用域
}
launch {
try {
deferred.await() // 2. async 抛出异常的同时,这里会再次抛出
} catch (e: Exception) {
println("捕获到异常: $e") // 3. 这里会捕获到 RuntimeException
}
}
}
parent.join()
}为了精准验证「通知父协程并触发结构化取消」这一行为,我们需要剥离
await()的直接干扰。所以,我们可以通过以下思路来验证:将受害者协程中的deferred.await()替换为delay(2000)。- 原理:
delay是一个标准的「可取消挂起函数」。它不关心async的具体返回结果,但它对协程栈的isActive状态极其敏感。 - 现象:当
async抛出异常后,即便没有调用await(),父协程接收到信号会立即将整个作用域设为「取消中」。此时,正在挂起的delay(2000)会立刻被唤醒并抛出CancellationException。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17fun main(): Unit = runBlocking {
val scope = CoroutineScope(EmptyCoroutineContext)
val parent = scope.launch {
val deferred = async {
delay(1000)
throw RuntimeException("Error") // 1. 这里抛出异常,立即通知父作用域
}
launch {
try {
delay(2000) // 2. 调用 delay() 等待唤醒
} catch (e: Exception) {
println("捕获到异常: $e") // 3. 这里会捕获到 RuntimeException
}
}
}
parent.join()
}
5.3 SupervisorJob
在 Kotlin 协程中,SupervisorJob 是实现容错机制的核心。它打破了结构化并发中默认的「连坐」效应(即一个子协程失败导致所有兄弟协程被取消)。我们可以将其比喻为防火墙:它允许异常在子协程内发生,但阻止异常向上传播给父协程。
5.3.1 核心机制
普通 Job 的取消和异常传播是双向的,而 SupervisorJob 则是单向的,表现出一种半父子关系:
- 向下传播(父到子):如果
SupervisorJob被取消,所有子协程都会被取消,与普通Job相同。 - 向上传播(子到父):如果子协程抛出普通异常,
SupervisorJob不会被取消,因此也不会影响其他子协程。
5.3.2 关键函数
在 Job 的源码实现中,决定行为差异的是 childCancelled 函数:
普通 Job:只要子协程因异常(非
CancellationException)而终止,该函数返回true。这意味着父协程会感知失败并自我终结。1
2
3
4
5
6/*** kotlinx.coroutines/JobSupport.kt ***/
public open fun childCancelled(cause: Throwable): Boolean {
if (cause is CancellationException) return true
return cancelImpl(cause) && handlesException
}SupervisorJob:无论子协程因何原因取消,它始终返回
false。它告诉协程框架:“虽然我的孩子出事了,但我还能撑住,不需要取消我。”1
2
3
4
5
6
7/*** kotlinx.coroutines/SupervisorJob.kt ***/
public fun SupervisorJob(parent: Job? = null) : CompletableJob = SupervisorJobImpl(parent)
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
override fun childCancelled(cause: Throwable): Boolean = false
}
5.3.3 异常分发
这是 SupervisorJob 最容易让人困惑的地方:虽然它阻止了异常传播,但它并不「吞掉」异常。由于异常不再向上传递,SupervisorJob 的直接子协程表现得就像「最外层协程」一样。这意味着:
- 必须在子协程中处理异常:你必须在
launch内部使用try-catch或传入CoroutineExceptionHandler。 - Handler 必须传给子协程:将
CoroutineExceptionHandler传给supervisorScope是无效的,必须传给具体的launch。
虽然 SupervisorJob 的概念容易理解,但是在使用过程中却非常具有迷惑性,下面通过几个例子来演示下。
SupervisorJob 的直接子协程,具备最外层协程的异常分发能力。
1
2
3
4
5
6
7
8
9
10
11fun main(): Unit = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println("捕获到异常: $exception")
}
val scope = CoroutineScope(SupervisorJob() + handler)
val parent = scope.launch {
throw RuntimeException("Error")
}
parent.join()
}在本例中,当一个协程发生异常时,它会走以下流程:
- 询问父协程:子协程(
parent)问它的父级(scope的SupervisorJob):“我崩溃了,你要处理这个异常并把自己取消吗?” - 主管拒绝:
SupervisorJob此时实现了childCancelled函数并返回false。它回答:“我不处理,我也不会取消,你自己想办法。” - 自力更生:由于父级不管,parent 就变成了异常处理的实际负责人。
- 查找 Handler:它会查看自己的
CoroutineContext里有没有CoroutineExceptionHandler。虽然handler是定义在scope上的,但由于协程上下文的继承机制,parent在启动时会自动继承scope的所有上下文成员(包括那个handler)。 - 触发分发:
parent发现自己上下文里确实有一个handler,于是它调用这个handler打印了日志。
- 询问父协程:子协程(
SupervisorJob 只对它的「直接下属」负责,不能跨级保护。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17fun main(): Unit = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println("捕获到异常: $exception")
}
val scope = CoroutineScope(SupervisorJob() + handler)
val parent = scope.launch {
launch {
throw RuntimeException("Error")
}
launch {
delay(500)
println("永远不会执行的打印逻辑")
}
}
parent.join()
}在本例中,层级关系是这样的:
- Level 0 (主管):
scope(持有SupervisorJob) - Level 1 (组长):
parent(由scope.launch启动,它是普通Job) - Level 2 (组员):内部的两个
launch(普通Job)
执行流程如下:
- 组员 A 抛出异常。
- 组长 parent 是个普通
Job,它收到组员 A 的异常后,触发了“连坐制”。 - 组长 parent 立即去掐死组员 B(所以“永远不会执行”)。
- 组长 parent 然后自己也断气了,临死前把异常往上递交给 主管 scope。
- 主管 scope 因为是
SupervisorJob,它接住异常并让handler打印了日志,但它自己没有死。
虽然
handler捕获了异常,但业务逻辑崩溃了。SupervisorJob的初衷是:即使组员 A 犯错,组员 B 也要把活干完。但在本例中,由于中间隔了一个普通的parent launch,组员 B 被「误伤」了。- Level 0 (主管):
SupervisorJob 只对它的「直接下属」负责,语法糖的迷惑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26fun main(): Unit = runBlocking {
val scope = CoroutineScope(EmptyCoroutineContext)
val parent = scope.launch {
val handler = CoroutineExceptionHandler { _, exception ->
println("捕获到了异常: $exception")
}
launch(SupervisorJob(coroutineContext.job) + handler) {
launch {
throw Exception("子协程 1 崩溃了")
}
launch {
delay(1500)
println("子协程 3 依然不会执行")
}
}
launch {
launch {
delay(1000)
println("子协程 2 依然可以执行")
}
}
}
parent.join()
}在本例中,父子层级关系是最隐蔽、最容易让人产生「错觉」的深坑。即便把「子协程 1」和「子协程 3」直接放在了带有
SupervisorJob的launch下面,「子协程 3」 依然不会执行。原因不在于SupervisorJob失效了,而在于你对launch(Job)这种写法的本质理解被 Kotlin 的语法糖迷惑了。问题的核心在于这一行:
launch(SupervisorJob(coroutineContext.job) + handler) { ... }。当你给launch传递一个明确的Job(或SupervisorJob)对象时,会发生两件事:- 关联父级:新 Job 会把
coroutineContext.job作为自己的父节点(这没错)。 - 创建新的作用域:这个
launch内部的代码块,其this(CoroutineScope) 的coroutineContext并不包含你传进去的那个SupervisorJob。
让我们拆解一下这个
launch内部发生了什么:- 你传入的
SupervisorJob确实成了这个launch的 Job。 - 但在
launch { ... }`` 的闭包内部,当你再次调用launch { … }`(创建子协程 1 和 3)时,它们默认继承的是父协程的 Context。 - 由于
launch的机制,它在启动时会自动为闭包创建一个新的普通 Job。 - 结果:子协程 1 和 3 的父亲,其实是这个闭包自动生成的普通 Job,而不是你传进去的那个
SupervisorJob!
如果用结构图解,上面的代码在 JVM 眼里长这样:
- Parent Launch(Job)
- Middle Launch(你传的 SupervisorJob)
- 隐形的普通 Job(由 launch { … } 闭包自动创建)← 这就是罪魁祸首!
- 子协程 1(崩溃)
- 子协程 3(受连累被取消)
- 隐形的普通 Job(由 launch { … } 闭包自动创建)← 这就是罪魁祸首!
- 子协程 2 的父级(普通 Job)
- 子协程 2(幸存)
- Middle Launch(你传的 SupervisorJob)
当「子协程 1」崩溃时,它向上找爸爸,找到了那个「隐形的普通 Job」。普通 Job 说:“我崩了,孩子们都得死。” 于是「子协程 3」被杀。而那个「隐形的普通 Job」再向上找爸爸,SupervisorJob 说:“行,我知道了,我不往上传了。” 这就是为什么「子协程 2」活了下来,但「子协程 3」死掉了。
我们可以把异常传播想象成一场火灾,而 SupervisorJob 是一道防火墙:
- 火源:子协程 1 烧起来了(抛出异常)。
- 向上蔓延:火苗烧到了它的爸爸——内部匿名 Job,也就是隐形的普通 Job。
- 普通 Job 的反应:爸爸是普通 Job,没有防火能力。它被点燃前,为了防止火势失控,会先把家里其他孩子(子协程 3)全部杀掉。
- 撞击防火墙:火苗继续往上,烧到了爷爷——SupervisorJob。
- 防火墙生效:爷爷是
SupervisorJob,它能抗住火灾而不自毁。它把火挡住了,不再向上(向parent)传播。 - 另一边风景大好:既然火没烧到
parent,那么parent开启的另一个大分支完全不知道发生了火灾,继续悠闲地打印它的delay(1000)。
- 关联父级:新 Job 会把
5.3.4 最佳实践
要让 SupervisorJob 真正起作用,它必须直接存在于子协程所在的作用域中。
方案一:使用
supervisorScope(推荐,零坑感)supervisorScope会确保它内部直接启动的launch都直接挂在一个Supervisor机制下,没有“隐形中间商”。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26fun main(): Unit = runBlocking {
val scope = CoroutineScope(EmptyCoroutineContext)
val parent = scope.launch {
val handler = CoroutineExceptionHandler { _, exception ->
println("捕获到了异常: $exception")
}
launch(handler) {
// 这里的 supervisorScope 会改变其内部 launch 的继承方式
supervisorScope {
launch { throw Exception("子协程 1 崩溃了") }
launch {
delay(1500)
println("子协程 3 依然可以执行")
}
}
}
launch {
launch {
delay(1000)
println("子协程 2 依然可以执行")
}
}
}
parent.join()
}方案二:手动构建 Scope(理解原理用)
如果你非要用
launch(SupervisorJob()),你得手动用这个新context包装出一个新的作用域:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25fun main(): Unit = runBlocking {
val scope = CoroutineScope(EmptyCoroutineContext)
val parent = scope.launch {
val handler = CoroutineExceptionHandler { _, exception ->
println("捕获到了异常: $exception")
}
val supervisor = SupervisorJob(coroutineContext.job)
val subScope = CoroutineScope(coroutineContext + supervisor + handler)
subScope.launch { throw Exception("子协程 1 崩溃了") }
subScope.launch {
delay(1500)
println("子协程 3 依然可以执行")
}
launch {
launch {
delay(1000)
println("子协程 2 依然可以执行")
}
}
}
parent.join()
}一句话避坑:永远不要试图通过
launch(SupervisorJob())来保护内部的子协程,那只能保护外部的父协程。想要保护内部的兄弟们,请永远使用supervisorScope { ... }。因为supervisorScope的底层逻辑完全不同,它会强行改变闭包内部的规则。