同一笔订单被计算两次,是否会扣款两次,取决于“计算”究竟是什么。保存一个已经提交的 Future,再等待它两次,通常是在观察同一项任务;保存 IO,再组合执行两次,通常是在执行两次描述。这个区别会影响重试、计费、日志、资源关闭,不能只用“它们都是异步容器”概括。

Cats 提供类型类、数据类型和组合规则;Cats Effect 提供效果执行所需的抽象与运行时。Validated 的错误累积不需要启动 fiber,IO 的取消也不能从 Applicative 定律直接推导。把这两组问题分开,才能判断一次实验究竟证明了什么。

本篇冻结 Scala 3.3.7、Cats Effect 3.5.7、JDK 21,使用仓库 examples/scala-lab/electives/E01 中的独立工程。实际执行记录为 evidence/20261002-b/lifecycle.log,运行命令和退出码存放在同名 JSON。这个组合是可复现基线,不代表当前最新发行版。

错误累积先于效果执行

假设订单有姓名和数量两个字段。姓名为空与数量小于一是独立输入错误,用户通常希望一次看到两项。若用 Either 的 flatMap 连接两个校验,第二项依赖第一项成功;遇到第一个 Left 就不会调用后续函数。这种短路适合先查询订单再读取其详情,因为详情查询需要前一步的订单标识。

独立校验的结构不同。两个校验都能在没有对方结果的情况下完成,随后才需要把成功值组成订单。Cats 的 ValidatedNel 为错误提供非空列表载体,mapN 使用该载体的组合能力积累失败。在本实验中,两个错误按姓名、数量的顺序构造,因此结果列表也是这个顺序。

1
2
3
4
5
6
7
import cats.data.ValidatedNel
import cats.syntax.all.*

val name: ValidatedNel[String, String] = "name".invalidNel
val quantity: ValidatedNel[String, Int] = "quantity".invalidNel
val result = (name, quantity).mapN((_, _))
assert(result.swap.toOption.get.toList == List("name", "quantity"))

这里没有线程,也没有 IO。mapN 的出现不意味着并行执行:两个局部值已经构造完毕,组合器只处理它们的结果。错误能否累积由数据类型及实例决定;任务是否并发由运行时与并发组合器决定。把 mapN 换成另一个效果类型上的实例,必须重新检查实例的语义。

错误顺序同样是接口的一部分。若先用无序集合收集字段,再转换成列表,显示顺序可能变化;若错误值只包含字符串,也可能无法定位重复字段。实际表单可以把错误定义为字段路径与错误码,非空列表只负责保证失败时至少有一项,并不会自动补齐业务结构。

[PATTERN] 先确定后一步是否依赖前一步的成功值,再选择短路或累积。错误容器的组合规则不能替代业务依赖分析。

创建任务与运行描述

Future.apply 接收传名代码块,但会把任务提交给执行上下文。传名参数使调用方能够提供代码,并不意味着返回的 Future 要等 Await 才启动。实验通过原子计数器记录任务执行,并显式等待完成,以避免用某一次线程调度顺序猜测是否已经执行。

1
2
3
4
5
6
7
8
val futures = new AtomicInteger
given ExecutionContext = ExecutionContext.global
val submitted = Future(futures.incrementAndGet())
Await.result(submitted, 5.seconds)

val ios = new AtomicInteger
val deferred = IO(ios.incrementAndGet())
assert(futures.get == 1 && ios.get == 0)

Await 只是这个对照实验中的同步观测点,不是应用中推荐的效果组合方式。它阻塞调用线程;在生产服务中把它放入计算线程池,会损害吞吐量。需要组合已有 Future 时,可以在效果边界桥接,并单独审查 Future 创建的时机。

IO.apply 把副作用推迟到执行描述时。变量 deferred 保存描述而非计数器第一次增加的结果。下面两次绑定分别执行同一段描述,所以读取结果依次是 1 和 2。

1
2
3
4
5
for
a <- deferred
b <- deferred
_ <- IO(assert(a == 1 && b == 2))
yield ()

因此,val 的不可重新赋值性与效果只执行一次没有直接关系。val deferred 不能指向别的 IO,却完全可以被运行多次。若业务需要缓存结果,必须显式采用缓存、共享或持久化幂等策略,并说明失败是否缓存、取消后是否重试、缓存多久。

反向例子是 IO.pure(counter.incrementAndGet())。Scala 会先求值严格参数,再调用 pure,计数在 IO 构造时已经发生。pure 适合包裹已存在的纯值,无法把参数求值中已经发生的副作用倒退回描述阶段。相同错误也会出现在 val f = Future(...) 之后再把 f 包进 IO 的代码里。

对于支付请求,“描述可重复运行”还是一个幂等性问题。把网络调用包装成 IO 能够控制何时发起,但第二次执行仍可能再次发起支付。外部系统是否接受幂等键、重试是否沿用同一个键、失败响应是否代表支付没发生,都需要协议层的证据。

取消必须有可观察的终止结果

只打印“发送取消”不足以证明资源释放。取消请求可以已经发出,任务却仍在不可取消的阻塞区域。本实验使用三个观测点:任务已经进入使用区,释放动作已经完成,以及 fiber 的最终 Outcome 是取消。

1
2
3
4
5
6
7
8
9
10
11
for
ready <- Deferred[IO, Unit]
closed <- Deferred[IO, Unit]
fiber <- Resource.make(IO.unit)(_ => closed.complete(()).void)
.use(_ => ready.complete(()) *> IO.never[Unit]).start
_ <- ready.get
_ <- fiber.cancel
_ <- closed.get.timeout(5.seconds)
outcome <- fiber.join
_ <- IO(assert(outcome.isCanceled))
yield ()

ready 解决了启动与取消的竞争。如果创建 fiber 后立即取消,程序可能尚未获得资源;那时没有释放事件也未必是泄漏。等 ready 完成之后,才能断言释放器已经进入活动资源范围。closed 则明确证明释放动作被执行,避免把“任务没有返回业务值”误判为关闭成功。

这里使用 IO.never 产生可取消的等待,没有占用一个线程做无限循环。取消使使用区终止,Resource 执行释放器,再让 fiber 得到取消结果。测试释放的是一个逻辑资源标记;文件描述符、套接字或数据库连接还需要在相应资源 API 上检查关闭效果。

Cats Effect 的取消是协作式协议。把任意第三方阻塞调用包在 IO.blocking 中,运行时能够把它移到阻塞执行路径,却不能据此保证底层调用立即停止。IO.interruptible 又依赖被调用 API 对线程中断的处理方式。网络超时、驱动取消和业务回滚也不自动等价。

官方 3.x API 对 Fiber.cancel 和最终清理有明确约定,但旧版 2.x 资料中还会出现 ContextShift、IO.cancelBoundary 等不同模型。本篇没有将这些旧 API 混入 3.5.7 工程。搜索结果页若未展示版本,首先核对依赖坐标与 API 所属版本,再使用其中的实现描述。

Resource 管理的是使用区

资源的安全性依赖 acquire、use、release 三段构成的作用域。Resource.make 把释放逻辑与获得的值绑定,use 给出这次使用何时结束。把句柄直接作为 use 的返回值交给外部,不会延长它的有效期:返回时释放器已经运行。

一个常见错误是让 use 内部启动后台任务,然后立即返回。后台任务只捕获了句柄引用,资源作用域却已经结束。修复需要让使用区等待完整消费,或者把后台任务生命周期也组织成资源。单纯给引用加上 val,或把返回类型改为 IO[Handle],都不能扩大作用域。

嵌套资源还涉及释放顺序。先获得输入,再获得输出,退出时应先释放内层输出再释放外层输入。若初始化第二个资源失败,已经获得的第一个资源仍需要释放。正式工程应分别注入 acquire 失败、use 失败和 release 失败,检查错误如何报告;本实验只证明取消路径的最终清理,不能代表所有驱动的异常行为。

此外,finalizer 不是事务回滚。关闭一个已经发送过请求的连接,并不撤回服务端已完成的写入。资源释放解决的是本地生命周期;订单状态一致性需要事务、幂等键或补偿协议。将两者合并成“取消后没有副作用”会给重试策略带来错误假设。

[PATTERN] 资源安全要同时检查引用范围与任务范围。关闭事件能够证明释放器运行,不能证明远端业务没有提交。

桥接接口时核对启动时机

已有服务经常返回 Future,新模块却希望用 IO 管理调用。桥接时最容易遗漏的是 Future 究竟在哪里创建。若先调用旧服务得到 Future,再把这个值包装进 IO,远端请求可能已经发出;后面的 IO 只能等待已有任务。若需要把请求启动也推迟,应将创建 Future 的表达式放入效果中,再使用对应桥接接口。两种写法的返回类型可以相同,启动时间却不同。

这会影响超时预算。一个请求在进入效果组合之前已经运行了一秒,随后给等待过程设置两秒超时,整体耗时就不能简单解释为两秒。测试应分别记录创建、提交、获得结果的事件,明确计时从哪里开始。时间戳用于诊断,任务启动标记与完成标记用于验证顺序,避免把机器负载造成的延迟误认成库语义。

还需要核查取消传播。如果底层接口只提供 Future,没有对应的取消句柄,停止等待并不一定停止底层请求。此时资源清理范围可以覆盖等待者,却不能凭空获得底层驱动没有提供的能力。接口设计可以返回业务结果与取消操作,或者使用本身支持取消的客户端;选择哪一种,应依据实际客户端契约。

对于重复执行,测试也应保留两种基线:重复观察同一个 Future,以及重新调用创建 Future 的方法。前者通常共享已提交任务,后者会提交新任务。将两者混在一个计数器里,容易错误地把 Future 描述成自动防重机制。本文只复用一个 Future 值,IO 则明确执行同一描述两次。

实测结果与修改练习

实际运行退出码为 0,所有断言通过。下面的数字来自程序输出,不是调度时间测量。

场景 观察值 能够支持的结论
Future 创建后等待完成 计数 1 创建时已提交任务
IO 创建完成但尚未绑定 计数 0 该副作用尚未执行
同一个 IO 绑定两次 计数 2 该描述未隐式记忆结果
两项独立失败校验 name、quantity 此组合按构造顺序累积错误
取消已进入使用区的 fiber canceled、finalizer 均为 true 已观察到终止与释放

手算题:把 deferred 改为 IO.pure(ios.incrementAndGet()),其他代码保持不变,构造后、第一次绑定后、第二次绑定后的计数各是多少?答案都是 1;因此原有断言应当失败。若结果不是这样,需要检查计数器是否被其他路径修改。

修改练习:将逻辑资源替换为临时文件的输出流,在 use 中写入一行后进入 IO.never,取消后尝试再次写入,记录 API 报错与文件内容。释放器应关闭句柄,已写入的数据仍然存在。这个练习把“释放”与“撤销”放在同一个可观测场景中区分。

可迁移规则 应检查的证据
错误组合服从依赖关系 独立字段是否都被校验
描述与执行分开 创建前后和每次运行的计数
取消与释放分开观测 进入标记、释放标记、最终 Outcome
外部效果需要业务幂等 相同请求键的服务端结果

完整运行方法见实验说明。先修为主线错误建模、类型类与异步资源章节;本篇不要求用效果库替换所有已有 Future 接口。

参考资料

顺序导航:系列入口:00 · 上一篇:39 · 下一篇:E02。