返回Iterator或Future时,工作可能还没完成

一个读取文件的方法把 Source 放进 Using.resource,返回 getLines() 得到的迭代器。方法正常返回,资源也被关闭,但调用者随后才取第一行,实际读到的是已经关闭的输入。另一个方法在同样作用域里创建 Future,Future 尚未使用资源,作用域就已经结束。

这两类错误有相同结构:词法作用域结束得比实际消费早。自动关闭机制按函数体的返回时刻工作,并不会检查返回值是否还间接依赖资源。Iterator[String] 和 Future[Int] 的类型本身没有证明它们与文件或连接的生命周期脱离。

本章同时使用真实临时文件和可记录事件的资源。正常路径、业务异常、关闭异常、双重异常、嵌套资源、惰性逃逸和异步逃逸都有断言。异步反例用门闩固定顺序,不靠线程调度偶然出现故障。

冻结环境为 Scala 3.3.7、Scala CLI 1.9.1、JDK 21.0.11;Using 实现核验到标准库 2.13.16 固定提交。具体运行证据位于 examples/scala-lab/evidence/20261002-ch33-r2/。

先明确获取、使用、关闭和消费

对同步立即计算,可以把时间线写成 acquire → use → close → return。业务结果如果已经完全独立于资源,关闭后仍能安全使用。例如把全部文件行物化成不可变列表,返回列表结构后无需再次读取源文件。

但“调用了使用函数”不一定表示数据已经消费。getLines() 可以先返回一个迭代器,真正读取发生在后续 next。这时应增加 consume 事件,检查它究竟位于关闭之前还是之后。

异步情况还多出提交与完成。创建 Future 可能很快返回句柄,任务体稍后才进入 use。如果关闭发生在句柄返回之后、任务完成之前,类型正确和提交成功都不能保护资源。

因此资源审查要沿实际动作追踪,而不只看大括号覆盖了哪些源码。函数值、迭代器、流、Future 或保存到外部字段的引用,都可能把使用动作移到作用域之外。每一种返回值都应问:后续访问是否还会调用原资源。

本章事件记录使用短字符串表达这些时刻。同步场景只在一个线程中更新;异步场景通过 Future 完成和门闩建立明确先后,再读取记录。它不是提供一个可供任意并发写入的通用日志容器。

Using和Using.resource怎样处理结果

Using(resource)(body) 返回 Try[A],用于承载按 Try 规则捕获的结果。Using.resource(resource)(body) 则直接返回 A,发生异常时按相应规则抛出。二者都围绕作用域退出执行释放,但结果外形与调用者处理方式不同。

正常实验资源在构造时记录 acquire,use 返回四十二,close 记录关闭。Using.resource 返回四十二,事件列表严格等于获取、使用、关闭。这个断言同时验证了业务结果与关闭动作,而不是只检查没有异常。

业务异常场景在使用之后抛出普通 IllegalArgumentException。Using 返回失败,事件仍包含关闭。本次结果说明该异常路径会执行释放;没有把所有可能的进程终止方式或虚拟机故障都描述成可恢复的普通 Try。

关闭异常场景则让业务使用成功、关闭函数抛出异常。最终结果失败,说明“业务计算返回了值”并不意味着整个资源作用域成功完成。写文件、提交缓冲或关闭网络流可能在释放阶段失败,调用者不能只关注函数体内部返回值。

这也影响成功响应的时机。如果在关闭之前就对外宣布整个操作成功,而关闭负责关键刷写动作,响应可能领先于真正完成。应按资源契约确定何时算完成,本章只用模拟关闭异常展示这类边界。

双重异常要查看主异常与suppressed

实验让业务体抛出消息为 body 的 IllegalArgumentException,关闭又抛出消息为 close 的同类异常。实际结果保留 body 作为主异常,getSuppressed 中有 close。这两条信息都被断言,避免关闭错误覆盖业务原因或者完全丢失。

不能由这一组结果推广成“Using 永远优先保留业务异常”。标准库按异常严重级别选择主异常;严重级别相同时,先发生的异常保留为主异常,后者受到抑制。不同类别的 Throwable 可能得到不同优先关系。

冻结源码的 preferentiallySuppress 对异常评分,再调用 addSuppressed。文档还说明控制异常或禁用抑制的异常可能具有特殊行为。本章只实验同级普通异常,因此正文结论保留这个范围,不用一个常见例子替代完整规则。

排查双重故障时,应保留主异常堆栈和 suppressed 列表。仅记录 getMessage 可能漏掉关闭失败;反过来只打印最后一个异常,也可能失去最早导致业务中止的原因。日志格式应能表达异常之间的关系。

资源封装如果自行捕获并吞掉关闭异常,就改变了这些行为。是否允许忽略关闭失败需要明确理由,不能因为异常发生在 finally 就默认无关紧要。对于纯释放内存句柄与负责提交数据的关闭操作,失败影响可能完全不同。

多个资源按注册顺序的反向释放

Using.Manager 可以注册多个资源。本章先获取 outer,再获取 inner,实际事件为:

1
2
3
4
acquire-outer
acquire-inner
close-inner
close-outer

反向释放适合后获取资源依赖先获取资源的情形,例如包装层依赖底层连接。冻结源码把新注册资源放到列表头,结束时从列表头依次释放,这与观察到的顺序一致。

注册也有边界。如果资源构造在注册前失败,管理器只知道已经成功注册的资源。需要审查构造过程是否会先获取其他资源再抛异常,否则可能出现尚未交给管理器的中间资源。高级封装可以要求构造时取得管理器并及时注册,但必须按实际获取步骤设计。

本章没有制造所有嵌套构造失败形状,只验证两个已注册资源的正常逆序关闭。源代码和文档解释了管理器保存的对象范围,实验则限定到可以直接观察的事件序列。

包装资源可能在自身 close 中关闭底层对象。若又把两者独立注册,就可能发生重复关闭。某些资源允许幂等关闭,某些实现有额外行为;是否重复注册应根据所有权约定决定,不能因为管理器能够接收多个对象就全部加入。

对于没有 AutoCloseable 接口的资源,Using 还可以通过 Releasable 表达释放方式。释放契约应准确对应资源,不宜为了通用化把“保存”“提交”“撤销”和“关闭”都混成同一动作。

真实文件揭示惰性读取逃逸

程序创建临时文件,内容是两行 alpha 和 beta。一个具名包装器持有 Source,获取时记录 acquire,创建行迭代器时记录 use,关闭后记录 close。作用域返回迭代器之后,调用者记录 consume,再执行 next。

实际事件严格为 acquire/use/close/consume,而 next 抛出 IOException。这不是只靠模拟资源的 closed 标志人为制造错误:真正的文件输入已经关闭,后续读取触及关闭状态。

修复路径在作用域内调用 getLines().toList,把当前文件全部物化后再返回,得到 List("alpha", "beta")。读取发生在资源有效期内,返回的列表不再依赖 Source 完成后续读取。

这个修复具有内存代价。大文件全部物化可能不合适,但不能因此回到未受管理的迭代器逃逸。另一种设计是把消费者作为函数传入,使遍历在资源作用域内完成;也可以使用具备明确生命周期的流式抽象。选择取决于数据规模和调用契约。

如果确实需要向调用者返回一个可关闭迭代器,应把关闭责任、提前停止和异常路径写进接口。普通 Iterator 类型没有强制调用方执行关闭,因此仅返回迭代器往往无法完整表达资源所有权。

Future创建成功时,资源作用域可能已经结束

错误异步样例的形状如下:

1
2
3
4
5
6
Using.resource(resource) { r =>
Future {
release.await()
r.use()
}
}

Future 任务等待门闩,资源作用域则在返回 Future 句柄后结束。主线程先断言事件只有 acquire、close,再释放门闩。任务随后调用 use,得到 already closed 失败,最终事件为 acquire、close、use。

这组顺序由门闩控制,不依赖线程恰好启动得慢。如果移除门闩,某些运行可能在关闭之前完成使用,看起来正常;另一些运行则失败。偶然成功不能证明生命周期正确,因为两个动作之间仍缺少所需完成关系。

Using 无法从返回值类型自动推断 Future 内部何时不再使用资源。它只知道函数体已经返回。把返回值包在 Try 中也不会等待异步完成:可能得到一个成功创建的 Future,而那个 Future 稍后失败。

因此错误处理也分成两层。资源获取或句柄创建可能同步失败,Future 中实际使用又可能异步失败。若调用方只检查最外层创建是否成功,就会遗漏后续失败。设计接口时应尽量让最终业务结果包含需要等待的工作。

把同步资源作用域移到任务体内

本章修复为:

1
2
3
Future {
Using.resource(resource)(_.use())
}

资源在任务开始后获取,使用在同一同步作用域内完成,再关闭,最后任务成功得到四十二。事件为 acquire、use、close,Future 完成之后读取记录并断言顺序。

这个结构适用于资源操作本身是同步的情形。若 use 又返回另一个异步句柄,同样的逃逸问题会在内层再次出现。大括号移进 Future 并不是对任意异步资源都通用的生命周期管理器。

同步阻塞 I/O 放进 Future 之后仍会占用工作线程。修复了关闭时机,不等于解决了线程容量问题。实际应用应选择合适的执行上下文,限制并发,并根据资源获取成本和外部系统能力安排工作。

对于真正异步的获取、使用和释放,需要把释放连接到最终完成关系,并保证所有正常、失败和取消路径都处理。仅在成功 map 中 close 会漏掉失败路径;在外层 finally 中 close 又可能过早。标准库 Future 缺少统一取消协议,不能把一个简短手写组合声称为完整异步 bracket。

本章没有预先引入效果框架,也没有实现通用异步资源库。它通过正反两种具体结构说明同步 Using 的适用范围,为后续选择已有资源抽象提供判断依据。

异常恢复的位置会改变资源范围

如果在资源作用域内捕获业务异常并继续使用资源,应确认资源仍处于可用状态。有些协议发生读取或写入错误后已经不可继续,简单恢复一个值可能掩盖需要关闭重建的连接状态。

如果在作用域外恢复,则资源已经按规则释放,恢复逻辑不能继续使用原引用。需要再次访问时,应重新获取或使用独立数据。把已经关闭的对象保存在异常对象、闭包或外部字段中,也可能造成更隐蔽的后续使用。

在异步场景中,恢复函数的执行时间还取决于 Future 完成。它可能晚于原词法作用域很多,因此应避免依赖已结束作用域中的资源。只捕获不可变结果或明确仍有效的依赖,通常更容易保持正确。

恢复本身失败时也需要观察最终结果。若只是注册一个完成回调负责关闭,回调异常可能走执行上下文报告路径,调用方未必把它视为业务失败。必须明确释放失败应如何反映到最终结果,不能靠日志打印隐式决定。

本章同级双重异常实验说明同步 Using 已经提供了一套明确合并规则。手写异步资源封装如果要宣称等价,就需要覆盖相同问题及异步特有的时序、取消和并发释放,而不是只复制 finally 的外观。

所有权比“有没有close调用”更重要

同一个对象被两个组件共享时,谁有权关闭必须明确。使用者不一定是拥有者:调用者传入的共享客户端,库方法通常不能在一次调用结束后关闭;方法自己新建的文件输入则应负责释放。

引用数量也不能决定关闭时机。一个资源被封装在对象里并传入多个 Future,即使只调用一次 close,也可能发生早关;反过来重复 close 可能没有立刻异常,却仍违反协议或造成难以解释的状态。

资源作用域应覆盖最后一次必要使用,而不是覆盖变量可见范围就算完成。惰性迭代器和异步任务显示了两种不同的延后消费,回调、缓存与对象字段还可以继续延长引用寿命。审查时要沿消费者追踪到实际 I/O 点。

接口文档可以直接声明“回调返回后资源关闭”“返回的是已物化内容”“调用方必须关闭返回句柄”。这些约定比“自动管理资源”更容易检验。测试则用故障注入验证约定:使用抛异常、关闭抛异常、提前停止、延迟消费分别会怎样。

获取失败与关闭失败不能共用一个标记

资源可能在构造时就失败,例如文件不存在,此时没有成功取得可关闭对象。资源也可能取得成功、使用失败,但关闭成功;还可能只有关闭动作失败。这些状态对重试和诊断的含义不同,不能只用一个模糊的布尔值记录“处理过”。

尤其不能在 finally 中无条件把 closed 设为 true,再据此声称底层关闭成功。finally 能证明控制流程经过某处,不能证明其中的操作完成。需要观察释放结果时,应分别记录关闭尝试与关闭成功,或者保留异常。

本章模拟 Resource 的 closed 表示它的逻辑状态已经置为关闭,随后可能抛出人为注入的关闭异常;这与真实文件包装器不同,后者只在 source.close() 返回后记录 close 事件。两种装置各自支持不同观察,不能把模拟状态直接当作文件系统已经确认释放的证明。

真实文件实验没有注入操作系统关闭失败,因此本文对文件的结论是正常关闭后的延迟读取被拒绝。关闭异常规则由显式会抛异常的资源另行验证。把实验对象与观察范围一起写清,比用一条“资源都能正确释放”概括更容易复核。

用消费边界评估流式接口

对大文件来说,物化全部内容会把资源生命周期问题换成内存占用问题。一个较直接的接口是接收处理函数,在资源作用域里完成遍历,只返回独立的统计值或领域结果。例如逐行累计错误数量,最终返回整数,调用方无需保留 Source。

这种设计把关闭责任留在提供者,消费策略交给调用者,但仍应限制回调把原资源或迭代器保存到外部。普通类型系统未必禁止逃逸,因此文档和测试仍然重要。若需要更强约束,可以研究专门的流和资源抽象,而不是假设任何高阶函数都自动安全。

提前停止同样要覆盖。只读取第一条有效记录后退出,并不代表可以忘记剩余输入对应的文件描述符;资源作用域应在回调返回时关闭,不依赖迭代器恰好耗尽。异常退出也应遵循同一所有权规则。

本章的修复选择 toList,因为输入只有两行且目标是区分求值时机。它没有把全量物化推荐为所有文件处理的默认架构。第 36 篇应用实验先物化小文件夹具,再进入异步报价;该程序尚未设置文件行数上限。资源生命周期与外部请求生命周期分开之后,可以分别验收,输入容量仍需另行限制。

复跑与练习

本章状态为 LAB_VERIFIED:

1
python3 examples/scala-lab/run.py chapter 33 --run-id local-ch33

最终证据 20261002-ch33-r2/33.json 与 33.log 记录原始命令及输出。断言覆盖正常四十二、业务异常仍关闭、关闭异常、同级双重异常及 suppressed、逆序释放、真实文件逃逸、物化修复、异步过早关闭与任务内作用域修复。临时文件最终删除,线程池终止有断言。

手算题:Using(source)(_.getLines()) 成功返回,能否认为文件内容已全部读取?不能,返回的是可能仍需读取的迭代器。把结果改成 toList 后可以证明当前样例在关闭前完成物化,但应重新考虑大文件内存占用。

第二题:Using.resource(r)(_ => Future(use(r))) 与 Future(Using.resource(r)(use)) 是否等价?不等价,资源作用域相对于异步完成的位置不同。如果第二种中的 use 又返回 Future,也需要重新分析,不能只看外层结构。

执行练习让两个嵌套资源的 close 都抛出普通异常,同时让业务体失败,检查主异常与 suppressed 列表。再给真实文件消费增加提前终止,在作用域内只读取一行,验证资源仍被关闭。答案应区分本次普通异常观测与标准库对不同严重级别的完整规则。

参考

Using 2.13.16固定源码支持释放顺序、resource与异常合并解释;Using 2.13.16 API说明异常严重级别和抑制规则;Source API提供文件读取接口说明。前篇是超时与共享状态,后续测试篇将这些边界变成可重复回归。

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