请求超时以后,后台任务是否还在运行?这个问题不能靠接口返回了一个超时错误来回答。调用方停止等待、运行时收到取消请求、计算抵达可取消位置、资源清理完成,是几个不同的事件。业务代码若只观察第一个事件,就可能在连接仍被占用时开始重试,或者在旧任务仍可能提交写入时宣布操作已经失败。

Cats Effect 的 fiber 提供了描述这些事件的工具,但并不会替应用决定所有任务的归属。本文固定 Scala 3.3.7、Cats Effect 3.5.7、Cats 2.12.0;超时测试使用同版本的 cats-effect-testkit。完整代码在 Main.scala,七项实际断言及命令、环境、摘要在 result.json。测试使用 Deferred 建立顺序,超时使用虚拟时钟,没有用随机 sleep 推测调度结果。

start 给出句柄,不自动给出任务所有权

io.start 的结果是一个描述“启动 io 并取得 fiber 句柄”的效果。执行这个效果后,调用方可以通过句柄等待或取消任务。但变量名叫 parent、代码写在一个方法内,都不自动建立“方法返回时取消其创建的所有 fiber”的规则。Cats Effect Concepts 明确讨论了裸 start 的非层级性质。

实验故意使用两次 start:内层启动一个永不自行结束的子任务,外层启动一个只负责取得该句柄的父任务。等待父任务完成时,拿到的是子任务句柄,不是子任务的计算结果。子任务开始后停在 IO.never,其 finalizer 修改 done。此时断言 done == false,证明父任务已经结束并没有导致子任务自动清理。

1
2
3
4
5
6
7
8
9
10
11
12
13
for
started <- Deferred[IO, Unit]
done <- Ref.of[IO, Boolean](false)
parent <- (started.complete(()) *> IO.never[Unit])
.guarantee(done.set(true)).start.start
child <- parent.joinWithNever
_ <- started.get
before <- done.get
_ <- IO(assert(!before))
_ <- child.cancel
after <- done.get
_ <- IO(assert(after))
yield ()

这段双 start 是为了让父子身份可观察,不是日常业务的推荐写法。其关键类型是:父任务成功产生一个 Fiber[IO, Throwable, Unit],子任务本身没有产生业务值。若误把 parent.joinWithNever 理解为“等整棵任务树结束”,就会漏掉句柄所代表的仍在运行的任务。

否定断言之后,测试显式取消子任务并确认 finalizer 完成,不会为了展示泄漏而把测试任务真的遗留在后台。实验所证的是这个最小程序中父完成与子存活可以同时成立,不是说所有任务 API 都缺少结构化生命周期。不同组合操作可以额外提供所有权协议,必须逐个阅读其契约。

用资源作用域表达后台工作的寿命

如果某个后台工作仅在一个操作期间有效,可以用 background 返回的资源约束其寿命。在实验中,后台子任务报告 ready 后永久等待;资源的 use 也永久等待。取消承载 use 的 owner 后,断言 owner 的 outcome 为取消,子任务的关闭标记已经为真。

1
2
3
4
5
6
7
owner <- (ready.complete(()) *> IO.never[Unit])
.guarantee(closed.set(true))
.background.use(_ => ready.get *> IO.never[Unit]).start
_ <- ready.get *> owner.cancel
out <- owner.join
c <- closed.get
_ <- IO(assert(out.isCanceled && c))

与裸 start 的差别不在于子任务是否写在同一个代码块,而在于资源释放逻辑持有并处理了它的句柄。资源范围结束时需要处理后台工作的终止,因此它的生命周期被纳入 owner 的清理路径。上一章的并发组合也有自己的范围语义;不能因为内部使用 fiber,就把它们都当成任意可脱离作用域的 start。

更长寿命的后台任务可以有应用级 owner。例如监听器、周期性维护任务可能比单次请求长,但应短于应用资源范围。此时不应为了套用“子任务随请求退出”而错误取消它们;需要将所有权提升到适当层级。Cats Effect 的 Supervisor 文档 讨论了这种被明确管理的后台工作,也区分离开范围时等待还是取消的策略。本文没有运行 Supervisor 场景,因此不将这些文档策略列入本次七条实验结论。

资源拥有者还要决定错误如何被观察。一个子任务被正确取消,并不等于它此前发生的业务失败已经被上层报告。是否等待成功值、是否把失败转成主流程失败、是否记录后继续,都属于监督策略。任务寿命和错误传播彼此相关,却不是同一个开关;给任务加一个 finalizer 只能处理退出动作,不能自动补齐错误处理。

取消请求和取消完成之间有清理区间

在本章被测的 Cats Effect fiber 上,cancel 不是单纯设置一个布尔标志后立即返回。为了观察它与 finalizer 的关系,实验让 finalizer 先发出 releasing,再等待 allowClose,最后把 closed 设为真。另一个 fiber 执行 target.cancel,返回后才设置 returned。这样可在清理期间读取两个独立标记。

1
2
3
4
5
6
7
8
9
10
11
target <- (ready.complete(()) *> IO.never[Unit])
.onCancel(releasing.complete(()) *> allowClose.get *> closed.set(true))
.start
_ <- ready.get
canceler <- (target.cancel *> returned.set(true)).start
_ <- releasing.get
r <- returned.get
c <- closed.get
_ <- IO(assert(!r && !c))
_ <- allowClose.complete(()) *> canceler.joinWithNever
out <- target.join

在收到 releasing 后,目标已进入取消清理路径,但允许关闭的门闩尚未打开。此时 returned 为假说明取消调用仍未返回;closed 为假说明清理尚未完成。放行后等待取消调用返回,再检查两个标记为真,且 out.isCanceled。这个双阶段测试比“最后看到 close 日志”更精确,因为它检查了返回时点与清理完成之间的关系。

为什么取消要放到另一个 fiber?若观察者自己直接等待 target.cancel,它会停在尚未放行的 finalizer,后面的 allowClose.complete 永远执行不到。把控制者与被控者分开不是扩大生产并发度,而是让测试有能力观察一个正在发生但尚未结束的过程。

这也揭示了一个真实风险:如果 finalizer 永远不完成,等待取消也可能永远不完成。资源安全不是“任何时候都在一个固定时限内退出”的同义词。关闭远端连接、刷盘或等待锁都可能有自身的失败与延迟。需要按协议设计清理范围,避免在不可轻易中断的清理区域里等待一个只能由已经取消的工作来发出的信号。

实验中的 IO.never 是运行时可管理的等待,不能拿它代表任意 JVM 阻塞调用。一个第三方方法若既不响应中断,也没有取消句柄,效果运行时不能凭空让底层操作消失。Sync 文档 区分普通延迟、阻塞与可中断阻塞的封装。将调用放进 IO.blocking 是调度与阻塞边界的选择,不是已经证明该调用会被操作系统立即中止。

Outcome 不只是 Either 的另一个名字

等待 fiber 的结果需要区分成功、错误和取消。成功结果还带着效果上下文中的值;取消不是一个普通成功值,也不应随意塞成默认结果。若一个接口在取消后返回空列表,调用方可能把它理解成“查询成功,确实没有记录”,失去“操作未完成”的信息。

本文只在保证成功的控制任务上使用 joinWithNever 来取值:例如父任务只返回子句柄,或取消调用最终完成。检查目标取消时使用的是 join 和 isCanceled,没有把取消压成一个永不结束的取值动作再等待。这个区别在测试代码里同样重要;用错等待方法可能让一个本应立刻报告取消的断言挂住。

错误与取消的区别也影响恢复逻辑。attempt 用于把错误变成 Either,并不意味着可以把所有终止都当作 Left 然后继续业务。取消表达控制流程不再需要这项工作,其清理应遵循取消协议;把它粗暴转换成重试条件,可能在用户取消之后继续产生新请求。

竞争中的失败也必须处理另一侧

“谁先完成就取谁”容易被误读成“只处理赢的一侧”。当一侧失败时,另一侧可能仍在持有资源或等待网络。实验让 loser 先开始并停在 IO.never[Int],为它安装保证执行的清理标记;失败的一侧等待 loser 的 ready,然后抛出指定错误。

1
2
3
4
val loser = (ready.complete(()) *> IO.never[Int])
.guarantee(closed.set(true))
val failed = ready.get *> IO.raiseError[String](new Exception("race-failure"))
val raced = IO.race(failed, loser).attempt

raced 返回后,断言错误消息为 race-failure,并检查 loser 的关闭标记为真。准备信号排除了“另一侧尚未进入需要清理的范围”的捷径。只检查左侧错误,不能证明右侧没有泄漏;只检查右侧关闭,又不能证明原失败被正确传播。组合后的错误和被终止侧的 finalizer 必须一起观察。

本实验检查的是 IO.race 在这条失败路径上的行为,不是所有名为 race 的 API 的共同定律。某些更底层操作会把未完成的 fiber 交给调用方,使其可以选择继续运行或取消;此时返回句柄本身就意味着新的所有权责任。替换操作时,即使结果看起来仍像一个 Either,也要复查对未完成任务的处置。

“最快响应”与“最快成功”也不同。若业务希望忽略一个副本的失败并等待另一个成功,就需要明确的错误汇聚策略;直接把失败纳入最先完成者,可能过早结束查询。反过来,如果权限校验失败要求立即停止所有工作,继续等待某个成功副本也可能违反业务规则。不要让一个方便的竞速操作替代需求中对失败的定义。

超时是计时、取消和后续动作的组合

超时测试经常写成“等几十毫秒,然后期望任务已经结束”。这类检查把机器负载、调度延迟和逻辑时间混在一起。本文使用 TestControl.executeEmbed,让运行时在测试中控制逻辑时间。工作先记录 start,再永久等待;取消清理记录 finalize;timeoutTo 的后备动作记录 fallback。

1
2
3
4
5
6
7
8
9
10
11
12
13
TestControl.executeEmbed {
for
events <- Ref.of[IO, Vector[String]](Vector.empty)
work = (events.update(_ :+ "start") *> IO.never[Unit])
.onCancel(events.update(_ :+ "finalize"))
start <- IO.monotonic
_ <- work.timeoutTo(1.second, events.update(_ :+ "fallback"))
end <- IO.monotonic
e <- events.get
_ <- IO(assert(e == Vector("start", "finalize", "fallback")))
_ <- IO(assert(end - start == 1.second))
yield ()
}

断言里的“一秒”是受控运行时的单调时间差,不是测试占用了精确一秒墙钟时间。事件顺序说明,在这个可取消工作、可完成 finalizer 的场景中,后备动作发生在清理之后。它不意味着任何 timeoutTo(1.second, ...) 都会在真实世界的一秒内把结果交还给调用方:清理本身可能耗时,底层调用也可能没有及时响应取消。

如果后备动作是重试,清理前后顺序尤其重要。先启动第二次写入再处理第一次的取消,可能导致两次写入重叠;等待清理又可能增加调用方可见延迟。两种风险不能靠把超时数字调小一起消除。需要结合幂等键、服务端截止时间、请求状态查询和资源清理协议设计,而不是把超时当成“外部世界已经撤销”的凭据。

Test Runtime 文档 也说明了这类测试运行时的适用边界。它可以控制效果调度与逻辑时间,并探索不同调度顺序,但不能证明真实多线程环境不存在所有竞争,也不能替代网络或文件系统的真实测试。本文用信号锁定所需的因果关系;没有记录和穷举全部调度,不把一次通过描述为对所有并发交错的数学证明。

从协议角度检查业务取消

给服务方法增加取消能力时,可以先列出四个位置:请求是否已发出,响应是否已到达,提交是否已确认,资源是否已归还。取消发生在不同位置,后果可能不同。尚未提交的纯计算可直接停止;已经被远端接受的写入,即使本地不再等待,也可能成功完成。效果类型能帮助组合这些分支,不能替远端系统提供事务撤销能力。

因此“超时后没有收到成功结果”只是本地观察,不是“远端没有成功”。本章故意使用内存状态和受控等待,以便可靠检验运行时协议;没有发送远端写入,也没有声称测试覆盖分布式撤销。把这个边界写清楚,有助于避免用一个取消单元测试批准重试策略。

对 finalizer 的审查也不宜只数调用次数。一次执行的 finalizer 可能抛错、永不结束,或只完成一半外部动作。第27章把释放开始时的退出原因与释放自身的成功分开,本章进一步区分清理进行中与清理已完成。两者合起来,才能解释为什么一个取消状态既需要生命周期证据,也需要具体资源协议的证据。

业务截止时间不能只包住最后一次等待

假设一个操作包含排队、建立连接、发送请求、解析响应和关闭连接。若仅给“等待响应”设置一秒超时,调用方经历的总时间还包含前面的排队与建立,以及后面的解析与关闭。这个超时只限定其中一个范围,不能在接口文档里写成“整个操作一秒内完成”。本章虚拟时钟测的是所包裹工作及其立即完成的清理,没有模拟其他阶段。

若业务给出一个总截止时间,可以将剩余预算作为显式数据传递给后续步骤,避免每一步重新获得完整的一秒。即便如此,本地预算仍不能替代底层驱动或远端协议的截止时间。上层已经没有预算时,下层若仍不可取消,资源就可能继续占用。设计上需要说明超时后是等待清理、交给应用级监督范围处理,还是返回仍可查询的操作身份。

“交给后台”尤其需要明确拥有者。把一个卡住的关闭操作裸 start 后立即返回,只是将等待从调用路径移走,并没有解决清理最终失败、进程退出和资源累积问题。如果业务允许异步收尾,应有比请求更长寿命的管理范围,以及能观察未完成数量和错误的接口。否则接口延迟看起来降低,泄漏风险却没有可见归属。

类似地,关闭应用时也要决定先停止接收新工作,还是先取消已有工作。若先关闭共享连接池,随后才等待仍使用连接的子任务,它们的 finalizer 可能面对已失效设施。资源嵌套应让依赖共享设施的任务先结束,再释放设施。这个顺序与第27章的逆序释放一致,但应用实际的关闭顺序仍需集成测试,本文没有启动服务器验证它。

取消边界要与不可分割的业务步骤分开

某些操作在本地需要短暂维护一致性,例如从内存队列移除项目后,将它登记为处理中。如果两个动作之间允许取消而没有恢复协议,项目可能从待处理集合消失,又没有出现在处理中集合。解决办法是用合适的原子状态更新或受保护的短协议,而不是给整个业务函数套上无限大的不可取消范围。

不可取消范围过大,会让用户停止操作后仍长时间占用资源;范围过小,则可能把必须配对的动作拆开。判断边界需要先写出状态不变量,明确哪个中间状态不允许对外出现,再选择同步或取消控制。Ref.modify 可以让一份内存状态的更新保持原子,但不能把数据库与消息发送变成跨系统原子事务。

本章没有为这个队列例子提供实现,因此不声称验证了某种通用屏蔽模板。它说明取消安全与普通线程安全有交集:都要检查中间状态,但取消还涉及工作不再继续推进的出口。一个没有数据竞争的单 fiber 程序,也可能在取消后留下半完成的业务状态。

对外部提交而言,关键边界可能不在本地代码的两行之间,而在远端是否接受请求。取消在收到确认之前发生,不能区分“请求尚未执行”和“执行成功但响应丢失”。这种不确定性需要协议状态表达,例如未知、待查询或可幂等重试。把本地 outcome 的 Canceled 直接映射为远端 Failed,会丢失真实的不确定状态。

为什么这些断言不用墙钟截止证明顺序

一个可靠的取消测试应先证明任务抵达目标阶段,再发出取消。否则任务可能在开始前就取消,finalizer 没有需要清理的资源;也可能已正常结束,取消只是对终态句柄操作。本文的 ready 将这两种情况排除,releasing 则进一步证明清理已开始。不同信号对应不同事实,不能只复用一个笼统的 started 标记。

在虚拟时间测试里,工作进入等待后,计时效果才有机会推进至截止点。断言事件顺序和单调时间差能够定位“清理没有运行”“后备过早运行”及“计时范围错误”三类问题。若只检查最终事件包含 fallback,连工作是否启动过都不清楚;若只检查最终时间,一次没有正确清理的超时仍可能通过。

这些精确断言仍然是有限协议测试。调度实现、真实阻塞、回调来自外部线程时的适配,以及资源关闭抛错,都可能要求其他场景。新增一个场景时,最好先写出预期事件的因果关系,再选择信号和观察点;不应先复制几个 sleep,看到一次通过后再倒推它证明了什么。

复跑与练习

1
node examples/functional-programming/run.mjs 29

当前证据包含七条 PASS:裸 start 的子任务在父完成后仍存活、显式取消清理、background 随 owner 取消清理、finalizer 阻塞时 cancel 未返回、放行后 cancel 返回且 outcome 取消、竞争失败清理另一侧、虚拟超时先清理再后备。每条都对应源码中的布尔断言,不能只通过观察打印先后判断通过。

手算题:对双 start 示例分别写出内层 start、外层 start、parent.joinWithNever 的结果类型。为什么父任务的成功值是子句柄,而不是 Unit?随后画出 ready → cancel请求 → releasing → allowClose → closed → returned,指出哪条边由代码顺序保证,哪条边由 Deferred.get 保证。没有这些边,仅有日志文本不足以复原可靠顺序。

修改题:把 finalizer 的一个关闭标记改为两个阶段 flushed 和 closed,中间增加第二个门闩。先检查 flush 完成但 close 未完成时 cancel 仍未返回,再放行 close 并检查取消结果。不要使用固定 sleep 等待阶段。新增断言应包括否定条件,否则即使两个阶段被错误地全部跳过,也可能只看到一个最终取消状态。

再将竞争实验的失败侧改为成功值,保留 loser 的启动门闩和清理标记,核对结果分支与资源清理。这个修改是新的待运行实验,不属于当前七条通过记录;若把它用于实际论证,应保存对应源码与新证据,而不能沿用旧文件的摘要声称已经验证。

参考资料与旧文边界

本文实际阅读 Concepts、Spawn、Supervisor、Sync 和 Test Runtime。旧文 Cats 与 IO 的执行和取消 提供构造与取消入门;本文新增父子所有权、清理中间态、失败竞速和虚拟超时的分离测试,不把旧文的一个取消案例当作这些场景都已覆盖。

系列导读 · 上一篇:28 · 下一篇:30