Kotlin 协程异常处理源码解析:从抛出到拦截的全链路追踪

本文将深入 kotlinx.coroutines 源码,解析一个异常是如何从子协程产生,并一步步通过 Job 层次结构向上汇报,最终被拦截或导致程序崩溃的。

1. 异常的源头:协程体的执行

launchasync 中,协程体被包装在 AbstractCoroutine 的实现类中。当协程体执行抛出异常时,由于它运行在 Continuation 的环境下,异常会被 resumeWith 捕获。

什么是 Continuation

在 Kotlin 协程中,Continuation 是实现挂起和恢复的核心接口。它本质上是一个通用的回调接口,代表了程序运行到某个挂起点之后“接下来的逻辑”。

1
2
3
4
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
  • **resumeWith(result: Result)**:这是核心方法。当异步操作完成或协程体执行结束时,会调用此方法。如果操作成功,result 携带结果值;如果发生了异常,result 则携带异常信息(Result.failure)。

简单来说,Continuation 就像是一个信使,它不仅知道该去哪(Context),还负责把信(Result)送到目的地。

Continuation 是如何实现挂起的

挂起(Suspend)并不是阻塞线程,而是函数在执行过程中,保存当前的执行状态(Continuation),然后退出当前的栈帧,将线程控制权交还给调度器。

其实现机制的核心在于 CPS(Continuation-Passing-Style)变换

  1. 状态机拆分:Kotlin 编译器会将带有 suspend 关键字的函数转换成一个内部状态机。每一个挂起点(如 delay 或其他挂起函数)都会将函数体拆分成不同的状态。
  2. 传递 Continuation:每一个挂起函数在编译后,都会在参数列表的最后多出一个 Continuation 参数。
  3. COROUTINE_SUSPENDED 标记:当挂起函数被调用时,它会检查是否需要真正挂起。如果需要,它会返回一个特殊值 COROUTINE_SUSPENDED。此时,调用方的状态机就会感知到挂起,并立即返回,从而释放线程。
  4. 恢复执行:当异步操作完成时,会调用之前保存的 Continuation.resumeWith。这会触发状态机进入下一个状态,协程从而在之前挂起的地方“复活”。

补充理解:保存状态与“复活”的本质

很多开发者对“保存状态” and “复活”感到抽象,我们可以从 JVM 内存模型的角度来拆解这个过程:

  • 保存状态(堆内存化):在普通的 JVM 函数中,局部变量和执行进度(程序计数器)都保存在栈帧(Stack Frame)中。一旦函数返回,栈帧就被销毁。
    • 在协程中,当遇到挂起点时,编译器生成的代码会将栈帧里的局部变量、参数以及当前的“执行进度标记”(label)全部拷贝到 Continuation 对象的成员变量中。
    • 因为 Continuation 实例存储在堆(Heap)上,所以即使函数返回、栈帧销毁,这些数据依然存在。
  • 退出栈帧:函数通过 return COROUTINE_SUSPENDED 结束执行。此时,当前线程被释放,可以去处理其他的任务,实现了非阻塞。
  • “复活”(重入状态机)
    • resumeWith 被调用时,协程的代码块(状态机函数)会被再次调用
    • 函数重新进入后,第一步就是从堆上的 Continuation 对象中读取之前保存的成员变量,重新赋值给局部变量。
    • 随后,根据保存的 label 进行跳转(类似于 switch-case),直接跳到上次挂起的位置继续执行。

通过这种“数据从栈到堆的迁移与回填”,协程模拟出了函数暂停和恢复的神奇效果。

伪代码模拟:编译器对挂起函数的改造

为了让“保存状态”和“复活”更好理解,我们可以通过伪代码看看编译器是如何将一个普通的挂起函数转换成状态机的:

原始代码:

1
2
3
4
5
suspend fun showInfo() {
val user = "Jason" // 局部变量
delay(1000) // 挂起点
println(user) // 恢复后需要用到 user
}

编译器改造后的伪代码:

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
26
27
28
29
30
31
32
33
34
35
// 1. 自动生成一个包装类(存档文件),存储在堆上
class ShowInfoContinuation(val completion: Continuation<Any?>) : ContinuationImpl(...) {
var user: String? = null // 用于保存局部变量
var label: Int = 0 // 用于保存执行进度(状态)

override fun resumeWith(result: Result<Any?>) {
showInfo(this) // 恢复时再次调用原函数,并传入自己
}
}

// 2. 原函数被改造,增加了一个 Continuation 参数
fun showInfo(completion: Continuation<Any?>) {
// 检查是否是第一次运行,还是从存档恢复
val cont = completion as? ShowInfoContinuation ?: ShowInfoContinuation(completion)

// 核心状态机切换
when (cont.label) {
0 -> {
// --- 第一阶段:初次运行 ---
cont.user = "Jason" // 【保存状态】将局部变量存入堆对象
cont.label = 1 // 【记录进度】标记下一步该从哪开始

// 尝试执行 delay
if (delay(1000, cont) == COROUTINE_SUSPENDED) {
return // 【退出栈帧】直接返回,不阻塞当前线程
}
}
1 -> {
// --- 第二阶段:复活 ---
// 函数重新进入后,根据 label 直接跳到这里执行
val user = cont.user // 【恢复数据】从堆中读回局部变量
println(user)
}
}
}

总结“复活”的逻辑

  1. 挂起时:函数执行到一半,发现需要等待(如 delay)。它把当前的局部变量 user 和进度 label = 1 存进堆里的 cont 对象,然后直接 return
  2. 等待中:当前线程空闲了,去干别的事了(比如处理其他的协程)。
  3. 时间到delay 定时器触发,它会调用 cont.resumeWith()
  4. 复活时resumeWith 内部再次触发 showInfo(cont)。因为 cont.label 已经是 1 了,函数通过 when 分支直接跳过了第一阶段,从第二阶段开始执行,并通过 cont.user 拿回了之前的数据。

这就是协程能够“复活”的奥秘:它不是在同一个函数调用里死等,而是通过多次“重新进入”函数,并配合堆内存中的数据备份,模拟出了暂停的效果。

深入理解:AbstractCoroutine 也是 Continuation

在 Kotlin 协程底层,每一个协程块最终都被编译为一个 Continuation。在调用 launchasync 时,创建的 AbstractCoroutine 实例本身就实现了 Continuation<T> 接口。

源码解析:
当协程体(block)执行结束(无论是正常返回还是抛出异常)时,Kotlin 编译器生成的代码会调用该协程所属的 completion.resumeWith(result)。对于协程库来说,这个 completion 就是 AbstractCoroutine

1
2
3
4
5
6
7
8
9
10
11
// AbstractCoroutine.kt
public abstract class AbstractCoroutine<in T>(...) : JobSupport(active), Job, Continuation<T>, CoroutineScope {

// 当协程体抛出异常,编译器会自动调用此方法,result 此时为 Result.failure(exception)
public final override fun resumeWith(result: Result<T>) {
// result.toState() 会将异常包装成 CompletedExceptionally 对象
val state = makeCompletingOnce(result.toState())
if (state === COMPLETING_WAITING_CHILDREN) return
afterResume(state)
}
}

这意味着,协程体外部并没有一个传统意义上的 JVM try-catch 块来捕获异常。相反,它是通过 Continuation 的回调机制,将异常作为“结果”传递给了 AbstractCoroutineresumeWith 方法,并由此开启了后续的异常上报流程。

2. 状态机流转:JobSupport.makeCompletingOnce

resumeWith 调用 makeCompletingOnce 进入 JobSupport 的状态机处理逻辑。如果 result 是失败的,它会携带一个 CompletedExceptionally 状态。

核心代码位置: JobSupport.kt

tryMakeCompletingSlowPath 中,如果发现有异常,会进入异常处理流程:

1
2
3
4
5
6
7
8
9
10
11
// JobSupport.kt
val notifyRootCause: Throwable?
synchronized(finishing) {
// ...
// 添加异常到 Finishing 状态中
(proposedUpdate as? CompletedExceptionally)?.let { finishing.addExceptionLocked(it.cause) }
// 如果是第一次变更为取消状态,则记录 rootCause
notifyRootCause = finishing.rootCause.takeIf { !wasCancelling }
}
// 触发取消通知
notifyRootCause?.let { notifyCancelling(list, it) }

3. 向上汇报:childCancelledparentHandle

核心枢纽:parentHandle 的建立

在协程的层级结构中,每一个子 Job 都持有一个指向父 Job 的引用,这个引用被封装在 parentHandle 成员变量中。

源码解析: JobSupport.kt

当一个子协程被创建时(例如通过 launch),它会调用 initParentJob 方法:

1
2
3
4
5
6
7
8
9
10
// JobSupport.kt
protected fun initParentJob(parent: Job?) {
if (parent == null) {
parentHandle = NonDisposableHandle
return
}
// 关键:将当前子 Job 挂载到父 Job 上
// attachChild 会返回一个 ChildHandle 对象,子 Job 将其保存为 parentHandle
parentHandle = parent.attachChild(this)
}

这个 parentHandle 类型为 ChildHandle,它是双向通信的桥梁:

  1. 父到子:父协程取消时,通过这个 Handle 通知子协程取消(调用 parentCancelled)。
  2. 子到父:子协程发生异常时,通过调用 parentHandle.childCancelled(cause) 向上汇报。

异常的上报过程

一旦子协程通过 resumeWith 捕获到异常并进入取消状态,它会立即通过 parentHandle 寻找它的“家长”。

JobSupport 中,子 Job 通过 ChildHandleNodeChildHandle 的具体实现)与父 Job 关联。

核心代码位置: JobSupport.kt 中的 ChildHandleNode

1
2
3
4
5
6
7
private class ChildHandleNode(
@JvmField val childJob: ChildJob
) : JobNode(), ChildHandle {
// ...
// 当子 Job 发生异常调用时,实质上是调用了父 Job 的 childCancelled
override fun childCancelled(cause: Throwable): Boolean = job.childCancelled(cause)
}

父 Job 的 childCancelled 方法会被触发:

1
2
3
4
5
public open fun childCancelled(cause: Throwable): Boolean {
if (cause is CancellationException) return true // 正常取消不向上汇报
// 关键:尝试取消自身并看是否能处理
return cancelImpl(cause) && handlesException
}
  • 如果父 Job 是普通的 JobcancelImpl 会返回 true 并在内部继续调用自己的父 Job 的 childCancelled,实现层层上报
  • 如果父 Job 是 SupervisorJob,它会重写此逻辑或返回 false,从而切断异常向上传播

什么是 SupervisorJob?

从源码上看,SupervisorJob 的实现极其简单且暴力。

源码解析: Supervisor.kt

1
2
3
4
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
// 直接重写 childCancelled,永远返回 false
override fun childCancelled(cause: Throwable): Boolean = false
}

当子协程报错并通过 parentHandle.childCancelled(cause) 通知 SupervisorJob 时:

  1. 它执行重写后的方法,直接返回 false
  2. 它不会调用 cancelImpl 导致自己取消。
  3. 因为它自己没有被取消,所以也就不会去取消它的其他子协程。

这就是为什么 SupervisorJob 能够实现“子协程故障互不干扰”的根本原因。

4. 根协程的终点:handleJobException

异常上报到最顶层的根协程(没有父 Job 的协程)后,cancelImpl 流程结束。此时,协程会进入完成阶段,并调用 handleJobException

对于 launch 启动的协程,其实际实现类是 StandaloneCoroutine

核心代码位置: Builders.common.kt

1
2
3
4
5
6
7
private open class StandaloneCoroutine(...) : AbstractCoroutine<Unit>(...) {
override fun handleJobException(exception: Throwable): Boolean {
// 最终调用全局的异常处理器
handleCoroutineException(context, exception)
return true
}
}

5. 最后的拦截:handleCoroutineException

这是异常处理的最后一站。它会检查协程上下文中是否有用户定义的 CoroutineExceptionHandler

核心代码位置: CoroutineExceptionHandler.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public fun handleCoroutineException(context: CoroutineContext, exception: Throwable) {
// 1. 尝试从上下文中获取拦截器
try {
context[CoroutineExceptionHandler]?.let {
it.handleException(context, exception)
return
}
} catch (t: Throwable) {
// 如果拦截器本身抛出异常,则交给全局兜底
handleUncaughtCoroutineException(context, handlerException(exception, t))
return
}
// 2. 如果没找到,交给平台相关的兜底处理器 (如 JVM 的 ServiceLoader 查找或 Thread.uncaughtExceptionHandler)
handleUncaughtCoroutineException(context, exception)
}

6. 源码解析总结:调用链图示

当子协程发生 Exception 时:

  1. AbstractCoroutine.resumeWith(Result.failure)
  2. JobSupport.makeCompletingOnce -> 状态变为 Cancelling/Finishing
  3. JobSupport.notifyCancelling -> 取消所有子协程
  4. parentHandle.childCancelled(exception) -> 向父 Job 汇报
  5. (循环上报直至 Root)
  6. Root Coroutine 调用 handleJobException
  7. handleCoroutineException(context, exception)
  8. CoroutineExceptionHandler.handleException(...) -> 用户代码拦截点

7. SupervisorJob 的使用场景与示例

在理解了 SupervisorJob 的源码原理后,我们来看它在实际开发中的典型应用。

场景一:UI 组件的生命周期作用域

在 Android 开发中,一个 ViewModel 可能同时发起多个不相关的请求(如用户信息、配置信息、广告位信息)。如果其中一个接口报错,我们通常不希望导致整个页面崩溃或者其它正在进行的请求被取消。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用 SupervisorJob 确保子协程互不影响
val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

fun fetchData() {
viewModelScope.launch {
// 请求 A 失败,由于父 Job 是 SupervisorJob,不会影响请求 B
throw IOException("Failed to load ads")
}
viewModelScope.launch {
// 请求 B 继续运行
val data = api.getUserInfo()
println("User info loaded: $data")
}
}

场景二:使用 supervisorScope 处理局部故障

如果你只想在某个特定的业务逻辑块中实现故障隔离,可以使用 supervisorScope。它会创建一个临时的监督作用域。

1
2
3
4
5
6
7
8
9
10
11
12
13
suspend fun processTasks() = supervisorScope {
val job1 = launch {
println("Task 1 started")
throw RuntimeException("Task 1 crashed")
}

val job2 = launch {
println("Task 2 started")
delay(100)
println("Task 2 completed successfully")
}
// supervisorScope 会等待 job1 和 job2 全部完成
}

关键区别:SupervisorJob vs supervisorScope

  • SupervisorJob:通常用于定义一个长生命周期的作用域(Scope),作为整个作用域的根 Job(如全局 Scope 或 ViewModel Scope)。
  • supervisorScope:是一个挂起函数。它会创建一个子 Job 并将其设为 Supervisor,然后等待内部所有协程完成。它适合在现有的协程流中临时开启一段并行的、容错的任务。

注意事项:异常依然需要处理

虽然 SupervisorJob 阻止了异常向上传播,但异常本身并没有消失

  1. 如果子协程使用 launch 启动,且没有内部 try-catch,异常最终会触发 handleCoroutineException
  2. 如果此时没有配置 CoroutineExceptionHandler,在 Android 等平台上仍然可能导致程序崩溃。
  3. 结论SupervisorJob 只是保护了“邻居”不被牵连,你仍然需要为每个子任务提供异常处理逻辑。
,