协程的取消与异常处理机制
为什么取消和异常是协程最容易踩坑的领域
上一篇文章我们深入剖析了协程的底层工作原理——CPS 变换、状态机、CoroutineContext 和 Dispatcher。但在实际项目中,真正让开发者头疼的往往不是"协程怎么启动",而是"协程怎么停下来"和"出了错怎么办"。
一个典型的 Android 场景:用户在页面 A 发起了一个网络请求,然后迅速返回。此时协程还在等待网络响应——如果不取消它,协程恢复后会尝试更新一个已经销毁的 UI,轻则内存泄漏,重则直接崩溃。再比如,一个页面同时加载用户信息和订单列表,如果订单请求失败了,用户信息的请求还能继续吗?还是应该一起取消?
这些问题的答案都藏在协程的取消机制和异常传播机制中。本文将从源码层面完整剖析这两大机制,让你彻底理解每一个设计决策背后的"为什么"。
Job 的状态机:协程生命周期的内部表示
要理解取消和异常,首先要理解 Job 的生命周期。上一篇文章提到,每个协程都有一个 Job,它管理着协程的生命周期。Job 的内部实现类是 JobSupport,它维护了一个精密的状态机。
六种状态与转换路径
┌──────────────────────────────────────┐
│ │
start() ▼ 协程体执行完毕 │
┌─────┐ ─────────► ┌────────┐ ──────────► ┌────────────┐ │
│ New │ │ Active │ │ Completing │ │
└──┬──┘ └───┬────┘ └─────┬──────┘ │
│ │ │ │
│ cancel() │ cancel() / 子协程异常 │ 等待子协程 │
│ │ │ 全部完成 │
│ ▼ ▼ │
│ ┌────────────┐ ┌───────────┐ │
└──────────────► │ Cancelling │ ──────────►│ Cancelled │ │
└────────────┘ └───────────┘ │
│
┌───────────┐ │
│ Completed │◄──────┘
└───────────┘
每种状态对应三个布尔属性的组合:
| 状态 | isActive |
isCompleted |
isCancelled |
|---|---|---|---|
| New(惰性启动时的初始状态) | false |
false |
false |
| Active(正在执行) | true |
false |
false |
| Completing(协程体执行完,等待子协程) | true |
false |
false |
| Cancelling(取消中,正在清理) | false |
false |
true |
| Cancelled(终态:已取消) | false |
true |
true |
| Completed(终态:正常完成) | false |
true |
false |
注意一个容易被忽略的细节:Completing 和 Active 的 isActive 都是 true。从外部观察,一个正在等待子协程完成的父协程看起来仍然是"活跃的"——这保证了结构化并发的不变式:父协程不会在子协程结束前进入完成状态。
cancelImpl:状态转换的引擎
当你调用 job.cancel() 时,内部发生了什么?JobSupport.kt 中的 cancelImpl 方法是取消操作的核心入口:
// JobSupport.kt(简化)—— 取消的核心逻辑
internal fun cancelImpl(cause: Any?): Boolean {
// 1. 尝试将状态从 Active/Completing 转换为 Cancelling
// 使用 CAS 操作保证线程安全
val finalState = makeCancelling(cause)
// 2. 如果已经处于终态(Completed/Cancelled),忽略本次取消
if (finalState === COMPLETING_ALREADY) return true
// 3. 如果成功进入 Cancelling 状态
// → 调用 notifyCancelling() 通知所有子 Job
// → 触发 invokeOnCompletion 注册的回调
afterCompletion(finalState)
return true
}
cancelImpl 做了三件关键的事:
- 原子地将 Job 状态从 Active 转为 Cancelling(通过
compareAndSet,保证多线程安全) - 调用
notifyCancelling递归地取消所有子 Job - 触发所有注册的完成回调(
invokeOnCompletion注册的 handler)
Job 的状态机就像一个交通信号灯系统——绿灯(Active)、黄灯(Cancelling)、红灯(Cancelled)。一旦变黄,就只能变红,不能倒回绿灯。而且父路口变黄时,所有子路口也必须跟着变黄。
协作式取消:协程不会被"杀死"
理解了 Job 的状态机,我们来看取消的核心设计:协作式取消(Cooperative Cancellation)。
调用 job.cancel() 之后,协程不会立即停止——它只是在 Job 的状态机上设置了一个"已取消"的标记。协程必须主动检查这个标记,才能真正停止执行。这与线程的 Thread.interrupt() 机制类似——interrupt() 也只是设置一个标志位,线程内部需要检查 Thread.interrupted() 才能做出响应。
为什么选择协作式取消?
如果协程可以被强制杀死(就像 Thread.stop() 那样),会导致严重问题:
- 数据库事务可能写了一半
- 文件可能只写了部分数据
- 网络连接可能没有正确关闭
- 对象可能处于不一致的状态
协作式取消的核心思想是:让协程在安全的时间点自行退出,而不是在任意时刻被强制中断。
取消的两种检测方式
方式一:挂起函数自动检测
kotlinx.coroutines 标准库中的所有挂起函数都是可取消的——它们在恢复之前会检查 Job 的状态:
// delay 的简化实现逻辑
public suspend fun delay(timeMillis: Long) {
// 在挂起之前,先检查取消状态
// 如果 Job 已经处于 Cancelling 状态,直接抛出 CancellationException
return suspendCancellableCoroutine { cont ->
// 设置定时器
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
关键类是 CancellableContinuationImpl——它是 suspendCancellableCoroutine 创建的 Continuation 实现。当协程挂起时,它会向 Job 注册一个取消回调(disposeOnCancellation)。一旦 Job 被取消,该回调会触发,让 Continuation 以 CancellationException 恢复。
常见的可取消挂起函数包括:
| 函数 | 取消行为 |
|---|---|
delay() |
取消时立即恢复并抛出 CancellationException |
yield() |
让出线程前检查取消状态 |
await() |
等待结果前检查取消状态 |
withContext() |
切换上下文前后都会检查取消状态 |
Channel.send/receive |
挂起等待时检查取消状态 |
Flow.collect |
每次发射前检查取消状态 |
方式二:手动检测(CPU 密集型任务)
如果你的协程在执行纯计算任务(没有调用任何挂起函数),它永远不会自动检查取消状态。这时需要手动检查:
isActive 属性——检查但不抛异常:
// ✅ 用 isActive 控制循环退出
val job = launch(Dispatchers.Default) {
var i = 0
while (isActive) { // 每次循环检查取消状态
// CPU 密集型计算
computeStep(i++)
}
// 从 while 自然退出后,可以在这里做清理
println("计算被取消,已完成 $i 步")
}
delay(100)
job.cancelAndJoin() // 取消并等待协程结束
ensureActive() 函数——检查并抛异常:
// ✅ 用 ensureActive 在取消时立即终止
val job = launch(Dispatchers.Default) {
var i = 0
while (true) {
ensureActive() // 若已取消,直接抛出 CancellationException
computeStep(i++)
}
}
ensureActive() 的实现极其简单:
// CoroutineContext 的扩展函数
public fun CoroutineContext.ensureActive() {
get(Job)?.ensureActive() // 从 Context 中取出 Job,检查状态
}
// Job 的 ensureActive
public fun Job.ensureActive() {
if (!isActive) throw getCancellationException()
// 如果不活跃,获取取消原因并包装成 CancellationException 抛出
}
yield() 函数——让出线程 + 检查取消:
// yield 不仅检查取消状态,还让出当前线程给其他协程执行
val job = launch(Dispatchers.Default) {
for (i in 1..1_000_000) {
yield() // 让出线程 + 如果已取消则抛出 CancellationException
computeStep(i)
}
}
yield() 相比 ensureActive() 多了一个功能:它会将当前协程重新排队到 Dispatcher,让同一线程上的其他协程有机会执行。这在公平性很重要的场景下很有用。
三种检测方式的对比
| 方式 | 抛异常? | 让出线程? | 适用场景 |
|---|---|---|---|
isActive |
❌ | ❌ | 需要在取消后执行清理逻辑 |
ensureActive() |
✅ | ❌ | 希望取消时立即中止计算 |
yield() |
✅ | ✅ | 需要公平调度 + 取消检查 |
CancellationException 的特殊地位
在协程的异常体系中,CancellationException 拥有独一无二的特殊地位——它不是"错误",而是"正常的取消信号"。这个设计决策深刻影响了整个异常传播机制。
取消 ≠ 失败
协程运行时严格区分两种"非正常完成":
| 类别 | 异常类型 | 语义 | 对父协程的影响 |
|---|---|---|---|
| 取消(Cancellation) | CancellationException |
"任务被主动叫停" | 不影响父协程 |
| 失败(Failure) | 其他任何 Throwable |
"任务执行出错" | 导致父协程也被取消 |
CancellationException就像工厂里工人收到的"下班通知"——它不是安全事故,只是正常的停工信号。工人(协程)收到通知后有序地收拾工具(释放资源),然后平静地离开。而RuntimeException之类的更像火警——一个车间着火(子协程失败),整个工厂都要疏散(父协程及所有兄弟被取消)。
源码级实现:childCancelled 中的类型判断
这个区分的核心代码在 JobSupport.kt 中:
// JobSupport.kt —— 子 Job 失败时通知父 Job(简化)
public open fun childCancelled(cause: Throwable): Boolean {
// 关键判断:CancellationException 被视为正常取消
if (cause is CancellationException) return true // "我知道了",但不采取行动
// 其他异常:触发自身的取消流程 → 连锁反应
return cancelImpl(cause)
}
当子协程完成时,如果它以 CancellationException 终止,父协程直接返回 true("已处理"),不触发任何连锁取消。但如果是其他异常,父协程会调用自身的 cancelImpl——然后整棵 Job 树都会被波及。
千万不要吞掉 CancellationException
理解了 CancellationException 的特殊地位后,一个常见的致命错误就暴露了:
// ❌ 致命错误:吞掉了 CancellationException
launch {
try {
delay(1000)
} catch (e: Exception) { // Exception 是 CancellationException 的父类
// CancellationException 被捕获后没有重新抛出!
// 协程的取消机制被破坏——它无法正常结束
log("出错了: $e")
}
// 代码继续执行……这个协程变成了"僵尸",即使父 Scope 已取消
}
正确做法:
// ✅ 方案 A:只捕获你关心的具体异常
launch {
try {
delay(1000)
} catch (e: IOException) { // 只捕获 IO 异常,CancellationException 不受影响
log("网络错误: $e")
}
}
// ✅ 方案 B:如果必须捕获 Exception,重新抛出 CancellationException
launch {
try {
delay(1000)
} catch (e: Exception) {
if (e is CancellationException) throw e // 必须重新抛出!
log("业务错误: $e")
}
}
取消与资源清理
协程被取消后,它会沿着 CancellationException 的抛出路径退出。但在退出之前,你通常需要释放资源——关闭数据库连接、释放文件句柄、取消网络请求等。
try-finally:最基础的清理模式
launch {
val connection = openDatabaseConnection()
try {
// 正常业务逻辑(可能在任意挂起点被取消)
val data = connection.query("SELECT * FROM users")
processData(data)
} finally {
// 无论是正常完成、异常还是取消,都会执行
connection.close()
println("数据库连接已关闭")
}
}
finally 块在取消时一定会执行,这是 Kotlin 语言层面的保证(与 Java 的 try-finally 行为一致)。但这里有一个隐蔽的陷阱——
finally 中的挂起操作会失败
一旦协程进入 Cancelling 状态,所有挂起函数都会立即抛出 CancellationException:
launch {
try {
delay(Long.MAX_VALUE)
} finally {
// ⚠️ 此时协程已处于 Cancelling 状态
delay(1000) // 💥 立即抛出 CancellationException!
// 这行代码永远不会执行
println("清理完成")
}
}
为什么这么设计?因为取消意味着"尽快停止"。如果 finally 里可以无限制地执行挂起操作,那取消就永远无法完成——一个恶意的 finally 块可以无限延迟协程的结束。
NonCancellable:在取消后仍需执行挂起操作
但有些清理操作确实需要挂起——比如将中间状态持久化到数据库,或者发送一个"任务已取消"的通知到服务器。这时需要 NonCancellable:
launch {
try {
riskyOperation()
} finally {
// 用 NonCancellable 创建一个"不可取消"的上下文
withContext(NonCancellable) {
// 在这个块中,所有挂起函数都正常工作
saveStateToDatabase() // 挂起操作,不会抛 CancellationException
notifyServer("cancelled") // 挂起操作,正常执行
println("清理完成")
}
}
}
NonCancellable 的实现很简单——它是一个特殊的 Job,永远不会进入 Cancelled 状态:
// NonCancellable.kt(简化)
public object NonCancellable : AbstractCoroutineContextElement(Job), Job {
// 永远返回 true——这个 Job "永远活跃"
override val isActive: Boolean get() = true
// cancel 操作无效
override fun cancel(cause: CancellationException?) {}
}
withContext(NonCancellable) 将当前协程的 Job 临时替换为这个"永远活跃"的 Job,从而让内部的挂起函数不再检测到取消状态。
⚠️ 重要警告:
NonCancellable只应该用在finally块中做关键清理。如果你在正常的业务逻辑中使用它来"绕过取消",那你正在破坏结构化并发的安全保证——协程可能在 Scope 取消后仍然执行,导致内存泄漏或 UI 崩溃。
invokeOnCompletion:另一种清理方式
除了 try-finally,你还可以通过 Job.invokeOnCompletion 注册完成回调:
val job = launch {
longRunningTask()
}
// 注册完成回调——在协程完成(包括取消)时被调用
job.invokeOnCompletion { cause ->
when (cause) {
null -> println("正常完成")
is CancellationException -> println("被取消: ${cause.message}")
else -> println("异常终止: $cause")
}
// 清理资源
releaseResources()
}
invokeOnCompletion 的回调在 Job 进入终态时同步调用(在完成 Job 的线程上执行),且回调中不能执行挂起操作(因为回调签名是普通函数,不是 suspend 函数)。它适用于轻量级的清理工作,比如关闭 IO 流、释放锁等。
异常传播机制:launch vs async 的根本差异
理解了取消之后,我们进入第二大主题:异常传播。当协程中抛出了一个非 CancellationException 的异常时,会发生什么?
默认行为:一个孩子出事,全家受影响
子协程 C 抛出 IOException
│
├── ① C 自身进入 Cancelling 状态
│
├── ② C 通知父协程 P:childCancelled(IOException)
│ │
│ └── 父协程 P 调用 cancelImpl(IOException)
│ │
│ ├── P 进入 Cancelling 状态
│ │
│ └── P 通知所有其他子协程取消(CancellationException)
│ ├── 子协程 A → 取消
│ └── 子协程 B → 取消
│
└── ③ 异常继续向上传播(如果 P 也有父协程)
这个传播过程在源码中的调用链:
子协程异常
→ JobSupport.cancelParent(cause) // 子协程通知父协程
→ 父 JobSupport.childCancelled(cause) // 父协程处理子协程的异常
→ 父 JobSupport.cancelImpl(cause) // 父协程取消自身
→ notifyCancelling() // 递归取消所有子协程
launch 与 async 的异常处理差异
两种构建器对异常的处理完全不同,根本原因在于它们的设计目标不同:
launch:自动传播(Propagate)
launch 创建的 StandaloneCoroutine 会在异常发生时立即向上传播:
// StandaloneCoroutine —— launch 创建的协程类型(简化)
private class StandaloneCoroutine(
parentContext: CoroutineContext,
active: Boolean
) : AbstractCoroutine<Unit>(parentContext, initParentJob = true, active = active) {
override fun handleJobException(exception: Throwable): Boolean {
// 关键:调用 CoroutineExceptionHandler 或默认的线程异常处理器
handleCoroutineException(context, exception)
return true
}
}
异常发生后,StandaloneCoroutine 首先通知父 Job(触发连锁取消),然后调用 handleCoroutineException 来做最后的异常处理。
async:暴露给调用者(Expose)
async 创建的 DeferredCoroutine 不会自动传播异常——它把异常存储在 Deferred 对象中,等到调用 await() 时才重新抛出:
// DeferredCoroutine —— async 创建的协程类型(简化)
private class DeferredCoroutine<T>(
parentContext: CoroutineContext,
active: Boolean
) : AbstractCoroutine<T>(parentContext, initParentJob = true, active = active),
Deferred<T> {
// 注意:没有 override handleJobException
// 异常被存储在内部状态中
override suspend fun await(): T = awaitInternal() as T
// awaitInternal 检查内部状态,如果是异常则重新抛出
}
这意味着:
val scope = CoroutineScope(Job())
// launch:异常立即传播
scope.launch {
throw IOException("网络错误")
// → 异常立即传播到 scope → scope 被取消 → 所有子协程取消
}
// async:异常被"存储"
val deferred = scope.async {
throw IOException("网络错误")
// → 异常被存储在 Deferred 对象中
}
// 异常在调用 await() 时才重新抛出
try {
deferred.await()
} catch (e: IOException) {
// 在这里处理异常
}
但有一个关键的"但是":即使 async 会暴露异常给 await() 调用者,它仍然会通知父 Job。如果 async 是 coroutineScope 内部的子协程,异常仍然会触发父协程的取消:
// ⚠️ 即使不调用 await(),异常也会传播到父 Scope
coroutineScope {
val d1 = async { throw IOException("boom") } // 异常传播给 coroutineScope
val d2 = async { delay(1000) } // d2 也会被取消
d1.await() // 这行可能根本执行不到——coroutineScope 已经被取消了
d2.await()
}
对比总结
| 特性 | launch |
async |
|---|---|---|
| 返回类型 | Job |
Deferred<T> |
| 异常行为 | 自动传播到调度器/CEH | 存储在 Deferred 中,await() 时重新抛出 |
| 对父协程的影响 | 通知父 Job → 触发连锁取消 | 同样通知父 Job → 触发连锁取消 |
CoroutineExceptionHandler 有效? |
✅ | ❌(异常被视为"已处理") |
CoroutineExceptionHandler:最后一道防线
CoroutineExceptionHandler 是协程上下文中的一个 Element,用于处理未被捕获的异常。但它的生效条件非常严格,稍有不慎就会"装了但不生效"。
生效条件
CoroutineExceptionHandler 只在以下条件同时满足时才会被调用:
- 异常来自
launch(不是async——因为async的异常被Deferred封装了) - Handler 安装在根协程或
SupervisorJob/supervisorScope的直接子协程上
为什么子协程上的 CEH 不生效?因为子协程的异常会委托给父协程处理。子协程自身的 Handler 根本没机会被调用——异常已经向上传走了。
安装位置的正反例
val handler = CoroutineExceptionHandler { _, exception ->
println("捕获异常: $exception")
}
// ✅ 正确:安装在 Scope(根协程)上
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + handler)
scope.launch {
throw IOException("网络错误") // → handler 被调用 ✅
}
// ✅ 正确:安装在 supervisorScope 的直接子协程上
supervisorScope {
launch(handler) {
throw IOException("网络错误") // → handler 被调用 ✅
}
}
// ❌ 错误:安装在普通 coroutineScope 的子协程上
coroutineScope {
launch(handler) {
throw IOException("网络错误")
// → 异常先传播给 coroutineScope 的父 Job
// → handler 不会被调用 ❌
}
}
// ❌ 错误:安装在 async 上
val scope2 = CoroutineScope(SupervisorJob() + handler)
scope2.async {
throw IOException("网络错误")
// → async 不调用 handleJobException
// → handler 不会被调用 ❌
}
CEH 的内部调用链
异常最终到达 CEH 的完整路径:
launch 中抛出异常
→ AbstractCoroutine.resumeWith(Result.failure(e))
→ JobSupport.makeCompletingOnce(e)
→ JobSupport.tryMakeCompleting(e)
→ JobSupport.cancelParent(e) // 通知父 Job
→ JobSupport.cancelMakeCompleting(e)
→ StandaloneCoroutine.handleJobException(e)
→ handleCoroutineException(context, e)
→ context[CoroutineExceptionHandler]?.handleException(context, e)
└── 如果有 CEH → 调用它
└── 如果没有 → 调用 Thread 的 UncaughtExceptionHandler → 应用崩溃
SupervisorJob:故障隔离的源码级剖析
上一篇文章简单介绍了 SupervisorJob 的隔离行为。现在让我们深入源码,看看它到底"改了什么"使得一个子协程的失败不会影响其他子协程。
SupervisorJob vs 普通 Job 的唯一区别
答案只有一行代码——在 childCancelled 方法中:
// 普通 Job(JobSupport.kt)
public open fun childCancelled(cause: Throwable): Boolean {
if (cause is CancellationException) return true
return cancelImpl(cause) // ← 非 CancellationException 的异常会取消自身
}
// SupervisorJob(Supervisor.kt)
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
override fun childCancelled(cause: Throwable): Boolean {
return false // ← 直接返回 false:"我不管子协程的异常"
}
}
就这么一行。普通 Job 在子协程异常时会调用 cancelImpl(cause) 取消自身(连锁反应);SupervisorJob 直接返回 false,表示"我不处理这个异常"。异常不会向上传播,父 Job 和兄弟 Job 都不受影响。
coroutineScope vs supervisorScope
它们的区别同样反映在 childCancelled 的行为上:
// coroutineScope 的内部实现(简化)
private class ScopedCoroutine<T>(context: CoroutineContext) :
AbstractCoroutine<T>(context) {
// 使用默认的 childCancelled → 子协程异常会取消自身
}
// supervisorScope 的内部实现(简化)
private class SupervisorCoroutine<T>(context: CoroutineContext) :
ScopedCoroutine<T>(context) {
override fun childCancelled(cause: Throwable): Boolean = false
// 子协程异常不影响自身 → 其他子协程继续运行
}
用一个图来对比:
coroutineScope(默认 Job) supervisorScope(SupervisorJob)
│ │
├── 子协程 A(失败 💥) ├── 子协程 A(失败 💥)
│ ↓ childCancelled │ ↓ childCancelled
│ 父 Scope 取消 │ → false(不处理)
│ ↓ │
├── 子协程 B → 被取消 ❌ ├── 子协程 B → 继续运行 ✅
└── 子协程 C → 被取消 ❌ └── 子协程 C → 继续运行 ✅
实战:ViewModel 中的 SupervisorJob 隔离
回顾上一篇文章中 viewModelScope 的实现:
public val ViewModel.viewModelScope: CoroutineScope
get() = CoroutineScope(
SupervisorJob() + Dispatchers.Main.immediate
)
为什么 viewModelScope 使用 SupervisorJob 而不是普通 Job?因为 ViewModel 中的多个操作通常是独立的:
class DashboardViewModel : ViewModel() {
fun loadDashboard() {
// 三个独立的请求,互不影响
viewModelScope.launch {
try {
val user = withContext(Dispatchers.IO) { userRepo.getUser() }
_userState.value = UiState.Success(user)
} catch (e: Exception) {
_userState.value = UiState.Error(e.message)
}
}
viewModelScope.launch {
try {
val orders = withContext(Dispatchers.IO) { orderRepo.getOrders() }
_ordersState.value = UiState.Success(orders)
} catch (e: Exception) {
_ordersState.value = UiState.Error(e.message)
}
}
viewModelScope.launch {
try {
val stats = withContext(Dispatchers.IO) { statsRepo.getStats() }
_statsState.value = UiState.Success(stats)
} catch (e: Exception) {
_statsState.value = UiState.Error(e.message)
}
}
}
}
如果 viewModelScope 使用普通 Job,任何一个请求的未处理异常都会取消整个 Scope——用户信息加载失败,订单列表和统计数据也跟着消失。使用 SupervisorJob 后,每个请求独立运行,一个失败不影响其他。
但也要注意:当多个操作构成一个原子整体时,应该使用 coroutineScope:
// 这里用 coroutineScope,因为两个请求的结果需要一起使用
// 如果任何一个失败了,另一个也没有意义
suspend fun loadUserWithOrders(userId: String): UserWithOrders = coroutineScope {
val user = async { userRepo.getUser(userId) }
val orders = async { orderRepo.getOrders(userId) }
// 如果 getOrders 失败 → coroutineScope 取消 → getUser 也被取消 ✅
UserWithOrders(user.await(), orders.await())
}
withTimeout:取消的定时机制
超时是取消的一个常见应用场景——如果某个操作在规定时间内没有完成,就取消它。
withTimeout 的实现原理
// withTimeout 的简化实现
public suspend fun <T> withTimeout(
timeMillis: Long,
block: suspend CoroutineScope.() -> T
): T {
// 创建一个子协程
val coroutine = TimeoutCoroutine(timeMillis, ...)
// 在子协程中执行 block
// 同时启动一个定时器,超时后调用 coroutine.cancel(TimeoutCancellationException)
return coroutine.startUndispatched(block)
}
withTimeout 在超时后抛出的是 TimeoutCancellationException——它是 CancellationException 的子类。这意味着:
try {
withTimeout(1000) {
// 超时后抛出 TimeoutCancellationException(CancellationException 的子类)
delay(Long.MAX_VALUE)
}
} catch (e: TimeoutCancellationException) {
// ✅ 可以捕获超时异常
println("操作超时")
}
但有一个陷阱:TimeoutCancellationException 虽然是 CancellationException,但它只在 withTimeout 的外部被视为"可捕获的异常"。在其内部,它的行为和普通的取消一样——所有挂起函数都会立即终止。
withTimeoutOrNull:安全的替代方案
如果你不想处理异常,可以使用 withTimeoutOrNull——它在超时时返回 null 而不是抛出异常:
// 超时返回 null,不抛异常
val result: User? = withTimeoutOrNull(3000) {
fetchUserFromNetwork()
}
if (result != null) {
showUser(result)
} else {
showTimeoutMessage()
}
这种方式更加 Kotlin 风格——用空安全替代了异常控制流。
异常处理最佳实践
总结前面所有的知识,这里给出实战中的异常处理基本法则。
法则一:try-catch 放在协程体内部
// ✅ 推荐:在协程内部捕获异常
viewModelScope.launch {
try {
val data = withContext(Dispatchers.IO) {
repository.fetchData()
}
_state.value = UiState.Success(data)
} catch (e: IOException) {
_state.value = UiState.Error("网络错误")
} catch (e: Exception) {
if (e is CancellationException) throw e // 不要吞掉取消异常!
_state.value = UiState.Error("未知错误")
}
}
// ❌ 不推荐:在协程外部 try-catch(对 launch 无效)
try {
viewModelScope.launch {
throw IOException() // 异常不会被外面的 try-catch 捕获!
}
} catch (e: Exception) {
// 永远不会执行到这里
}
为什么外部 try-catch 捕获不到 launch 的异常?因为 launch 是一个普通函数(不是 suspend 函数),它立即返回一个 Job,协程体在另一个时间点异步执行。当异常发生时,try 块早已结束。
法则二:用 supervisorScope 隔离独立任务
// ✅ 多个独立操作,一个失败不应该影响其他
suspend fun loadAllData() = supervisorScope {
val userJob = launch {
// 失败了只影响自己
_userState.value = try {
UiState.Success(fetchUser())
} catch (e: Exception) {
UiState.Error(e.message)
}
}
val ordersJob = launch {
// 即使 fetchUser() 失败,这里仍然继续
_ordersState.value = try {
UiState.Success(fetchOrders())
} catch (e: Exception) {
UiState.Error(e.message)
}
}
}
法则三:用 coroutineScope 组合原子操作
// ✅ 两个操作必须都成功,任何一个失败就全部取消
suspend fun transfer(from: Account, to: Account, amount: Double) = coroutineScope {
val debit = async { bankApi.debit(from, amount) }
val credit = async { bankApi.credit(to, amount) }
// 如果 debit 失败 → coroutineScope 取消 → credit 也取消
// 这正是我们期望的事务语义
debit.await()
credit.await()
}
法则四:CEH 只用于兜底日志,不用于业务逻辑
// ✅ CEH 作为最后一道防线,用于崩溃上报
val crashReporter = CoroutineExceptionHandler { _, exception ->
// 上报到 Crashlytics / Sentry 等
CrashReporter.report(exception)
}
class MyApplication : Application() {
val applicationScope = CoroutineScope(
SupervisorJob() + Dispatchers.Main + crashReporter
)
}
CEH 不应该用来实现业务逻辑(比如"如果网络错就显示缓存数据")——业务级别的异常处理应该用 try-catch 在协程内部完成。CEH 只是"最后的兜底",类似于 Thread.UncaughtExceptionHandler 的定位。
法则五:async 的异常在 await 处理
// ✅ async 的异常在 await() 调用处捕获
supervisorScope {
val deferred = async {
riskyNetworkCall() // 可能抛异常
}
try {
val result = deferred.await()
processResult(result)
} catch (e: IOException) {
handleNetworkError(e)
}
}
完整的异常传播决策流程图
协程中抛出异常
│
├── 是 CancellationException?
│ ├── 是 → 正常取消流程
│ │ ├── 取消子协程(向下传播)
│ │ ├── 不通知父协程(不向上传播)
│ │ └── Job 进入 Cancelled 状态
│ │
│ └── 否 → 失败流程
│ │
│ ├── 通知父 Job:childCancelled(cause)
│ │ │
│ │ ├── 父 Job 是普通 Job?
│ │ │ └── 是 → 父 Job 调用 cancelImpl → 取消自身和所有子协程
│ │ │ → 异常继续向上传播
│ │ │
│ │ └── 父 Job 是 SupervisorJob?
│ │ └── 是 → return false(不处理)→ 异常不向上传播
│ │
│ ├── 当前协程是 launch 创建的?
│ │ └── 是 → 调用 handleJobException
│ │ → 查找 CoroutineExceptionHandler
│ │ → 找到则调用它处理异常
│ │ → 找不到则通过 Thread.UncaughtExceptionHandler(应用崩溃)
│ │
│ └── 当前协程是 async 创建的?
│ └── 是 → 异常存储在 Deferred 中
│ → 调用 await() 时重新抛出
│ → 不调用 handleJobException(CEH 无效)
本章小结
本文从源码层面完整剖析了 Kotlin 协程的取消与异常处理机制:
| 知识点 | 核心结论 |
|---|---|
| Job 状态机 | 6 种状态(New → Active → Completing/Cancelling → Completed/Cancelled),通过 CAS 原子操作保证线程安全 |
| 协作式取消 | cancel() 只设标记,不强制停止。协程通过挂起函数或手动检查(isActive/ensureActive/yield)来响应取消 |
| CancellationException | 不是"错误",是"正常的取消信号"——不触发父协程取消,不被 CEH 处理。千万不要在 catch (e: Exception) 中吞掉它 |
| 资源清理 | try-finally + NonCancellable(用于 finally 中需要挂起的操作)+ invokeOnCompletion(轻量回调) |
| 异常传播 | launch 自动传播(fire-and-forget),async 存储在 Deferred 中(awaiter catches)。两者都会通知父 Job |
SupervisorJob |
childCancelled 返回 false——子协程的异常不向上传播,兄弟协程不受影响 |
CoroutineExceptionHandler |
只对根协程或 SupervisorJob 直接子协程中的 launch 有效。是"最后的兜底",不替代 try-catch |
withTimeout |
超时后抛出 TimeoutCancellationException(CancellationException 的子类)。优先用 withTimeoutOrNull 避免异常控制流 |
下一篇文章《Flow 深度解析》将聚焦于 Kotlin Flow 的背压策略、冷流与热流的差异、以及 StateFlow / SharedFlow 在 Android 架构中的最佳实践。