一个导入函数打开文件,读完后关闭,看起来只需要三行。实际接口还必须回答:打开失败后是否调用关闭,解析中途抛错是否关闭,请求取消时谁等待关闭,以及关闭本身失败后能否返回成功。只在正常路径末尾添加 close(),无法覆盖这些出口。

本章把文件抽象成带名字的逻辑资源,以受控错误和取消验证生命周期。资源名字及事件都是教学数据;真实文件句柄的关闭验证放在效果流章节。前置知识是 IO 描述与执行边界,目标是能写出明确的 acquire、use、release 归属,并用失败断言检查它们,而不是把所有清理交给一个没有观察点的 finally。

实验固定 Scala 3.3.7、Cats 2.12.0、Cats Effect 3.5.7 和 JDK 21。Main.scala包含九个场景,result.json记录本次实际执行。所有事件通过 Ref 保存,取消时序通过 Deferred 建立,测试不使用随机等待。

清理必须覆盖使用区的所有出口

用普通 flatMap 串联三个动作,可以表达正常路径,却还不能表达释放保障。acquire.flatMap(r => use(r).flatMap(a => release(r).as(a))) 只有在 use 成功返回时,才进入 release。如果 use 报错,接续链短路,释放动作不运行。给最外层加 attempt 只会把失败变成数据,不会补回没有运行的释放。

手动用错误处理器补一个 release,也还需要回答取消路径。取消属于任务生命周期协议,不能假定普通错误恢复能观察到所有取消。此外,在 acquire 已返回资源、清理器尚未安装的间隙,取消可能带来更隐蔽的泄漏窗口。安全资源组合器负责把这些阶段组织为一个协议。

bracket 的教学签名可以写成三段:

1
2
3
4
acquire : IO[R]
use : R => IO[A]
release : R => IO[Unit]
bracket : IO[R] -> (R => IO[A]) -> (R => IO[Unit]) -> IO[A]

这里 A 是业务结果,R 是资源。调用者应得到 A,并在离开使用区前完成对应释放。R 可以是文件,也可以是数据库会话、锁许可或一个后台 fiber。需要管理的共同属性是有效期,而不是“它是否实现 AutoCloseable”。

Resource 把获得和释放配成一个可组合描述,use 再给出消费范围。官方文档说明嵌套资源逆序释放,并强调 use 结束会触发清理。Resource 官方说明。本章的具体次数、事件顺序及失败信息由冻结版本实验检查,不将文档示意图当作已经运行的证据。

Resource 保存获得方法,而非一个已经打开的对象

实验的 resource(log, name, ...) 返回 Resource[IO, String]。字符串只是逻辑句柄,获得时记 acquire-name,释放时记退出原因。返回 Resource 的函数不立即修改日志;修改被封装在 IO 里,只有 use 触发的运行才发生。

1
2
3
4
5
6
7
Resource.makeCase(acquire) { (handle, exit) =>
val reason = exit match
case Resource.ExitCase.Succeeded => "success"
case Resource.ExitCase.Errored(_) => "error"
case Resource.ExitCase.Canceled => "cancel"
record(s"release-$handle-$reason")
}

这个片段省略了实验中的故障参数;完整定义在 Main.scala。makeCase 使释放器能够观察使用区的退出原因。比如连接回收与事务回滚可能需要不同动作,但是否回滚仍由具体事务协议决定。看见 Canceled 不能直接推断服务端事务没有提交。

定义 Resource 时,获得操作应保留在效果里。若先执行打开文件,把已经获得的对象塞进 Resource.pure,描述未被使用时也已经占用资源,而且 pure 不会自动登记对应释放方法。先获得后补生命周期,往往意味着资源取得点已经越过了管理边界。

把构造放进 Resource 也不是无限保险。一个 acquire 内部若先打开第一个对象、又在打开第二个对象时失败,却没有为第一个对象注册清理,Resource 只看到整个 acquire 没有产生返回值。更合适的做法是将两次获得拆成两个 Resource 再组合,使第一个对象成功获得后就有独立释放责任。

成功和使用失败的对照

正常场景返回四十二,同时要求事件严格等于 acquire-ok, use, release-ok-success。如果只断言结果四十二,遗漏 release 仍然可能通过;如果只断言日志包含 release,重复调用释放也可能通过。严格列表断言同时限制次数和顺序。

使用失败场景先获得 bad,然后在 use 中抛出消息为 body 的异常。最终 attempt 必须得到这个失败,事件必须为 acquire-bad, release-bad-error。这里没有业务成功值,也没有成功退出标记。清理与错误传播同时存在,不能为了清理而把错误吞成一个默认值。

这对导入结果的设计有直接影响。读取到第十条后解析失败,释放文件并不能把前九条已经执行的写入撤销。若接口返回“整批失败”,还需要定义已提交部分如何表示、是否重跑、重跑是否幂等。Resource 只能管理获得的对象,不会自动扩展成跨系统事务。

相反,如果业务允许部分成功,可以让 use 返回包含成功项和错误项的数据,但这个选择需要在类型中表达。把所有异常 catch 成空列表,会把“没有输入”和“读取失败”压成同一个结果。资源关闭正确并不弥补这种信息丢失。

获取失败时不存在可释放的该层句柄

获取失败场景记录 acquire-no 后抛出异常。断言要求没有 unexpected-use,也没有 release-no-*。此处的事件名称表示开始尝试获得,并不表示已经成功获得;返回 R 才是该层 Resource 成功交付句柄的观察点。

这个负面断言很重要。若教程声称“无论如何都会调用 release”,读者可能在 release 里假定有一个完整句柄,继而为 acquire 失败路径制造第二个异常。正确讨论必须指定对象:成功获得的资源有清理责任,尚未成功获得的当前层资源没有对应的返回值可交给释放器。

但已经获得的外层资源仍然需要释放。实验先获得 outer,再尝试获得 inner,inner 的 acquire 抛错。结果事件是 acquire-outer, acquire-inner, release-outer-error。它证明外层清理没有因为内层失败而丢失,同时拒绝错误地释放尚未成功获得的 inner。

文件输入与压缩解码器可以按这个形状设计。先获得底层输入,再建立解码器;如果解码器初始化失败,底层输入仍应关闭。若全部写在一个巨大的构造函数里,必须自行解决半初始化对象;把获得步骤拆成组合资源,能够使已完成的获得阶段具有独立清理范围。

取消需要一个已经进入 use 的信号

取消测试最容易产生假结论的写法,是启动 fiber 后立刻 cancel,再要求存在释放记录。任务可能在获得资源之前就被取消,这时没有释放记录是合理行为。测试必须先证明目标资源确实已经进入使用区。

实验创建 ready,use 的第一步完成 ready,然后等待 IO.never。测试先等 ready,再取消 fiber。这样取消发生时,资源已经获得,清理器也已经归属当前作用域。

1
2
3
4
5
6
7
8
9
for
ready <- Deferred[IO, Unit]
fiber <- resource(log, "cancel").use { _ =>
ready.complete(()) *> IO.never[Unit]
}.start
_ <- ready.get
_ <- fiber.cancel
outcome <- fiber.join
yield outcome

Deferred.get 表示等待信号,不占住线程做自旋;IO.never 提供可取消等待。测试要求 join 结果是 Canceled,事件严格为获得与一次 cancel 原因释放。它同时观察最终任务状态与清理动作,避免把任意异常终止都解释成取消成功。

取消过程中,释放器可能还要等待外部驱动完成。第29章的任务实验会故意让 finalizer 等待门控信号,观察 cancel 尚未返回的中间状态。这里的释放只是更新 Ref,因此这个场景不能证明真实磁盘关闭、连接断开或远端请求停止的时间上限。

释放本身也不宜无限等待。一个拥有业务截止时间的请求,可能仍因不可取消清理而在截止之后继续占用资源。设计上需要根据具体驱动确定关闭是否有自己的超时、错误如何记录、是否允许中断关闭。把清理粗暴放进可取消区,也可能让释放进行到一半就被打断,必须先明确资源契约。

嵌套资源为什么逆序释放

实验组合 outer 和 inner,正常退出时要求 release-inner-success 先于 release-outer-success。这对应后获得的对象可能依赖先获得的对象,例如缓冲输出依赖底层文件;先关闭底层再刷缓冲,可能使正常写出无法完成。

组合时用 *> 只丢弃前一个资源的业务值,没有丢弃其释放责任。outer 的字符串不出现在 use 参数里,outer 的 finalizer 仍然存在。这一点不能从“最终返回哪个句柄”推断出来:Resource 描述同时保留生命周期结构和结果值,两者承担不同任务。

如果两个句柄确实共享所有权,还要避免重复关闭。例如包装器的 close 已关闭底层句柄,再把包装器与底层都作为独立所有者管理,可能产生两次底层关闭。某些 API 的 close 幂等,某些关闭动作会额外写数据或更新指标。是否允许重复不能由 Resource 通用类型替调用者决定。

资源作用域也会影响持有时间。把多个请求都放在一个外层连接 use 内,连接只获得一次,但会一直持有到全部请求完成;每个请求单独 use 则有不同的获得次数和隔离边界。减少打开次数不总是正确优化,长期持有可能消耗连接池、延长事务或保留过期状态。

关闭失败与双故障是两个实验问题

关闭失败场景让 use 正常返回四十二,release 记录 success 退出原因后抛出 close-close。最终结果必须是这个关闭错误,不能得到四十二。退出原因 success 描述的是 use 的结束方式,不表示释放器自身也成功了。这一细节决定日志字段应该怎样命名。

如果日志仅写 success,运维读者可能把“业务体成功”误读为“整个资源作用域成功”。可以分别记录业务结果、释放开始、释放完成及释放异常。实验为了保持最小结构只记录释放调用并检查错误;并未将释放调用记录冒充实际关闭完成。

Scala 标准库 Using、Java try-with-resources 与 Cats Effect 的错误处理协议不能互相借用。已有 Scala 33:同步与异步资源的生命周期中,“双重异常要查看主异常与 suppressed”解释的是 Using 的规则,并包含严重级别条件。本章没有把该规则搬到 Cats Effect 3.5.7 上。

本章的双故障场景采用显式策略:关闭错误进入单独的 Ref,关闭效果恢复为 Unit,让原来的业务错误继续传播。断言要求主结果的消息为 body,关闭记录恰好为 List("close")。这是应用自己选择的策略,不是对 Cats Effect 默认双错误优先级的断言。

1
2
3
4
5
6
Resource.make(IO.unit) { _ =>
IO.raiseError[Unit](new Exception("close"))
.handleErrorWith(e => closeErrors.update(_ :+ e.getMessage))
}.use { _ =>
IO.raiseError[Unit](new Exception("body"))
}

这段代码的教学价值在于让两个故障都可观察,但它也改变了接口契约:若业务成功而关闭失败,同样的 handler 会把关闭失败从返回通道中移走。实际项目不能直接复制它并忘记这个后果。若关闭关系到数据刷写,常常需要把关闭失败保留为整个操作的失败,或者把两个错误组成明确的结果类型。

使用内存 Ref 记录错误只适合实验。进程退出后 Ref 内容消失,生产诊断还需要可靠的日志或指标策略;日志设施自身也可能失败。这里不设计通用错误聚合器,只把“默认协议”“显式恢复”“外部可观测性”三件事分开,防止错误信息被一句“finally 总会执行”盖住。

返回句柄不会延长它的有效期

最后一个场景获得一个记录关闭状态的 Ref,把它作为 use 的结果返回。use 结束后 finalizer 把状态设为 true,外部虽然仍然持有同一个 Ref,读取到的已经是关闭状态。这是对作用域逃逸的最小演示。

1
Resource.make(IO.pure(closed))(_.set(true)).use(IO.pure)

类型系统允许返回 R,不等于证明 R 离开作用域后仍可使用。若 R 是文件读取器,后续读取会触及已经关闭的句柄;若返回的是惰性 Iterator,错误可能推迟到 next 才出现。若返回后台任务句柄,任务可能还没读取任何数据,资源就已经释放。

正确修复通常是把完整消费放在 use 内,返回与资源脱离的数据。例如文件较小时返回已物化的列表;文件较大时让消费效果保持在同一个流作用域。不能为了消除资源逃逸就无条件读完整个大文件,这会把生命周期问题转化成内存问题。

对于后台任务,更稳妥的结构是让任务本身也有归属关系。使用区等待子任务完成,或者退出时取消子任务并等待其 finalizer,然后再释放子任务使用的资源。仅仅将句柄包装成另一个 IO 返回,仍然不能修复已经结束的资源有效期。

获取阶段与使用阶段为何采用不同保护

获取资源成功与登记释放责任之间不能任意插入取消点。否则一个文件已经打开,任务却在释放器归属建立前退出,后面没有任何代码负责关闭。普通 Resource.make 的获取与释放采用受保护的取消边界,使用区则可以响应取消。这里的保护不是把 JVM 线程锁住,而是效果取消协议暂时不在这些位置终止计算。

这带来一个需要权衡的后果:如果获取函数内部永久等待一个外部条件,整个获取阶段可能迟迟无法完成。不能因为使用了 Resource 就忽略获取操作自身的协议。连接池等待许可、创建实际连接和验证连接健康,往往需要区分;已有库若提供专门的可取消获取接口,应按其文档使用,不能把整个任意耗时过程都塞进一个不了解边界的 acquire。

本文没有直接实现低层取消屏蔽组合器,也没有验证取消发生在获取中间的所有交错。当前获取失败测试只说明效果报告失败、没有交付该层句柄时不进入 use 或 release。受保护获取的说明来自 Resource 与 MonadCancel 文档,不能把这个文档层面的解释混写成实验已经覆盖所有窗口。

对一个需要两步初始化的对象,可以先问第一步成功后是否已有可独立关闭的状态。如果有,把第一步表示成 Resource,再在其范围内获取第二步,能够利用已有逆序释放规则。如果没有,而底层构造器可能在抛错前分配不可见资源,就需要底层库保证失败原子性或提供清理能力。外层 Resource 拿不到未返回的内部对象,不可能替实现者找到它并关闭。

这也是为什么“获取失败不释放”与“部分获取要清理”并不矛盾。前者讨论当前层没有返回的句柄,后者讨论此前已经被其他层成功管理的句柄。实验中的 outer 与 inner 把这个对象区分直接编码进事件名;在复杂代码中也应保留类似的资源身份,而不是只记一条没有对象信息的 close。

用所有权图判断资源应放在哪一层

假设一个导入任务读取压缩文件,再写入一个批次会话。输入流、解码器与会话不一定有相同寿命:输入流覆盖整个读取过程,解码器依赖输入流,会话可能只覆盖一个批次。将三者全部放进最外层 use 虽然容易得到统一清理,却会使会话持有到整份文件结束。若会话占用有限连接,范围过宽本身就可能成为容量问题。

反过来,把输入流放在每个批次的 use 中重新打开,也可能每次从文件开头读取,造成重复处理。作用域设计必须结合可恢复位置、重复读取能力和资源成本。Resource 的组合规则可以保证按照声明的范围释放,但无法判断声明的范围是否符合业务需求。

一个有效的审查方式是对每个资源分别标记创建者、消费者和最终关闭者。若关闭者有两个,应检查是否存在所有权转移或幂等约定;若没有关闭者,应查找是否依赖了调用方未明确承担的责任;若消费者可能比关闭者长寿,则存在逃逸风险。这个审查不是让所有资源都有全局注册表,而是在代码结构中找到每个范围的确定拥有者。

同样需要检查失败后的可观察性。事件列表严格相等能够在测试里发现重复释放,但生产日志常会异步写出,文本出现顺序未必等于动作发生顺序。为了诊断,应给资源实例一个关联标识,并区分获得成功、释放开始与释放结束。日志设计是诊断辅助,不能反过来代替程序里的同步和所有权协议。

对于必须先刷出缓冲再关闭底层的对象,还应检查关闭失败是否允许重试。重复调用 close 可能无害,也可能再次提交部分数据;不能把失败后的重试默认放进通用 finalizer。应先阅读具体资源协议,再决定是记录失败、补偿、转交后台恢复,还是把错误返回给业务调用者。本章逻辑字符串没有这种外部副作用,所以只检验调用次数,不假设任何重试安全性。

从九个断言建立验收边界

运行入口如下,唯一结果文件已在文首给出;实验没有生成未引用的日志包。

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

本次正常、使用失败、获取失败、取消四条基本路径全部通过。附加断言覆盖关闭失败覆盖成功值、内层获取失败释放外层、正常逆序释放、显式双故障策略及返回句柄不延长作用域。正向断言检查应发生的释放,负向断言检查未获得资源不释放、不进入 use,以及失败后不交付成功值。

类型题:Resource[IO, R].use(f) 中若 f: R => IO[List[A]],结果是什么类型?答案为 IO[List[A]]。再考虑 f: R => IO[Iterator[A]],类型仍然合法,但需要说明 iterator 是否捕获 R。只有类型推导正确,不能判定资源安全。

手算题:outer 已成功获得,inner 获得失败,use 尚未开始。写出允许的 release 列表以及顺序。应该只有 outer;没有 inner 释放,也没有 use。若 inner 的获得函数内部私自打开第三个对象后失败,该对象的清理责任不能从当前实验的事件表中得到保证。

修改题:在内层 release 注入错误,保持外层正常,增加断言证明外层释放仍被调用,并明确最终失败信息。再把 use 内的 IO.never 改成可返回的值,检查退出原因从 cancel 变为 success。修改时保留原四路径,不能只运行新增正常分支;生命周期修复最容易遗漏的正是与目标分支相邻的出口。

进一步的 API 背景可读 MonadCancel 文档。本章已使用真实库验证上述有限输入,未测试进程强杀、虚拟机崩溃或所有外部驱动;这些情形不能由普通 finalizer 机制承诺资源动作必然完成。

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