协程任务的失败控制:取消、异常传播与Supervisor

协程任务的失败控制:取消、异常传播与Supervisor

协程任务形成父子结构以后,取消和异常就不再是单个代码块内部的事情。父任务取消会向下传播,子任务的未处理异常可能向上传播,而Supervisor只改变其中一部分规则。

这套机制最容易出现两种误解:把cancel()理解成强制终止线程,或者认为Supervisor能够阻断所有取消和异常。实际运行中,取消依赖代码主动响应,Supervisor也只能隔离子任务失败,不能切断父任务的生命周期管理。

内容摘要

  1. 协程取消是协作式的,cancel()只发出取消信号。
  2. 可取消挂起点会响应取消;CPU密集代码需要主动建立检查点。
  3. 普通Job中,子任务未处理异常会使父任务失败并取消兄弟任务。
  4. Supervisor隔离子任务失败,但父任务取消仍然向下传播。
  5. 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收到取消
→ 全部子任务仍然被取消

它只隔离"子任务失败向上影响父任务和兄弟任务",不阻断"父任务取消向下管理子任务"。

SupervisorJobsupervisorScope

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适合相互独立的任务。无论选择哪一种,父任务仍然管理子任务的生命周期,取消也仍然依赖代码协作。清晰的业务关系,是选择传播模型的前提。

参考资料

相关推荐
Android打工仔2 小时前
从 finally 理解程序的控制流:它为什么不是 catch 后面的代码?
android·kotlin
随遇丿而安2 小时前
第15周:Service 全功能 + 后台优化
android
YXL1111YXL2 小时前
续体和状态机 —— suspend 函数的 CPS 变换
android·kotlin
WAsbry2 小时前
Flow数据模型:冷流、状态、事件与共享策略
android
WAsbry2 小时前
深入理解 Kotlin suspend:挂起语义、状态机与恢复调度
android
WAsbry2 小时前
Flow在Android中的完整落地:异常、重试与生命周期
android
WAsbry2 小时前
协程共享状态:Atomic、Mutex、Semaphore与状态封闭
android
WAsbry2 小时前
Flow执行模型:上下文保持与生产消费节奏
android
WAsbry2 小时前
协程任务的组织方式:launch、async、withContext与结构化并发
android