24 的合成步骤把线程是否空出的问题分成了两种等待,但 `suspend` 本身没说启动的子任务由谁等。假如步骤内部 `launch` 一个工作项后立即返回,外层便报告任务成功;子工作项却还在等输入。随后服务关闭或子工作项抛异常,这份“成功”算什么?需要区分**函数主体运行到末尾**、**整棵任务树结束**和**业务记录实际提交**。

本文继续使用 Kotlin 2.2.21、Gradle 8.14、JDK 21、language/API 2.2、jvmTarget 21,协程 core/test 仍是 1.10.2(上游 tag 5f8900478a8e20c073145b1608fbc71fe3d7378b)。依据是 Kotlin 协程基础、异常处理以及 Job、coroutineScope、SupervisorJob 的官方 API。库契约解释为何安排这种测试;“已经发生”的结果只取本工程的受控闸门和输出,不从文档替运行做结论。

先给每次执行一个可等待的范围

executeManagedTask 不再仅调用单个挂起步骤,而是让步骤在 coroutineScope 中执行。步骤的接收者是 CoroutineScope,所以在其中 launch 的子协程有确定的父级。整棵作用域正常返回之后,才构造业务成功值:

1
2
3
4
5
suspend fun executeManagedTask(id: TaskId, step: suspend CoroutineScope.() -> Unit): TaskState {
val running = advanceTask(TaskState.Queued, TaskSignal.Start)
coroutineScope { step() }
return advanceTask(running, TaskSignal.Success("completed:${id.value}"))
}

step() 主体写完并非 coroutineScope 返回条件;还须等它在这个作用域创建的子任务。若子任务抛异常,coroutineScope 不会沿正常路径返回,下面的 Success 不会执行。这是函数返回值的控制流约束:没有把失败写回 Store,也没有定义“业务失败状态必须自动持久化”。相反,若代码在作用域外另建无主的后台协程,不会自动被纳入这棵树;本篇刻意不造这种隐蔽前提。

对象 负责什么 本篇能观察的完成点
CoroutineScope 和 Job 把本次步骤及子协程连成父子关系 子任务过闸后父级结果才可交付;故障触发同树兄弟清理
Deferred<TaskState> 让服务调用方等待执行结果或收到故障/取消 await() 得到结果或异常;isCancelled 只表示 Job 已取消
TaskState.Succeeded 领域中的成功值 只能在该示例协程作用域正常返回后构造;不是数据库提交
外部文件、HTTP、数据库 各自的副作用与提交点 25 未做这些操作,不能由 Job 状态推断它们已结束

这些关系也不是普通 JVM 线程的父子关系。子协程暂停时可能腾出线程;父级等待不是必然阻塞调用线程。第 24 篇用真实单线程探针检查过“线程是否空出”;这里使用 runTest 的调度器控制 Job 完成顺序,不重复宣称真实线程测试。

闸门未放行时父级能否交付成功

定向测试先在 runTest 中启动 executeManagedTask(task-25),步骤内部 launch 一个子任务。子任务把 childStarted 闸门标成已进入,再等另一个未完成的 releaseChild。测试等 childStarted.await() 确认子任务已经确实开始,此时外层 Deferred.isCompleted 为 false。在测试线程释放 releaseChild 后,result.await() 才得到 Succeeded("completed:task-25"),然后 isCompleted 为 true。

原始 JUnit 输出 TREE child_started=true parent_before_gate=false parent_after_gate=true 记录三个检查点。关键不是“我们用了 launch”,而是步骤主体已经退出与子任务还没完成同时成立,外层仍没有成功结果。若跳出当前作用域启动无主任务,上述验收便没有这个因果保证;要验证修复,应在返回结果之前核对子任务退出或明确后续跟踪的 API,而非靠固定睡眠等待它碰巧结束。

同树失败走另一条路径。测试先起一个在 awaitCancellation() 等待的子任务;它进入等待后,另一个子任务抛预先构造的 IllegalStateException("injected child failure")。前者的 finally 完成 siblingCleaned,外层 await() 抛出同类同消息异常。初版测试误用 assertSame(failure, observed) 断言异常对象身份,实际失败:在当前协程版本和测试环境中,异常传播可能产生带恢复堆栈的包装对象,消息相同但引用不同。原始失败保留在 verification/25/build-initial.txt。修正断言检查消息及因果链中包含原异常(观察对象是原异常或它的 cause 是原异常),并检查 finally 已执行。不能把这条结论说成“任何平台/版本都会保持同一个异常引用”,也不能把完成清理等同于已提交事务。

谁有权关闭服务作用域

ManagedTaskService 在构造时建立自己的 SupervisorJob 和 CoroutineScope(owner + dispatcher),start 用此 scope 的 async 返回可等待的 Deferred<TaskState>,stop() 则调用 owner.cancelAndJoin()。它只为服务拥有的任务提供共同的停止边界,不是全进程的全局 scope,更不是调用方不必再处理异常的理由。

关闭测试创建一个基于 StandardTestDispatcher(testScheduler) 的服务。提交任务后,子任务先确认进入 awaitCancellation(),测试见到外层尚未完成,随后调用 stop() 并等待它返回。断言 Deferred.isCancelled 为 true、子任务 finally 中的清理闸门已完成、顺序调用 start 被 IllegalStateException 拒绝。输出是 TREE owner_stop_joined=true task_cancelled=true child_cleaned=true new_start_rejected=true。cancelAndJoin 提供的是本棵 Job 树中协作取消任务的结束点。如果子任务在 suspend 内使用阻塞 I/O 且不响应取消,stop() 也可能迟迟不能返回;不能把这条虚拟调度器测试说成已经验证文件或数据库操作被杀死。

服务根使用 SupervisorJob 是为使不同的提交不必共享普通父 Job 的失败传播策略;单个 executeManagedTask 仍通过 coroutineScope 让同树子任务失败影响兄弟。两层关系不能倒置。本篇实际故障实验只验证了单棵任务树内的传播与清理;服务下两次独立提交之间是否互不影响,后续失败隔离章节还要单独注入并验收。start 使用 check(owner.isActive) 拒绝顺序上的停止后提交,不宣称多线程 start/stop 竞争已有原子接收边界。

练习与本篇证据

  1. 子任务已进入 releaseChild.await(),步骤主体结束,此时能报告 Succeeded 吗?不能。 在本例中 coroutineScope 尚未返回;闸门实验实测父级 isCompleted=false,放闸后才得到成功值。业务提交仍另算。
  2. 将同树一个子任务改为抛 IllegalStateException,可以只检查另一任务的 isCancelled 吗?不够。 还应让它先进入等待,控制故障注入,再检查 finally 是否真正运行和父级接到何种失败;本篇的 siblingCleaned 与异常因果链是对应证据。
  3. stop() 返回能否证明同步文件、真实线程或数据库事务已经停止?不能。 这里只对协作挂起的任务用测试调度器验证 cancelAndJoin,真实阻塞适配、资源释放与完成/取消竞争需要另外分账。

命令、环境、三条闸门输出、预期非法 CLI 退出与累计回归见 verification/25/;同名素材目录提供完整工程 kotlin-language-25.tar.gz、校验值和解包命令。下一步才把真实阻塞工作转移到明确的 Dispatcher,并验证线程局部信息在切换前后怎么恢复,不让“换线程”替代取消或资源管理的证明。