共享状态:Ref、Queue、PubSub、Semaphore 怎么选
第 13、14 章让你开始同时运行多个 fiber。一旦同时跑,就会冒出新问题:它们怎么共享一个计数器?一个 fiber 产生的任务怎么交给另一个?一个事件怎么让好几个 fiber 都知道?同时最多允许几个在跑?这一章不按 API 清单走,而是按这四个问题走,每个问题对应一个原语。
朴素做法为什么不够
JavaScript 是单线程的,很多人因此以为共享变量没有竞态。这只对「没有 await 的同步片段」成立。凡是读和写之间隔着一个异步点,别的任务就可以插进来:
let counter = 0
async function bump() {
const current = counter // 读
await somethingAsync() // 别的任务在这里插入
counter = current + 1 // 写:覆盖了别人的更新
}一百个并发的 bump 跑完,counter 远小于 100。这是经典的「读-改-写」丢失更新。而 Effect 的 fiber 在每个 yield* 处都可能被切换,所以同样的问题会原样出现。
此外,用数组当任务队列、用事件回调做广播、用自旋的 while 限制并发,都是在重新发明这四个原语,并且通常漏掉背压、取消和关闭语义。
选型的核心:先问「你要解决什么」
四个问题,四个原语:
- 我要保存并更新一个值:Ref。
- 我要把工作项交给一个消费者(每项只被处理一次):Queue。
- 我要把事件告诉所有订阅者(每项被每个人看到):PubSub。
- 我要限制同时进行的数量:Semaphore。
记住这一条就够了:Queue 是「分发」,PubSub 是「广播」。同样是「一个 fiber 发、别的 fiber 收」,语义完全不同。
Ref:并发安全的一个值
Ref<A> 是一个可以原子更新的盒子。它解决的就是上面的丢失更新:把「读-改-写」收拢成一步。
import { Effect, Ref } from "effect"
export const program = Effect.gen(function*() {
const counter = yield* Ref.make(0)
yield* Effect.forEach(
Array.from({ length: 100 }, (_, i) => i),
() => Ref.update(counter, (n) => n + 1),
{ concurrency: "unbounded", discard: true }
)
return yield* Ref.get(counter) // 100
})关键在于不要把读和写拆成两个 Effect。分别用 Ref.get 再 Ref.set 写回,和裸变量一样会丢更新;一步完成的是 Ref.update,需要同时拿到一个返回值时用 Ref.modify,它接受 (当前值) => [返回值, 新值]。
两条使用纪律:
- 传给
update的函数应当是纯的、同步的。它不是 Effect,不要在里面做请求或写库。如果更新过程本身需要执行 Effect,那是SynchronizedRef的场景(本章不展开)。 - Ref 里放不可变的值。更新时返回新对象,不要原地修改再返回同一个引用。
另外,如果你要多个状态一起原子提交,Ref 不够,需要事务;v4 里是 Effect.tx 与 TxRef,见延伸阅读。
Queue:有界的任务通道
Queue 是生产者与消费者之间的通道。每个元素只会被一个消费者取走,所以它天然适合「任务分发」,比如一个入口接收请求,几个 worker fiber 并行处理。
Queue.bounded(n) 是最该优先选择的形态:队列满时,Queue.offer 会挂起生产者,直到消费者取走一些。这就是上一章的背压,只不过换到了 fiber 之间。生产者被拖慢,系统整体负载就有了上限。
import { Effect, Fiber, Queue } from "effect"
declare const handle: (job: string) => Effect.Effect<void>
export const program = Effect.gen(function*() {
const jobs = yield* Queue.bounded<string>(100)
const worker = Effect.forever(
Queue.take(jobs).pipe(Effect.flatMap(handle))
)
const workers = yield* Effect.forEach(
[1, 2, 3],
() => Effect.forkChild(worker)
)
yield* Queue.offer(jobs, "job-1")
yield* Queue.offer(jobs, "job-2")
yield* Fiber.interruptAll(workers)
})其他几种形态是明确的取舍:
Queue.unbounded:永不阻塞,也永远没有上限;生产者快于消费者时内存会持续增长,只在你能证明量级有限时使用。Queue.dropping(n):满了丢新元素。Queue.sliding(n):满了丢最旧的元素。
收尾时可以用 Queue.end 表示「正常结束」,再配合 Stream.fromQueue,把队列接成上一章的流;Queue.shutdown 则是直接关掉。
PubSub:广播给所有订阅者
PubSub 是一对多。每个订阅者都会收到发布后的每一条消息。典型用法是一个业务事件同时驱动审计、通知、缓存失效等多个独立的反应。
它有一个必须理解的时序性质:PubSub 只把消息发给已经订阅的人,不是回放日志。在订阅之前发布的消息,之后才订阅的人永远收不到。所以要先建立订阅,再开放发布;订阅需要 Scope(第 15 章),它随着 Scope 关闭而自动退订。
import { Effect, PubSub } from "effect"
export const program = Effect.scoped(
Effect.gen(function*() {
const events = yield* PubSub.bounded<string>(64)
const audit = yield* PubSub.subscribe(events)
const notify = yield* PubSub.subscribe(events)
yield* PubSub.publish(events, "user-created")
const a = yield* PubSub.take(audit)
const b = yield* PubSub.take(notify)
return [a, b] // 两个订阅者都拿到了
})
)和 Queue 一样,bounded 的 PubSub 在缓冲满了之后会让发布方等待最慢的订阅者,所以一个卡住的订阅者能拖住发布方,进而拖住所有人。dropping 与 sliding 都不会阻塞发布方,但都要接受丢消息,而且丢法不同:sliding 丢最旧的,快的订阅者仍然收到全部,慢的订阅者只剩最新的几条;dropping 丢新来的,而「满」是由最慢的订阅者决定的,所以一个慢订阅者会让所有订阅者一起丢掉新消息(以上行为在 4.0.5 里实测过)。需要「慢者不拖累快者」时,sliding 才是对的选择。事件绝对不能丢的场景,不应该只靠内存里的 PubSub,要有持久化的来源(如数据库里的待发事件表)。
Semaphore:限制并发,不是限制速率
Semaphore 持有 n 个许可。用 semaphore.withPermits(1) 包裹一个 Effect,它执行期间占用一个许可;许可用完时,后来者排队等待。
import { Effect, Semaphore } from "effect"
declare const callApi: (id: number) => Effect.Effect<string>
export const program = Effect.gen(function*() {
const limit = yield* Semaphore.make(5)
const limited = (id: number) => callApi(id).pipe(limit.withPermits(1))
return yield* Effect.forEach(
Array.from({ length: 100 }, (_, i) => i),
limited,
{ concurrency: "unbounded" }
)
})这一百个调用同时被发起,但任一时刻最多 5 个在执行。注意区分:Semaphore 限制的是同时进行的数量(并发度),不是每秒多少次(速率)。限速需要令牌桶一类的工具,见延伸阅读里的 RateLimiter。
许可数为 1 的 Semaphore 就是互斥锁,适合保护「同一时刻只能有一个在做」的临界区,例如串行化对某个外部资源的写入。而第 14 章里 concurrency 选项限制的是一次 forEach 的并发度;Semaphore 的价值在于跨多个调用点共享同一个上限。
常见误区
- 把
Ref.get+Ref.set当成原子更新。要用update或modify。 - 在
Ref.update的函数里做副作用。它应当是纯函数。 - 用无界 Queue 图省事。没有背压的队列只是把问题推迟到内存耗尽的那一刻。
- 把 Queue 当广播用。多个消费者取同一个 Queue 是在分任务,不是都收到。要都收到,用 PubSub。
- 先发布后订阅。PubSub 不回放,订阅晚了就是漏了。
- 用 Semaphore 当限速器。它限制并发,不限制频率。
- 用 Ref 存一切。当「状态」其实是一个流水线(上游产出、下游消费),用 Stream 或 Queue 表达数据流动,比让大家去读写同一个 Ref 更清晰。
小结
选原语不是背 API,而是回答问题:存一个值用 Ref,派活用 Queue,广播用 PubSub,限并发用 Semaphore。它们共同的设计取向是:把「读改写」「排队」「等待」这些容易写错的并发细节,收拢进有明确语义的原语里,并且天然可中断。到这里,第四部分的并发与资源就讲完了。接下来进入工程化:怎样把这套 Effect 程序接进真实的框架与运行时,这是下一章「运行时边界」的主题。