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

参考资料

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen4 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone4 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui