任务因超时被取消,调用者收到异常,资源也一定关了吗?26 的真实闸门已经给出一个反例:协程取消时,专用线程上的同步 await() 直到测试线程放闸才退出。现在增加两种更难混的情况:一段计算只有在检查取消时才肯停;一个资源在“已由生产者取得、尚未交给等待的协程”这段间隙碰上取消,到底谁负责关闭?

版本不变:Kotlin Gradle Plugin/编译器/stdlib 2.2.21、Gradle 8.14、JDK/toolchain 21、language/API 2.2、jvmTarget 21,kotlinx.coroutines core/test 1.10.2(源码 tag 5f8900478a8e20c073145b1608fbc71fe3d7378b)。先核对 Kotlin 取消与超时指南、ensureActive、withTimeout、NonCancellable 和 suspendCancellableCoroutine 的官方契约。实际运行结果分别记在真实线程和测试调度器两本账上,不能互相代替。

取消不是 JVM 强行退出正在执行的指令

真实单线程实验各启动一个三步有限循环;两者都在第一步的 CountDownLatch.await() 前发出“已进入”信号,测试线程随后请求 Job.cancel() 再放闸。第一种循环每步开头调用 coroutineContext.ensureActive(),第二种循环没有检查取消,也没有其它挂起点。两条路径均由同一个专用单线程执行器执行,结束后测试 join(),读取独立计数器:

1
REAL cooperative_steps=1 unchecked_steps=3

为什么协作版本不是 0?第一次 ensureActive 在取消之前通过了;它已经进入阻塞闸门,取消请求本身不会使这条原生等待瞬间结束。放闸后第一步计数加一,到第二次检查才察觉已取消并停止。没有检查的版本在闸门放开后依次执行满三步,Job 的取消请求没给它凭空插入一个检查点。这段循环被有限的三步约束,避免为了证明问题制造一个无限自旋的失控测试;它不是调度吞吐、实时响应间隔或所有 CPU 工作负载的结论。

让任务在能配合的地方调用 ensureActive 或进入可取消挂起,是控制计算停止的职责;让真实同步驱动响应中断、关闭连接或让数据库回滚,是另一个接口和实验的职责。runInterruptible 等库 API 有自己的中断约定,但这篇既没把普通 withContext 换成它,也没拿一个锁闸门代替所有文件和 JDBC 驱动。Job.isCancelled 是任务生命周期状态,不自动转换为磁盘和网络的完成证明。

资源取得以后,在两种顺序下谁关它

累计工程增加 executeWithLease:先取得 AutoCloseable,然后在 try 中运行受管任务步骤;finally 在 withContext(NonCancellable) 内执行可挂起的 cleanup,再用第二层 finally 调用 lease.close()。第二层 finally 的意义很具体:清理回调若自己抛异常,仍尝试关闭已经取得的 lease。这里尚未覆盖 close() 自身异常与清理异常的优先级或重试;不能因有两层 finally 就说外部资源永不泄漏。

1
2
3
4
5
6
7
8
9
10
val lease = acquire()
try {
return executeManagedTask(id) { step() }
} finally {
try {
withContext(NonCancellable) { cleanup() }
} finally {
lease.close()
}
}

在已取得资源且步骤已进入等待的前提下,用两扇 CompletableDeferred 闸门控制顺序。完成优先:先放开步骤,await() 得到 Succeeded("completed:task-27-win"),资源关闭次数为 1,此后对已经完成的 Deferred 调用 cancel(),原成功结果不被反向改写。取消优先:让步骤继续等闸,先 cancelAndJoin(),await() 抛 CancellationException,步骤闸门未完成,取得的资源同样关闭 1 次。输出为 VIRTUAL complete_first_close=1 cancel_first_close=1 late_cancel_ignored=true。

这两种受控先后次序不等于穷尽真实线程同时发布“完成”和“取消”的所有交错。已闭合的测试仅断言在这两个明确的顺序里没有重关/漏关、终态不被晚到的取消倒改;不同执行器和外部提交点上的并发线性化还需另设门槛。本篇业务成功仍只是函数返回的 TaskState,没有写 Store;更没有宣称成功时刻等于数据库事务提交或消息已发出。

资源在挂起交接处被取消,finally 够用吗

上一个 finally 只接管已经从 acquire() 返回给调用者的 lease。若异步提供者已拿到资源,但等待它的协程在恢复之前被取消,调用者甚至没执行到 val lease = acquire() 的下一行;这一段间隙需要在交接处另指定归属。

acquireCancellableLease 使用 suspendCancellableCoroutine 注册回调,并在向 continuation 交付资源时为 resume(lease) 提供 onCancellation 处理:若交付值未能到达已取消的调用者,由该处理关闭被丢弃的资源。测试先等注册回调完成,先取消并等待消费者退出,随后才让生产者回调交付一只可计数的 AutoCloseable,断言 lost_lease_close=1。这是实测的一种“取消先于交付”顺序;资源被成功交付后的关闭归 executeWithLease 的 finally 管。该测试没有同时触发真实生产者/消费者线程竞争,也没有测试重复回调、回调里 close 抛出或所有库实现版本。需要给真正的异步数据库或网络客户端接入时,应在那条边界重新验证归属协议。

超时异常之后底层步骤如何核对

虚拟测试调度器中用 withTimeout(100) 包围合成步骤:步骤调用 delay(1000),没有执行真实文件或网络 I/O。超时到来后,已取得 lease 的 finally 进入 NonCancellable 清理段,先 delay(2) 再记录清理调用 1 次,随后关闭 lease 1 次。原始输出 VIRTUAL timeout_time=102 cleanup=1 close=1:测试时钟 100 是超时,增加的 2 是可挂起清理,调用者见到 TimeoutCancellationException 不表示清理在 100 那一刻已结束。

清理操作在 NonCancellable 中可以挂起,但这不是让所有清理过程永远不可取消的建议:真实远端调用若长时间挂起,缺乏独立上限的关闭流程一样可能拖住服务退出。本例特意只用可控的虚拟计时器证明自己代码的 finally 路径。第 26 篇已用真实阻塞闸门证明了另一个完成点,因此绝不能从这条超时异常倒推出那个同步操作、文件 I/O、线程或数据库事务已停止。

练习答案与验收边界

  1. 循环在第一次 ensureActive() 后被取消,为什么还做完第一步?因为第一次检查发生在取消前,线程后来卡在阻塞闸门;只有放闸并进入第二次检查时才观察到取消。 真实线程日志是 1 步与不检查版本的 3 步,不是两条路径都立刻归零。
  2. 消费者取消后,生产者才把资源交给 continuation,谁关资源?交接的 onCancellation 处理;若消费者已经成功拿到 lease,则由调用者的 finally 关闭。lost_lease_close=1 只证明这次受控次序,不能拿 try/finally 覆盖尚未成功取得资源的间隙。
  3. 看到 TimeoutCancellationException 就能确认 close() 已调用吗?不能只看异常类型。 此处有额外的清理和 close 次数断言,且是虚拟时钟中的合成步骤;真实驱动和外部副作用须各自检查。

逐组原始 JUnit XML、真实线程/虚拟时钟输出、命令环境及 CLI 正反退出码见 verification/27/;完整累计工程及 Wrapper、RUN/VERSIONS 可在同名素材中下载 kotlin-language-27.tar.gz,按 README 解包复跑。下一篇把 launch、async 与监督放在相同的故障拓扑里比较,不能把本篇单一任务树的清理结论泛化为所有兄弟任务都互相隔离。