Kotlin 28:同一个异常为何有时落在 await,有时到 handler
任务树里有一个子任务抛 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 | |
它把普通步骤异常放入 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 的事务。
练习与逐篇验收
- 一次
launch的异常被 handler 看到了,是否说明其兄弟不被取消?不能。 必须先知道是否在受监督父级下。本篇仅监督正例确认兄弟能继续过闸,普通树反例确认等待中的兄弟运行了finally。 async没有把异常打印到 handler,能否视作执行成功?不能。 这里必须调用await()才获得被封装在 Deferred 中的异常;无主 Deferred 的错误更不应被默默扔掉。runCatching能接住CancellationException,为何不直接给Succeeded默认值?那会把终止控制流写成业务成功。 测试中的incorrect-default是专门保留的反例;实际入口显式重抛取消,普通失败才转Result.failure。
verification/28/ 记录 30 组累计 JUnit、四条故障拓扑和取消反例、Gradle/CLI 正反命令、环境与退出码;同名素材提供完整累计工程 kotlin-language-28.tar.gz、SHA-256 和解包复跑。下一篇才处理共享可变索引的更新竞争:故障有处理者,不代表任务状态和 Store 的并发转移就具有原子性。






