5.4 取消、超时与异常传播


5.4 取消、超时与异常传播

本节摘要:取消是协程最容易写错的部分:它是协作式的,需要代码主动响应;取消异常 CancellationException 参与控制流,误捕会破坏整个结构。本节讲取消的传播与响应、清理代码的正确写法、withTimeout 的语义、Job 与 SupervisorJob 的异常传播差别、CoroutineExceptionHandler 的安装位置,以及安卓页面的标准防崩组合。

取消为什么是"协作式"的

先立机制,再看事故。job.cancel() 做两件事:把 Job 状态置为 Cancelling,同时向协程所在的线程发出一个中断般的信号——具体形态是让协程在下一个挂起点抛出 CancellationException。关键词是"下一个挂起点":

val job = scope.launch { var i = 0 while (true) { // 没有挂起点的死循环 i++ } // cancel 之后这个循环照转 } delay(100) job.cancel() // 取消信号 无处抛出

这个协程永远停不下来——循环里没有挂起点,CancellationException 没有机会抛出。Kotlin 选择了协作式取消(而不是杀线程式的抢占式),原因是确定性:协程只在明确的边界点(挂起点)被打断,中间的代码块天然是原子的,不用担心任意一行执行到一半被杀。代价是开发者必须在这些边界点配合。响应方式三种,按场景选:

// 方式一 循环条件主动检查 while (isActive) { renderFrame() } // isActive 是 CoroutineScope 的扩展属性 // 方式二 清理前显式确认 fun loop() { while (true) { renderFrame() ensureActive() // 已取消则立刻抛取消异常 } } // 方式三 依赖挂起点 while (true) { pollOnce() delay(16) // delay 是挂起点 取消在此生效 }

CPU 密集循环用一或二,等待型循环天然有三。不响应取消的后果不是报错,而是取消失效:页面销毁了任务还在烧电、还在发请求——泄漏从"内存"升级为"流量与电量"。

清理代码:finally 与 NonCancellable

取消时要做清理(关文件、发埋点、恢复状态),直觉写 try-finally:

scope.launch { try { processFile() } finally { closeFile() // 大多数清理 OK } }

finally 在取消异常抛出后确实会执行。但如果清理本身是挂起操作(比如向服务器发一条"已中断"埋点),问题来了:协程已处于取消中,任何挂起点立刻再抛 CancellationException——清理代码刚起飞就被打断:

finally { tracker.reportInterrupted() // suspend 函数 直接再抛取消异常 上报丢失 }

解法是把清理包进 withContext(NonCancellable)——一个"暂停可取消性"的特殊上下文:

finally { withContext(NonCancellable) { tracker.reportInterrupted() // 不受取消影响 稳定送达 } }

NonCancellable 只应包裹"必须完成的收尾",不要拿它包业务逻辑——那等于局部放弃了结构化并发的全部保障。

误捕 CancellationException:最隐蔽的坑

第 2 章埋的线在这里收拢。runCatching 与过宽的 catch 会把取消异常当普通失败捕获:

viewModelScope.launch { try { repo.syncAll() // 挂起 用户退出 取消异常从这里抛出 } catch (e: Exception) { _state.value = UiState.Error(e) // 误捕 页面已销毁还在发状态 } }

用户退出页面,本该随作用域安静终止的协程,被 catch 块截住,继续执行状态更新——结构化并发的取消被人为打断。正确写法是先放行取消:

try { repo.syncAll() } catch (e: CancellationException) { throw e // 取消不是失败 让它走 } catch (e: Exception) { _state.value = UiState.Error(e.message ?: "同步失败") }

这条纪律同样适用于第 2 章的 runSuspendCatching 封装。判断标准:catch 到的异常是不是要向用户展示的业务失败?取消不是,中断不是,它们是控制流的一部分

超时:withTimeout 的两层语义

val result = withTimeout(3000) { repo.fetchBanner() // 三秒内完成返回 超时抛 TimeoutCancellationException }

TimeoutCancellationException 是 CancellationException 的子类——超时会取消 withContext 块内的全部子协程,然后向上抛。两个实践要点。其一,超时是"抛出"不是"返回 null",要柔性降级用 withTimeoutOrNull:

val banner = withTimeoutOrNull(3000) { repo.fetchBanner() } ?: Banner.default() // 超时给默认横幅 不炸流程

其二,超时取消的是 withTimeout 块内的任务;块内如果又是"不可取消的 CPU 死循环",超时照样停不下来——协作式取消的边界在这里再次显形。

取消与异常的传播路径

下图把 Job 树上的两类信号流画清楚:取消向下广播,异常向上上报,SupervisorJob 与 CoroutineExceptionHandler 是两道改向闸门。

取消与异常的传播路径

SupervisorJob 与异常处理器:安卓的标准组合

Job 树默认"一损俱损"(5.2 节规则二),但很多场景要的是"一个熄灭别拉着兄弟":列表页的十个图片加载协程,一张失败不该熄灭整个页面。SupervisorJob 改写父子传播规则:子失败不取消父、不牵连兄弟,失败只上报给自己作用域的处理器:

val handler = CoroutineExceptionHandler { _, e -> log("页面级兜底", e) // 最后防线 记日志 上报崩溃平台 _state.value = UiState.Error(e.message ?: "未知错误") } val pageScope = CoroutineScope(SupervisorJob() + Dispatchers.Main + handler)

好消息是jetpack 已把这套组合做成了默认:viewModelScope 的实现就是 SupervisorJob 加 Dispatchers.Main——ViewModel 里 launch 的兄弟任务互不牵连,未处理异常最终抛给安卓全局处理器。自己建作用域时(比如 Application 级的常驻作用域)记得手写这个组合,漏了 SupervisorJob,一个网络错误就会掀翻整个作用域。

CoroutineExceptionHandler 的安装位置有讲究:它装在作用域创建时才生效,launch 处传无效;async 的异常不进 handler(它认为你会 await——await 时会重新抛出)。这些细节记不全没关系,记住组合模板:页面级 SupervisorJob 加 handler 兜底,业务级 coroutineScope 事务性收场,两者别混用

一次完整的退出流程串讲

把本节机制串进一个真实场景:用户在图片批量下载页按了返回。

class BatchDownloadVM : ViewModel() { private val handler = CoroutineExceptionHandler { _, e -> log("下载批次异常", e) } fun downloadAll(urls: List<String>) { viewModelScope.launch(handler) { // SupervisorJob 已内建 val dispatcher = Dispatchers.IO.limitedParallelism(4) coroutineScope { // 批次事务 urls.forEach { url -> launch(dispatcher) { try { downloader.fetch(url) } finally { withContext(NonCancellable) { cache.markDone(url) // 取消时也标记完成项 } } } } } // 全部完成或整批取消 } } // 用户退出 ViewModel 清理 viewModelScope 整树取消 // 批次里每个任务在挂起点响应取消 finally 用 NonCancellable 标记进度 }

退出瞬间发生的事:viewModelScope.cancel → 批次事务与全部子任务进入取消 → 每个子任务在下一个挂起点抛出取消异常 → finally 执行、NonCancellable 保证标记落盘 → 树安静熄灭,无泄漏、无崩溃、无"页面没了还在发请求"。这就是取消机制的全貌——不是杀线程,是一场有秩序的撤离

与 Flow 取消的衔接与全局兜底

取消机制在 Flow 上有一处延伸值得提前衔接:collect 本身就是一个挂起点,取消收集者的协程,流的上下游一起停(冷流没有独立生命)。所以 5.5 节的 repeatOnLifecycle 之所以能"后台停收集",底层正是本节的取消沿作用域树传播到 collect、再传播到上游生产。反过来,Flow 的操作符里也有取消的参与面:mapLatest 的"取消旧处理"、take 的"取满即取消上游"、first 的"拿到即取消"——它们都是结构化取消在流式世界的复用。理解了本节,那些操作符的行为不再是魔法。

最后补全局兜底的位置。协程的未处理异常最终去哪?没有 handler 的作用域上,异常被抛给线程的未捕获异常处理器——安卓上就是全局崩溃。两个收尾动作:Debug 构建里让全局处理器直接崩溃(快速暴露,配合 StrictMode 哲学一致);Release 里接入崩溃平台(记录、上报、优雅退出)。第三个位置是进程级监控:ANR 与卡顿监控依赖主线程的时间预算(5.3 节),协程化后这两类问题通常显著下降——这也是"结构消灭事故"在可观测性上的回报。

常见问题

问:cancel 之后 join 有什么用?直接 cancel 不就停了吗?
cancel 是"发出取消信号",任务是"协作地"在挂起点退出——两者之间有时间差。join 等待任务真正结束,价值在需要确定性收尾的场合:测试里 cancel 加 joinAll 后再断言副作用;顺序关闭里等前一批任务退出再关资源;防止"作用域已关但任务还在跑最后几行"的竞态。cancelAndJoin 是两者的组合快捷方式。日常 UI 代码不需要它(作用域自动等子协程),测试与优雅关闭是它的主场。

问:CancellationException 该不该出现在 catch 列表里被"特殊对待"?它的子类(如 TimeoutCancellationException)也一样吗?
放行规则对整棵子类树生效:catch 到它(或其任何子类)都应该原样上抛,不做业务处理。TimeoutCancellationException 是特例中的特例——withTimeout 内部抛出的它在跨越 withTimeout 边界前代表"超时"语义,跨越后如果被外层误吞,外层会以为"只是被取消了"而静默继续。所以第 2 章的纪律在这里加一条注释版:需要区分超时与取消时,用 withTimeoutOrNull 的返回值而不是 catch 类型——用数据表达差异,别用异常子类的微妙身份。

问:SupervisorJob 看起来总是更"安全",为什么不全用它?
因为"一损俱损"经常是想要的语义。coroutineScope 的事务性(5.2 节)保证"要么全成功要么全收场"——支付加积分加发券的并行拆分,一个失败就该全部回滚收场,SupervisorJob 会让"支付成功、发券失败"这种半吊子状态堂而皇之地存在。SupervisorJob 的正确位置是互不相干的同层任务(页面的多个独立加载)。一个粗判:任务是"同一笔业务的一部分"用事务性,任务是"同住一层的邻居"用监督性。全用 Supervisor 的工程,多半是把并发组合的语义想浅了。

本节要点回顾

  • 协作式取消:取消在挂起点生效,CPU 循环要 isActive 或 ensureActive 主动配合;
  • 清理用 finally 加 NonCancellable:挂起型收尾必须包进 NonCancellable,否则刚起飞就被取消打断;
  • 先放行 CancellationException:catch Exception 与 runCatching 都会误捕取消,破坏结构化并发;
  • withTimeout 抛异常、withTimeoutOrNull 给降级:超时同样受协作式取消约束;
  • 普通 Job 一损俱损,SupervisorJob 各自安好:并行拆分用前者,同层互不相干用后者;
  • CoroutineExceptionHandler 装在作用域上:viewModelScope 已内建 SupervisorJob 加 Main,是页面任务的防崩默认值;
  • 取消是一场有序撤离:泄漏、崩溃、僵尸请求三类事故在此一并收场。

单次调用的生老病死齐了。下一节升级时间维度:Flow——把"调一次"变成"持续地流",以及它在安卓状态管理里对 LiveData 的全面接替。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U