本文将深入 kotlinx.coroutines 源码,解析一个异常是如何从子协程产生,并一步步通过 Job 层次结构向上汇报,最终被拦截或导致程序崩溃的。
1. 异常的源头:协程体的执行
在 launch 或 async 中,协程体被包装在 AbstractCoroutine 的实现类中。当协程体执行抛出异常时,由于它运行在 Continuation 的环境下,异常会被 resumeWith 捕获。
什么是 Continuation
在 Kotlin 协程中,Continuation 是实现挂起和恢复的核心接口。它本质上是一个通用的回调接口,代表了程序运行到某个挂起点之后“接下来的逻辑”。
1 | public interface Continuation<in T> { |
- **resumeWith(result: Result
)**:这是核心方法。当异步操作完成或协程体执行结束时,会调用此方法。如果操作成功, result携带结果值;如果发生了异常,result则携带异常信息(Result.failure)。
简单来说,Continuation 就像是一个信使,它不仅知道该去哪(Context),还负责把信(Result)送到目的地。
Continuation 是如何实现挂起的
挂起(Suspend)并不是阻塞线程,而是函数在执行过程中,保存当前的执行状态(Continuation),然后退出当前的栈帧,将线程控制权交还给调度器。
其实现机制的核心在于 CPS(Continuation-Passing-Style)变换:
- 状态机拆分:Kotlin 编译器会将带有
suspend关键字的函数转换成一个内部状态机。每一个挂起点(如delay或其他挂起函数)都会将函数体拆分成不同的状态。 - 传递 Continuation:每一个挂起函数在编译后,都会在参数列表的最后多出一个
Continuation参数。 - COROUTINE_SUSPENDED 标记:当挂起函数被调用时,它会检查是否需要真正挂起。如果需要,它会返回一个特殊值
COROUTINE_SUSPENDED。此时,调用方的状态机就会感知到挂起,并立即返回,从而释放线程。 - 恢复执行:当异步操作完成时,会调用之前保存的
Continuation.resumeWith。这会触发状态机进入下一个状态,协程从而在之前挂起的地方“复活”。
补充理解:保存状态与“复活”的本质
很多开发者对“保存状态” and “复活”感到抽象,我们可以从 JVM 内存模型的角度来拆解这个过程:
- 保存状态(堆内存化):在普通的 JVM 函数中,局部变量和执行进度(程序计数器)都保存在栈帧(Stack Frame)中。一旦函数返回,栈帧就被销毁。
- 在协程中,当遇到挂起点时,编译器生成的代码会将栈帧里的局部变量、参数以及当前的“执行进度标记”(label)全部拷贝到
Continuation对象的成员变量中。 - 因为
Continuation实例存储在堆(Heap)上,所以即使函数返回、栈帧销毁,这些数据依然存在。
- 在协程中,当遇到挂起点时,编译器生成的代码会将栈帧里的局部变量、参数以及当前的“执行进度标记”(label)全部拷贝到
- 退出栈帧:函数通过
return COROUTINE_SUSPENDED结束执行。此时,当前线程被释放,可以去处理其他的任务,实现了非阻塞。 - “复活”(重入状态机):
- 当
resumeWith被调用时,协程的代码块(状态机函数)会被再次调用。 - 函数重新进入后,第一步就是从堆上的
Continuation对象中读取之前保存的成员变量,重新赋值给局部变量。 - 随后,根据保存的
label进行跳转(类似于switch-case),直接跳到上次挂起的位置继续执行。
- 当
通过这种“数据从栈到堆的迁移与回填”,协程模拟出了函数暂停和恢复的神奇效果。
伪代码模拟:编译器对挂起函数的改造
为了让“保存状态”和“复活”更好理解,我们可以通过伪代码看看编译器是如何将一个普通的挂起函数转换成状态机的:
原始代码:
1 | suspend fun showInfo() { |
编译器改造后的伪代码:
1 | // 1. 自动生成一个包装类(存档文件),存储在堆上 |
总结“复活”的逻辑
- 挂起时:函数执行到一半,发现需要等待(如
delay)。它把当前的局部变量user和进度label = 1存进堆里的cont对象,然后直接return。 - 等待中:当前线程空闲了,去干别的事了(比如处理其他的协程)。
- 时间到:
delay定时器触发,它会调用cont.resumeWith()。 - 复活时:
resumeWith内部再次触发showInfo(cont)。因为cont.label已经是1了,函数通过when分支直接跳过了第一阶段,从第二阶段开始执行,并通过cont.user拿回了之前的数据。
这就是协程能够“复活”的奥秘:它不是在同一个函数调用里死等,而是通过多次“重新进入”函数,并配合堆内存中的数据备份,模拟出了暂停的效果。
深入理解:AbstractCoroutine 也是 Continuation
在 Kotlin 协程底层,每一个协程块最终都被编译为一个 Continuation。在调用 launch 或 async 时,创建的 AbstractCoroutine 实例本身就实现了 Continuation<T> 接口。
源码解析:
当协程体(block)执行结束(无论是正常返回还是抛出异常)时,Kotlin 编译器生成的代码会调用该协程所属的 completion.resumeWith(result)。对于协程库来说,这个 completion 就是 AbstractCoroutine。
1 | // AbstractCoroutine.kt |
这意味着,协程体外部并没有一个传统意义上的 JVM try-catch 块来捕获异常。相反,它是通过 Continuation 的回调机制,将异常作为“结果”传递给了 AbstractCoroutine 的 resumeWith 方法,并由此开启了后续的异常上报流程。
2. 状态机流转:JobSupport.makeCompletingOnce
resumeWith 调用 makeCompletingOnce 进入 JobSupport 的状态机处理逻辑。如果 result 是失败的,它会携带一个 CompletedExceptionally 状态。
核心代码位置: JobSupport.kt
在 tryMakeCompletingSlowPath 中,如果发现有异常,会进入异常处理流程:
1 | // JobSupport.kt |
3. 向上汇报:childCancelled 与 parentHandle
核心枢纽:parentHandle 的建立
在协程的层级结构中,每一个子 Job 都持有一个指向父 Job 的引用,这个引用被封装在 parentHandle 成员变量中。
源码解析: JobSupport.kt
当一个子协程被创建时(例如通过 launch),它会调用 initParentJob 方法:
1 | // JobSupport.kt |
这个 parentHandle 类型为 ChildHandle,它是双向通信的桥梁:
- 父到子:父协程取消时,通过这个 Handle 通知子协程取消(调用
parentCancelled)。 - 子到父:子协程发生异常时,通过调用
parentHandle.childCancelled(cause)向上汇报。
异常的上报过程
一旦子协程通过 resumeWith 捕获到异常并进入取消状态,它会立即通过 parentHandle 寻找它的“家长”。
在 JobSupport 中,子 Job 通过 ChildHandleNode(ChildHandle 的具体实现)与父 Job 关联。
核心代码位置: JobSupport.kt 中的 ChildHandleNode
1 | private class ChildHandleNode( |
父 Job 的 childCancelled 方法会被触发:
1 | public open fun childCancelled(cause: Throwable): Boolean { |
- 如果父 Job 是普通的
Job,cancelImpl会返回true并在内部继续调用自己的父 Job 的childCancelled,实现层层上报。 - 如果父 Job 是
SupervisorJob,它会重写此逻辑或返回false,从而切断异常向上传播。
什么是 SupervisorJob?
从源码上看,SupervisorJob 的实现极其简单且暴力。
源码解析: Supervisor.kt
1 | private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) { |
当子协程报错并通过 parentHandle.childCancelled(cause) 通知 SupervisorJob 时:
- 它执行重写后的方法,直接返回
false。 - 它不会调用
cancelImpl导致自己取消。 - 因为它自己没有被取消,所以也就不会去取消它的其他子协程。
这就是为什么 SupervisorJob 能够实现“子协程故障互不干扰”的根本原因。
4. 根协程的终点:handleJobException
异常上报到最顶层的根协程(没有父 Job 的协程)后,cancelImpl 流程结束。此时,协程会进入完成阶段,并调用 handleJobException。
对于 launch 启动的协程,其实际实现类是 StandaloneCoroutine。
核心代码位置: Builders.common.kt
1 | private open class StandaloneCoroutine(...) : AbstractCoroutine<Unit>(...) { |
5. 最后的拦截:handleCoroutineException
这是异常处理的最后一站。它会检查协程上下文中是否有用户定义的 CoroutineExceptionHandler。
核心代码位置: CoroutineExceptionHandler.kt
1 | public fun handleCoroutineException(context: CoroutineContext, exception: Throwable) { |
6. 源码解析总结:调用链图示
当子协程发生 Exception 时:
AbstractCoroutine.resumeWith(Result.failure)JobSupport.makeCompletingOnce-> 状态变为Cancelling/FinishingJobSupport.notifyCancelling-> 取消所有子协程parentHandle.childCancelled(exception)-> 向父 Job 汇报- (循环上报直至 Root)
Root Coroutine调用handleJobExceptionhandleCoroutineException(context, exception)CoroutineExceptionHandler.handleException(...)-> 用户代码拦截点
7. SupervisorJob 的使用场景与示例
在理解了 SupervisorJob 的源码原理后,我们来看它在实际开发中的典型应用。
场景一:UI 组件的生命周期作用域
在 Android 开发中,一个 ViewModel 可能同时发起多个不相关的请求(如用户信息、配置信息、广告位信息)。如果其中一个接口报错,我们通常不希望导致整个页面崩溃或者其它正在进行的请求被取消。
1 | // 使用 SupervisorJob 确保子协程互不影响 |
场景二:使用 supervisorScope 处理局部故障
如果你只想在某个特定的业务逻辑块中实现故障隔离,可以使用 supervisorScope。它会创建一个临时的监督作用域。
1 | suspend fun processTasks() = supervisorScope { |
关键区别:SupervisorJob vs supervisorScope
- SupervisorJob:通常用于定义一个长生命周期的作用域(Scope),作为整个作用域的根 Job(如全局 Scope 或 ViewModel Scope)。
- supervisorScope:是一个挂起函数。它会创建一个子 Job 并将其设为 Supervisor,然后等待内部所有协程完成。它适合在现有的协程流中临时开启一段并行的、容错的任务。
注意事项:异常依然需要处理
虽然 SupervisorJob 阻止了异常向上传播,但异常本身并没有消失。
- 如果子协程使用
launch启动,且没有内部try-catch,异常最终会触发handleCoroutineException。 - 如果此时没有配置
CoroutineExceptionHandler,在 Android 等平台上仍然可能导致程序崩溃。 - 结论:
SupervisorJob只是保护了“邻居”不被牵连,你仍然需要为每个子任务提供异常处理逻辑。