协程任务的失败控制:取消、异常传播与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收到取消
→ 全部子任务仍然被取消

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

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

参考资料

相关推荐
booboosui2 小时前
Android 上来电怎么判:7 个条件加 5 个中文词
android
MardaWang2 小时前
App 内嵌 H5 的信任边界设计(ArkWeb/HarmonyOS 实战)
android
石头慢慢说2 小时前
不写 XPath,让 AI 在真机上跑测试:Appium MCP 全流程实战
android
90后的晨仔4 小时前
解决 Android 构建报错:Unable to delete file .../file-map.txt
android·前端
mg6685 小时前
安卓玩机工具----安卓手机变成桌面电脑!多窗口镜像+音频投射+游戏操控,无需 Root
android·智能手机·电脑
peakmain95 小时前
异常处理 - Native 层的崩溃捕获机制及实现 (一)
android·性能优化
木头左6 小时前
【Android】听写播放器改版:中英文书写速度拆项与「正在读」高亮
android·room·media3·jetpack compose·听写
好的佩奇7 小时前
Dart 之方法、操作符与可调用对象
android·flutter·dart
恋猫de小郭7 小时前
Flutter Impeller Vulkan 内存持续上涨,问题没想到是文字阴影
android·前端·flutter