Effect.txRetry 怎么让事务等条件满足?
手写「余额不足就等」要自己配条件变量或者轮询,还得保证等待期间不把半截的修改暴露出去。
在 Effect v4 的事务里,写一行 Effect.txRetry 就够了:它让当前事务阻塞,直到本次事务访问过的任一 TxRef 发生变化,然后从头重跑事务体。 它不是忙等,事务被挂起,直到被唤醒。
Effect.tx 是 v4 里取代 STM 的事务边界,事务体是普通 Effect,背景见 Effect v4 没有 STM 了,怎么原子地改多个状态?。下面用一个例子看 txRetry 的行为,再讲它的唤醒条件。
txRetry 在事务里怎么用?
下面这个例子同时演示三件事:每个原语自己包一层 Effect.tx、多个账户原子汇总、余额不足时阻塞:
import { Effect, Fiber, TxRef } from "effect"
// 每个原语自己包一层 Effect.tx:单独调用是一个事务,被组合时并入外层事务
const withdraw = (account: TxRef.TxRef<number>, amount: number) =>
Effect.tx(Effect.gen(function*() {
const balance = yield* TxRef.get(account)
if (balance < amount) return yield* Effect.txRetry // 余额不足:阻塞,直到 account 变化后重跑
yield* TxRef.set(account, balance - amount)
}))
const deposit = (account: TxRef.TxRef<number>, amount: number) =>
Effect.tx(TxRef.update(account, (balance) => balance + amount))
// 从多个账户原子地各扣一笔,汇入同一个账户:要么全部成功,要么什么都没发生
const sweep = (sources: ReadonlyArray<TxRef.TxRef<number>>, target: TxRef.TxRef<number>, each: number) =>
Effect.tx(Effect.gen(function*() {
for (const source of sources) {
yield* withdraw(source, each)
}
yield* deposit(target, each * sources.length)
}))
const program = Effect.gen(function*() {
const a = yield* TxRef.make(100)
const b = yield* TxRef.make(10)
const vault = yield* TxRef.make(0)
// b 只有 10,sweep 每户要 50:整个事务阻塞在 b 上,a 的扣款对外不可见
const fiber = yield* Effect.forkChild(sweep([a, b], vault, 50))
yield* Effect.sleep("20 millis")
console.log("阻塞中", yield* TxRef.get(a), yield* TxRef.get(b), yield* TxRef.get(vault))
yield* deposit(b, 60) // b 变化,被阻塞的事务自动重跑并提交
yield* Fiber.join(fiber)
console.log("提交后", yield* TxRef.get(a), yield* TxRef.get(b), yield* TxRef.get(vault))
})
Effect.runPromise(program)输出:
阻塞中 100 10 0
提交后 50 20 100为什么 withdraw 和 deposit 可以各包一层 Effect.tx?
因为嵌套的 Effect.tx 不开新事务,而是复用外层的日志和重试状态。withdraw 和 deposit 单独调用就是事务,被 sweep 组合时自动并入外层,我不需要为「独立版」和「可组合版」写两套函数。
sweep 阻塞期间,a 的余额仍然是 100:对 a 的扣款只写在日志里,没有提交,外界不可见。最后 deposit(b, 60) 改变了 b,阻塞的事务被唤醒,重跑后一次性提交,a、b、vault 同时变化。
什么时候事务会被唤醒,什么时候会永远等下去?
唤醒条件是「本事务访问过的 TxRef 变化」。如果你的条件依赖的状态没有通过 TxRef 读取(比如读了一个普通变量或外部 API),变化它不会唤醒事务,事务会永远等下去。所有影响判断的状态都必须放在 Tx 结构里。
不用每次都手写 txRetry:TxQueue.take 在空队列上就会在事务内阻塞,这个阻塞同样能发生在更大的事务里面。
阻塞的事务能被中断或超时吗?
能。被阻塞的事务可以像任何 effect 一样被中断或超时。我用 Effect.timeout 包住一个永远等待的事务试过,它按预期以超时失败。
阻塞也意味着事务体会被重新执行,里面的副作用会重复,见 Effect.tx 事务体里有哪些坑?。
版本是 effect@4.0.5。示例代码实际运行过,输出如文中所示;用Effect.timeout包住永远等待的事务并超时失败,也在本地试过。txRetry的唤醒条件(访问过的TxRef变化)来自官方文档对机制的描述,没有另外构造反例验证。