协程任务的失败控制:取消、异常传播与Supervisor
协程任务形成父子结构以后,取消和异常就不再是单个代码块内部的事情。父任务取消会向下传播,子任务的未处理异常可能向上传播,而Supervisor只改变其中一部分规则。
这套机制最容易出现两种误解:把cancel()理解成强制终止线程,或者认为Supervisor能够阻断所有取消和异常。实际运行中,取消依赖代码主动响应,Supervisor也只能隔离子任务失败,不能切断父任务的生命周期管理。
内容摘要
- 协程取消是协作式的,
cancel()只发出取消信号。 - 可取消挂起点会响应取消;CPU密集代码需要主动建立检查点。
- 普通Job中,子任务未处理异常会使父任务失败并取消兄弟任务。
- Supervisor隔离子任务失败,但父任务取消仍然向下传播。
SupervisorJob适合长期独立任务,supervisorScope适合函数内部的临时监督边界。

一、取消为什么必须由代码协作
cancel()发送的是状态变化
scss
val job = scope.launch {
// delay 是可取消挂起点,会在任务取消后抛出 CancellationException
delay(10_000)
}
// cancel 只发出取消信号,不会强制终止承载线程
job.cancel()
调用cancel()后,Job进入取消状态。协程运行到delay这类可取消挂起点时,会收到CancellationException并结束。
整个过程可以概括为:
Job收到取消信号
→ 代码经过可取消挂起点或主动检查
→ 抛出CancellationException或自行退出
→ 执行资源清理
取消不会直接杀死承载协程的线程。强制终止线程会破坏同一线程上运行的其他任务,也无法保证finally中的资源清理正常执行。
CPU密集循环需要建立检查点
没有挂起点的计算代码不会自动感知取消:
scss
scope.launch(Dispatchers.Default) {
// CPU 循环没有自然挂起点,需要主动检查取消状态
while (isActive) {
doHeavyWork()
}
}
常用检查方式有三种:
arduino
isActive → 查询状态,由代码决定如何退出
ensureActive → 已取消时立即抛出CancellationException
yield → 检查取消并让出当前执行机会
yield()不保证切换线程,也不应机械插入每个循环。检查频率需要在取消响应速度和计算开销之间平衡。
CancellationException不是普通失败
CancellationException用于表达协程正常取消。单独取消一个子任务时,它通常不会被当作父任务失败。
捕获宽泛异常时需要保留取消语义:
scss
try {
loadData()
} catch (error: CancellationException) {
// 取消属于生命周期信号,必须继续向上抛出
throw error
} catch (error: Throwable) {
// 只有真正的执行失败才转换为业务错误
handleFailure(error)
}
误吞CancellationException会让已经失效的任务继续执行,破坏结构化生命周期。
本节小结
取消是一种受控的生命周期协作,而不是线程终止。可靠的取消需要Job发出信号、代码建立响应点,并允许资源清理逻辑正常完成。
二、普通Job中的异常传播
未处理异常如何影响任务树
在普通父子Job结构中,子任务抛出未处理的非取消异常时:
子任务失败
→ 异常向上传播
→ 父任务失败
→ 父任务取消其他子任务
这种传播适合多个步骤共同完成一个目标的场景。任何关键步骤失败,整体结果就不再成立。
scss
coroutineScope {
// 两个子任务共同完成页面装配,采用共同成败语义
val user = async { api.loadUser() }
val permission = async { api.loadPermission() }
// 任一 await 失败都会使作用域失败,并取消另一个子任务
buildPage(
user.await(),
permission.await()
)
}
如果用户或权限数据缺失,页面装配无法继续,两个任务共同成败符合业务语义。
正常取消与执行失败并不相同
正常取消子任务
→ 使用CancellationException表达
→ 通常只影响当前子任务
子任务执行失败
→ 未处理的普通异常逃出协程
→ 普通Job中使父任务失败
父任务取消
→ 向下取消全部子任务
区分这三种路径,才能正确理解兄弟任务为什么有时继续运行,有时被一起取消。
CoroutineExceptionHandler不是传播开关
CoroutineExceptionHandler用于处理没有其他传播路径的未捕获异常,常见于根协程或监督结构中的launch。它不能阻止普通父子关系中的异常传播,也不能替代局部try-catch。
async的结果和异常通过Deferred表达,调用await()时会重新抛出对应异常。异常不是在await()调用瞬间才产生,只是通过await()重新暴露给调用方。
本节小结
普通Job强调共同成败。未处理异常从子任务进入父任务,再由父任务取消其余子任务。Handler负责最终观察,不能改变这条结构化传播路径。
三、Supervisor改变了哪一段传播
Supervisor隔离子任务失败
scss
val scope = CoroutineScope(
// 子任务失败不会自动取消同级任务
SupervisorJob() + Dispatchers.Main
)
scope.launch {
// 独立任务一:刷新资料
refreshProfile()
}
scope.launch {
// 独立任务二:持续观察消息
observeMessages()
}
如果资料刷新和消息监听互不依赖,其中一个失败不应自动取消另一个。SupervisorJob允许每个子任务独立失败,但每个失败仍然需要自行处理。
Supervisor改变的是:
子任务失败
不再自动使监督父任务失败
也不再自动取消兄弟任务
父任务取消仍然向下传播
Supervisor并没有取消结构化生命周期:
父任务取消
→ Supervisor收到取消
→ 全部子任务仍然被取消
它只隔离"子任务失败向上影响父任务和兄弟任务",不阻断"父任务取消向下管理子任务"。
SupervisorJob与supervisorScope
sql
SupervisorJob
→ 常用于长期Scope的监督父Job
supervisorScope
→ 挂起函数内部的临时监督边界
→ 等待内部全部子任务结束
scss
suspend fun refreshWidgets() = supervisorScope {
// 三个组件可以独立成功或失败,但仍共享外部取消边界
launch { refreshWeather() }
launch { refreshCalendar() }
launch { refreshNews() }
}
三个组件相互独立时,一个组件失败不必取消其他组件。但如果外部页面任务被取消,三个子任务仍会一起结束。
如果supervisorScope代码块自身抛出异常,该作用域也会失败,并取消内部子任务。监督不是忽略错误,而是重新定义独立任务之间的失败边界。
工程场景:仪表盘多个模块独立刷新
一个聚合页面可能同时刷新资料、消息和推荐内容。三者共享页面生命周期,但业务结果相互独立:
页面关闭
→ 三个刷新任务都应取消
推荐接口失败
→ 资料和消息仍应继续刷新
→ 推荐模块独立显示错误状态
如果直接使用普通coroutineScope,任一子任务的未处理异常都会使其他刷新任务一起取消。只把它替换成supervisorScope仍然不完整:失败虽然不会拖垮兄弟任务,但launch中的异常仍需在各自边界处理。
kotlin
suspend fun refreshDashboard() = supervisorScope {
// 每个模块使用独立子任务,单个失败不会拖垮其他模块
launch {
refreshModule(Module.Profile) {
profileRepository.refresh()
}
}
launch {
refreshModule(Module.Messages) {
messageRepository.refresh()
}
}
launch {
refreshModule(Module.Recommendations) {
recommendationRepository.refresh()
}
}
}
private suspend fun refreshModule(
module: Module,
block: suspend () -> Unit
) {
try {
// 执行模块刷新,并把成功结果写入对应模块状态
block()
dashboardState.updateSuccess(module)
} catch (error: CancellationException) {
// 页面整体取消必须继续传播,不能转成模块失败
throw error
} catch (error: Throwable) {
// 普通异常只影响当前模块
dashboardState.updateFailure(module, error)
}
}
这套结构把三种责任分开:
arduino
supervisorScope
→ 隔离模块之间的执行失败
每个子任务的try-catch
→ 把失败转换成该模块的可观察状态
CancellationException继续抛出
→ 保留页面关闭时的整体取消
如果三个请求必须共同生成一个不可拆分的页面结果,就不应使用这套隔离结构。监督关系来自模块是否允许独立失败,而不是为了让异常"看起来更安全"。
本节小结
Supervisor只隔离子任务失败,不隔离父任务取消。SupervisorJob适合长期管理多个独立任务,supervisorScope适合在函数内部建立一次明确的监督区间。
四、从业务关系选择传播模型
选择普通Job还是Supervisor,取决于任务之间是否存在共同结果:
| 任务关系 | 推荐结构 | 失败语义 |
|---|---|---|
| 多个步骤共同构成一个结果 | coroutineScope / 普通Job |
任一关键步骤失败,整体失败 |
| 多个任务相互独立 | supervisorScope / SupervisorJob |
单个失败不自动影响其他任务 |
| 页面或业务对象整体销毁 | 两者都响应父取消 | 全部子任务结束 |
不应为了"避免崩溃"把所有任务都放进Supervisor。错误的隔离会让本应整体失败的业务继续产生不完整结果。
本节小结
传播模型应由任务是否共同构成结果决定。普通Job表达共同成败,Supervisor表达子任务相互独立;两者都保留父任务对整体生命周期的管理。
结语:失败控制首先是业务建模
取消、异常和监督不是三个孤立API:
取消定义任务何时失效
异常定义任务为何失败
Supervisor定义失败是否影响兄弟任务
普通Job适合共同完成目标,Supervisor适合相互独立的任务。无论选择哪一种,父任务仍然管理子任务的生命周期,取消也仍然依赖代码协作。清晰的业务关系,是选择传播模型的前提。