取消一个正在等待的任务,释放资源的记录出现了吗?在时间二十恰好发生两次更新,采样会得到哪一个值?前一个问题属于效果运行时的生命周期,后一个属于时变值的语义。两者都可能出现在响应式应用里,却不能用同一个“流”字略过差别。

本篇分成两个独立实验:ZIO 2.1.16 检查业务失败、缺陷、中断与释放;JavaScript 用有限整数时间模型检查保持、组合与采样。它们没有强行连成一个新框架。已有ZIO 的错误与任务生命周期保留配置环境和服务组织背景,FS2 的需求与资源作用域保留流需求背景;新系列入口见导读与能力自测。

错误类型不覆盖全部退出原因

ZIO[R,E,A] 描述需要 R、可能以 E 失败、成功时得到 A 的计算。E 用于可预期的失败通道,而运行时还可能记录 defect 与 interruption。把 E 写成 Nothing,只说明没有可构造的类型化失败值,不表示计算永远不会出缺陷、被中断或者不终止。

实验用 ZIO.fail("quantity") 表示可预期业务错误,用 ZIO.die(new IllegalStateException("bug")) 表示缺陷。再将结果转为 Exit,分别检查 Cause 里的 failureOption 与 defects。代码不靠打印一个 Throwable 的字符串猜类别,而是读取实际结果结构。

为了检验普通 catchAll 不会把 defect 当成业务失败恢复,实验把 die 的类型显式放宽成 IO[String,Unit],再调用 catchAll。运行后 Exit 仍包含 bug 缺陷。若直接在 E=Nothing 的值上调用 catchAll,ZIO 的 CanFail 约束会在编译期拒绝这个无意义的业务失败处理;这次实验过程中也实际遇到了这个类型检查边界。

1
2
3
val defective: IO[String, Unit] =
ZIO.die(new IllegalStateException("bug"))
val observed = defective.catchAll(_ => ZIO.unit).exit

类型放宽没有把 die 转成 fail,也没有制造一个字符串错误。它只让类型层面允许使用 catchAll,从而在运行时验证缺陷通道仍然不同。若确实需要观察全部 Cause,应使用适合原因结构的接口,并决定哪些原因能恢复;把所有问题都替换成成功值会让取消与程序缺陷失去区别。

获取、使用、释放的三个边界

实验采用 acquireReleaseWith:获取时记录 acquire,释放时记录 release,使用阶段返回七或失败。ZIO 的资源管理说明介绍了这一结构以及它跨失败和中断的清理语义。本例用内存事件代替真实句柄,因此证明的是运行时调用释放动作的顺序与次数,不是文件系统或数据库已经完成回滚。

正常使用得到七后释放一次;业务失败 quantity 后也释放一次。另一个场景在获取阶段直接失败,释放记录为零。获取失败意味着没有成功获得需要交给 release 的资源,不能为了追求每条路径都有 release 而伪造一个资源值。

获取过程自己分多步并留下部分资源时,仍需要内部清理策略。外层只知道获取没有成功返回,无法自动推断前半段打开了什么。类似地,release 本身可能出缺陷或阻塞;本例的内存记录不会失败,因此没有验证这些更复杂的清理故障。

资源释放和业务撤销也不同。一个请求已经写入远端,再被本地任务取消,关闭连接并不能证明远端写入被撤回。如果需要幂等或补偿,应在业务协议里处理。本实验不连接外部服务,不能把 release=3 扩大成分布式事务成功。

可靠中断测试先建立到达信号

若 fork 后立刻 interrupt,子任务可能还没获得资源,测试就无法证明使用中的资源会被释放。固定睡眠也只提供调度概率。实验在进入使用阶段后完成一个 Promise,父任务等待该信号,再发中断:

1
2
3
4
5
6
ready <- Promise.make[Nothing, Unit]
fiber <- ZIO.acquireReleaseWith(record("acquire"))(
_ => record("release")
)(_ => ready.succeed(()) *> ZIO.never).fork
_ <- ready.await
interrupted <- fiber.interrupt

ready 表示获取已经完成且进入使用阶段,不表示业务工作完成。ZIO.never 使子任务在可中断区域等待,父任务的 interrupt 等待终止,之后检查 Exit 为中断,并检查 release 已写入事件序列。没有依靠日志先后猜测任务状态。

三个成功获取的场景最终形成六条事件:正常获取/释放、业务失败获取/释放、中断获取/释放。失败获取不会加入这六条中的 acquire,因为该场景的获取动作直接失败。严格比较完整 Vector 比只统计三次 release 更强:额外释放或错乱顺序也会被发现。

这组实验不覆盖不可中断区域里的永久阻塞、外部阻塞调用的中断响应或应用进程被强制终止。运行时的合作式中断需要相应代码提供可响应边界。测试中 never 可以响应中断,所以结果只支持该明确场景。

FRP先声明时间与输入域

另一个实验定义时间为整数,不读取墙钟。事件是有限数组 {time,value},约定按 time 非递减排列;同一时刻的事件按数组稳定顺序处理,最后一项决定该时刻的值。初始值在第一个事件之前有效。这个约定是 hold 的输入契约,未排序数组不属于本实验定义域。

1
2
const hold = (initial, events) => t =>
events.reduce((v, e) => e.time <= t ? e.value : v, initial);

这个实现扫描数组,不自行排序。若数组先放 time=20,再放 time=10,查询二十会被旧事件覆盖,无法得到时间上最近的更新。因此真实接入无序消息时,应先定义重排、迟到事件、同刻冲突和水位策略,再选择相应实现。不能把 reduce 的数组顺序误认成天然时间顺序。

quantity 初值一,时间十变成二,时间二十先变成三再变成四。定义 price(t)=quantity(t)*5。查询零、九、十、十九、二十、二十一,数量为 1,1,2,2,4,4,价格为 5,5,10,10,20,20。组合保留相同的查询时刻,没有读取另一个独立时钟。

条件使用 <=,所以事件在发生时刻就生效。对于这个分段保持模型,可将它视为实数时间上的右连续阶梯函数再限制到整数采样;在二十处值为四,二十之前的保持值为二。数组中的中间三在同刻最后写入规则下没有单独持续时间,但它仍可能是事件序列中的一个事件,不能据行为采样结果说该事件从未存在。

Elliott 与 Hudak 的Functional Reactive Animation 作者材料区分时变行为与事件,并以时间语义支撑组合。本文只实现一个有限、离散、无反馈的教学子模型,不声称覆盖原论文的连续时间、事件检测或动画系统。

采样会丢失信息

构造一个脉冲:初值零,时间五变成一,时间六恢复零。只在零和十采样得到 [0,0],但查询五得到一。样本没有看到变化,不等于底层行为恒为零,更不等于事件没有发生。

这与监控和 UI 都有直接关系。低频采样显示的最后状态可能正确,却无法恢复两个采样点之间的短暂越界;对每个事件分别处理可以保留事件,但也会引入缓冲与消费能力的问题。选择行为观察还是事件消费,要由业务问题决定,不能把两个结果相互替代。

同刻的先后顺序同样要明确。如果 quantity 与单价都在时间二十更新,计算总价时使用更新前还是更新后的两个输入,决定了是否出现混合状态。当前 price 使用常量单价,所以没有实现多源一致传播问题;扩展到多个变化源时,需要给出逻辑时刻、批次或拓扑传播规则。

这里也没有凭函数形式自动得到增量效率。每次查询 hold 都扫描整个事件数组,多次查询的成本随事件数与查询次数相乘。可以用二分查找有序事件或保存推进游标优化,但游标会引入查询时刻单调性的前提,二分查找则仍要处理同刻最后值。正确性模型应先固定,再优化实现。

Reactive Streams回答不同的问题

Reactive Streams 的需求与背压协议关注生产者、消费者和请求数量;FRP 模型关注某个时间的行为值、事件发生与组合含义。某个库可以同时提供二者,也可以只提供其中一部分。看到发布订阅、Observable 或 Stream 名称,不能直接推导它采用了本文的时间和采样规则。

本篇 ZIO 实验管理的是任务与资源,FRP 实验没有订阅者、线程或请求计数,两者各自成立。若把事件处理接到 ZIO 任务里,需要再定义订阅资源由谁关闭、错误如何改变行为、取消后是否接受迟到事件。当前实验没有实现这个连接,因此不会用一个效果运行成功来宣称 FRP 系统完成。

边界数据如何暴露错误模型

只有一个事件时,很多错误实现都会得到相同结果。两个不同时刻的事件可以区分是否取最新值,同刻两个事件可以区分稳定次序,短脉冲可以区分行为与样本。这些输入分别针对不同假设,不应只用更多随机整数替代。

对于同刻更新,如果业务认为它们必须合并相加,那么“最后值覆盖”就不是正确的 hold 定义。若业务认为同刻冲突非法,应在构造阶段拒绝。实验选定最后值覆盖只是一个明确可检验的模型,不是所有 FRP 系统都必须遵守的规定。

资源事件也采用相同原则。只跑正常路径看见 release,无法区分“正常返回后手动关闭”与“所有退出路径都清理”。加入类型化失败和已进入使用区间的中断,才能排除这些错误实现。失败获取则检查另一个方向:没有获得资源时,不应执行需要资源的释放动作。

把这些边界拆开还有助于解释失败。若中断 Exit 正确却缺少 release,问题在生命周期;若 release 正确却远端状态仍改变,问题可能在业务协议;若数量采样错误,先检查时间域与排序,不应把它归因于 ZIO 调度。证据必须对应具体模型层,跨层猜测会让测试结果失去约束力。

运行记录与修改题

运行 node examples/functional-programming/run.mjs E08,runner 强制 Scala 3.3.7,依赖固定 ZIO 2.1.16。实际输出包含:

1
2
3
4
zio=2.1.16; success=7; typed=quantity; defect=bug; interrupted=true; release=3; failed-acquire-release=0
events=acquire,release,acquire,release,acquire,release
FRP integer-time/right-continuous/stable-ties: quantity=1,1,2,2,4,4; price=5,5,10,10,20,20
pulse-at5=1; samples-at0,10=0,0; left-limit-at20=2; value-at20=4

全部输入为Main.scala与frp.mjs,命令、哈希和退出状态见result.json。

自测:E=Nothing 为什么仍可能得到中断 Exit?ready 完成为什么不等于业务成功?二十时刻的中间值三为什么没有出现在 quantity(20) 中?这三个问题分别检查类型化失败范围、生命周期信号和同刻冲突规则,不能用“最终都完成了”统一回答。

修改题给 hold 增加输入验证,对无序时间、非整数时间和同刻多事件分别测试;若选择排序,必须保留同刻稳定次序并说明是否改变输入数组。另一项修改给 ZIO 资源使用阶段加入 die,验证缺陷路径也释放,同时保持现有失败获取与中断测试。新增释放次数应由新增场景推导,不要直接把预期数字改到绿色。