Effect fork 之后为什么子 fiber 没有马上执行?

你 fork 了一个消费者,紧接着往 PubSub 里发消息,结果消息丢了。排查半天,问题不在 PubSub,在于 forkChild 返回的时候,子 fiber 还没开始执行。

因为默认情况下子 fiber 要等调度器的下一个 tick 才开始跑,forkChild 只是把 fiber 返回给你。想让它在 fork 返回前先跑起来,给 fork 加 startImmediately: true;但更稳的做法是不依赖时序。

fiber 是 Effect 的轻量并发单元,子 fiber 归父 fiber 管,背景见 Effect 的 fiber 是什么,为什么子任务归父任务管?。PubSub 的特点是不给后来的订阅者保留已发布消息,它与 Queue 的区别见 Effect 的 Queue 和 PubSub 有什么区别?。下面先看顺序,再看它怎么变成丢消息。

默认的执行顺序是什么?

API 的 JSDoc 写得明白:「returning the fiber immediately, without waiting for it to begin executing」。用最小的例子看顺序:

import { Effect } from "effect"

const run = async (name: string, startImmediately: boolean) => {
  const log: Array<string> = []
  await Effect.runPromise(
    Effect.gen(function*() {
      const child = Effect.sync(() => log.push("child"))
      yield* Effect.forkChild(child, { startImmediately })
      log.push("parent")
      yield* Effect.yieldNow
    }).pipe(Effect.scoped)
  )
  console.log(name.padEnd(20), log)
}

await run("default", false)
await run("startImmediately", true)

// 父 fiber fork 后立刻结束,不让出
const ran: Array<string> = []
await Effect.runPromise(
  Effect.forkChild(Effect.sync(() => ran.push("child"))).pipe(Effect.asVoid)
)
console.log("parent returns right after fork:", ran)

输出:

default              [ 'parent', 'child' ]
startImmediately     [ 'child', 'parent' ]
parent returns right after fork: []

默认下 parent 先于 child。更严重的是最后一行:父 fiber fork 完立刻返回,子 fiber 被中断时一行代码都没来得及执行。

先 fork 订阅者再发布,为什么会丢消息?

「先 fork 订阅者,再发布消息」这个顺序假设一旦隐含在业务里就是竞态:

import { Effect, Fiber, PubSub } from "effect"

const scenario = (startImmediately: boolean) =>
  Effect.gen(function*() {
    const hub = yield* PubSub.unbounded<number>()
    const received: Array<number> = []
    const consumer = Effect.scoped(Effect.gen(function*() {
      const sub = yield* PubSub.subscribe(hub)
      const n = yield* PubSub.take(sub)
      received.push(n)
    }))
    const fiber = yield* Effect.forkChild(consumer, { startImmediately })
    yield* PubSub.publish(hub, 1)
    yield* Effect.sleep(20)
    yield* Fiber.interrupt(fiber)
    return received
  })

console.log("default          ", await Effect.runPromise(scenario(false)))
console.log("startImmediately ", await Effect.runPromise(scenario(true)))

输出:

default           []
startImmediately  [ 1 ]

默认情况下消费者还没来得及 subscribe,消息已经发布完了,PubSub 不会为之后才出现的订阅者保留已发布的消息,所以收到空数组。加上 startImmediately: true,子 fiber 在 forkChild 返回之前就先开始执行,订阅已经建立,随后的发布能被收到。

startImmediately 能保证什么,不能保证什么?

它保证的是「子 fiber 先跑到第一个让出点」,不是「子 fiber 已经完成准备」。如果准备动作跨越了多个异步步骤,仍然需要显式握手,比如 Deferred。

它不是 forkChild 独有的,forkScoped、forkIn 和 forkDetach 都接受同一组选项。在服务里启动后台循环(心跳、消费者、缓存刷新)时,这个选项尤其有用:服务的构造函数可以在返回前确认循环已经开始运行,调用方拿到服务时不必再猜后台任务有没有就绪。

这个竞态到底该怎么解?

我的顺序是:

  1. 能用结构表达就别靠时序:订阅放在 fork 之前完成,把 Subscription 传给消费者。这是最稳的写法,不依赖任何调度细节。
  2. 订阅逻辑必须在子 fiber 里时:startImmediately: true。
  3. 不要用 Effect.sleep 或 yieldNow 凑:凑出来的间隔在负载变化时又会变成新的竞态。

四个 fork 各自把子 fiber 挂在哪,见 Effect 的四个 fork 该怎么选?。

验证环境是 effect 4.0.5,涉及的 API 都是 stable。上面三段输出都是我在这个版本上跑出来的。方案 1(把订阅放在 fork 之前再传给消费者)和 Deferred 握手是我的建议,我没有为它们另写代码跑一遍。