任务树里有一个子任务抛 IllegalStateException。若它由 launch 启动,错误会走到哪里?换成 async 就会让兄弟任务保持运行吗?25已经验证普通任务树的子故障会清理等待中的兄弟;27验证取消需要保留自己的控制流。现在把故障与拓扑放到同一张图上:普通 coroutineScope 和受监督 supervisorScope 决定兄弟间的关系,launch、async 则影响错误在哪里被观察。不能把两个维度混成“换了 builder 就自动隔离”。

沿用 Kotlin 2.2.21、Gradle 8.14、JDK 21、language/API 2.2、jvmTarget 21、coroutines core/test 1.10.2,库上游 tag SHA 5f8900478a8e20c073145b1608fbc71fe3d7378b。官方异常处理指南与 supervisorScope、launch、async、CoroutineExceptionHandler 官方 API 是设计实验的一手资料。下述断言使用本机 runBlocking、受控 CompletableDeferred 闸门及 JUnit 原始输出;没有模拟真实网络服务或数据库事务。

普通树中的 launch:不是等到 await 才发现错

先建一个普通 coroutineScope,在里面 launch 两个子任务。第一个确认自己已经进入 awaitCancellation(),并在 finally 完成 siblingCleaned;第二个等该确认信号后抛合成异常 IllegalStateException("injected task-28 failure")。测试在 coroutineScope 外面捕获它返回时的异常,并断言错误消息与预期相同、兄弟的 finally 已执行。记录为 TREE ordinary_launch_parent_failed=true sibling_cleaned=true。

父级必须知道子任务失败,才能防止“父级主体走完就报成功”。这不是 handler 从异常里把兄弟救回来了:普通作用域自己的故障传播已使等待中的兄弟进入取消清理。子任务并未把业务失败写到 Store,TaskState.Failed 也不是这个异常自动生成的结果。若把这次父级异常误判为“任务已经记录成 Failed”,在没有显式写入的地方会凭空产生一个业务记录。

受监督树中的 launch:handler 能做什么

第二组只改变父级策略与观察位置:在 supervisorScope 中先让一个兄弟任务进入 releaseSibling.await(),再启动会抛同一类型和消息异常的 launch(handler)。等待失败的 Job join() 之后,失败 Job 已取消,而兄弟尚未完成;handler 的回调收到了异常消息。最后测试释放兄弟闸门并 join() 它,得到 TREE supervised_launch_handler=true sibling_completed=true。

这组实验能说明的是:在这个 supervisor 作用域里,一个子 launch 的异常有终端观察者,另一个子任务没有被连带取消。不能由此推出 handler 通常可以恢复已经失败的协程,或普通 coroutineScope 里给子 launch 加 handler 就可以阻断向父级传播;隔离来自所选的父 Job / supervisor 边界,不是 handler 的副作用。服务级 SupervisorJob 与单次任务内的 coroutineScope 也必须分层:同一服务里不同提交是否故障隔离,不能用一棵子树的兄弟测试冒充完成。

CoroutineExceptionHandler 在本篇用 CompletableDeferred<Throwable> 接收消息;观察的是消息相同,不强制异常对象引用相同。25 的首轮身份断言已经暴露过堆栈恢复可能改变引用的边界。捕获失败、记录失败与清理后安全重试是不同的动作;Handler 没有自动执行后两件事。

受监督树中的 async:谁主动领取失败

第三组仍在 supervisorScope 中先启动等待闸门的兄弟,再启动 async { throw failure }。调用方用 failed.await() 得到 IllegalStateException,对照消息;兄弟在失败发生时还没完成,放闸后它返回字符串 "survived"。输出 TREE supervised_async_awaiter_failed=true sibling_completed=true。这里没有给 async 绑定异常处理器,不代表错误被忽略:测试明确在 await() 中领取它。若压根不调用 await(),这份错误不会自动变成业务成功,调用者会失去一个明确的反馈点。

实验拓扑 子任务 builder 错误观察者 同树兄弟 本次验收范围
普通 coroutineScope launch 作用域调用方拿到异常 等待中的兄弟进入 finally 单棵树;没有数据库写入
supervisorScope launch(handler) handler 收到异常 放闸后仍可完成 不等于 handler 将故障协程恢复
supervisorScope async await() 抛同类异常 放闸后仍可完成 没有证明忽略 Deferred 是安全的

这张表不是所有 CoroutineScope/CoroutineExceptionHandler 组合的通用真值表。父级是谁、异常是否逃出嵌套作用域、根 Job 是否正在取消,都会改变结果;需要先画出具体树,再由断言定位完成点。

为什么通用 Result 不能接管取消

任务系统增加一个明确的边界函数:

1
2
3
4
5
6
7
8
suspend fun captureTaskFailure(id: TaskId, step: suspend () -> Unit): Result<TaskState> =
try {
Result.success(executeManagedTask(id) { step() })
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Exception) {
Result.failure(failure)
}

它把普通步骤异常放入 Result.failure,但让 CancellationException 继续走协程控制流。测试另构造 runCatching<TaskState> { throw cancellation }:它确实得到 failure,甚至能通过 getOrDefault(Succeeded("incorrect-default")) 换出一个假的成功值。相同取消输入交给 captureTaskFailure,调用者得到的仍是 CancellationException;普通注入的 IllegalStateException("task-28 broken") 才进入 Result.failure。输出 RESULT cancellation_rethrown=true ordinary_failure_captured=true。

Result.failure、领域 TaskState.Failed、Job 的失败与取消不是一个类型,也没有自动彼此同步。普通错误捕获后是否写入 Failed、如何保证写入故障不掩盖原始异常、取消与失败同时到达谁先记录,需要单独的存储契约;这里的 Result 只封装函数调用的普通异常,未连接 13 的提交决策或 35 的事务。

练习与逐篇验收

  1. 一次 launch 的异常被 handler 看到了,是否说明其兄弟不被取消?不能。 必须先知道是否在受监督父级下。本篇仅监督正例确认兄弟能继续过闸,普通树反例确认等待中的兄弟运行了 finally。
  2. async 没有把异常打印到 handler,能否视作执行成功?不能。 这里必须调用 await() 才获得被封装在 Deferred 中的异常;无主 Deferred 的错误更不应被默默扔掉。
  3. runCatching 能接住 CancellationException,为何不直接给 Succeeded 默认值?那会把终止控制流写成业务成功。 测试中的 incorrect-default 是专门保留的反例;实际入口显式重抛取消,普通失败才转 Result.failure。

verification/28/ 记录 30 组累计 JUnit、四条故障拓扑和取消反例、Gradle/CLI 正反命令、环境与退出码;同名素材提供完整累计工程 kotlin-language-28.tar.gz、SHA-256 和解包复跑。下一篇才处理共享可变索引的更新竞争:故障有处理者,不代表任务状态和 Store 的并发转移就具有原子性。