Effect fork 家族:forkChild、forkDetach、forkScoped、forkIn 与 startImmediately
fork 是 Effect 里最容易「看起来没问题、偶尔丢数据」的 API。你 fork 了一个消费者,紧接着往队列里发消息,结果消息丢了。排查半天,发现问题不在队列,在于 fork 返回时子 fiber 还没开始执行。v4 给 fork 系列加了两个选项,专门处理这件事。
验证环境是 effect 4.0.5,涉及的 API 都是 stable。并发与资源的整体模型见 并发与资源,这里专讲 fork 的归属、时机与中断选项。
先对一下名字:v3 到 v4
- v3
Effect.fork现在是Effect.forkChild;Effect.forkDaemon现在是Effect.forkDetach。 forkScoped和forkIn名字没变,v4 里它们也都接受选项参数。- 语义没变:
forkChild的子 fiber 挂在父 fiber 上,父结束时被中断;forkDetach的子 fiber 挂在全局作用域,父结束后继续运行。
这几条我只在 v4 上跑过,v3 的行为以官方迁移名表为准,没有再装 v3 对比。下面的实测全部是 4.0.5。
四个 fork 把子 fiber 挂在哪
Effect.forkChild:挂在当前父 fiber。父 fiber 结束,子 fiber 被中断。Effect.forkDetach:挂在全局作用域,不随父结束,需要你自己负责中断或等它跑完。Effect.forkScoped:挂在当前Scope,Scope 关闭时中断。需要环境里有Scope。Effect.forkIn(effect, scope):挂在你显式传入的 Scope,Scope 关闭时中断。
用一个会打印生命周期的 worker,把四个都跑一遍:
import { Effect, Exit, Scope } from "effect"
const log: Array<string> = []
const worker = (name: string) =>
Effect.gen(function*() {
log.push(`${name} start`)
yield* Effect.sleep(30)
log.push(`${name} done`)
}).pipe(Effect.onInterrupt(() => Effect.sync(() => { log.push(`${name} interrupted`) })))
// forkChild:父结束,子被中断
await Effect.runPromise(Effect.gen(function*() {
yield* Effect.forkChild(worker("child"), { startImmediately: true })
yield* Effect.sleep(5)
}))
// forkDetach:父结束,子继续
await Effect.runPromise(Effect.gen(function*() {
yield* Effect.forkDetach(worker("detached"), { startImmediately: true })
yield* Effect.sleep(5)
}))
await Effect.runPromise(Effect.sleep(80))
log.push("---- forkScoped / forkIn")
// forkScoped:Scope 关闭,子被中断
await Effect.runPromise(Effect.scoped(Effect.gen(function*() {
yield* Effect.forkScoped(worker("scoped"), { startImmediately: true })
yield* Effect.sleep(5)
})))
// forkIn:父 fiber 结束后,只要 Scope 没关,子继续;关了才中断
const scope = Effect.runSync(Scope.make())
await Effect.runPromise(Effect.gen(function*() {
yield* Effect.forkIn(worker("forkIn"), scope, { startImmediately: true })
yield* Effect.sleep(5)
}))
log.push("parent gone, scope still open")
await Effect.runPromise(Effect.sleep(5))
await Effect.runPromise(Scope.close(scope, Exit.void))
await Effect.runPromise(Effect.sleep(80))
console.log(log)实测输出:
[
'child start',
'child interrupted',
'detached start',
'detached done',
'---- forkScoped / forkIn',
'scoped start',
'scoped interrupted',
'forkIn start',
'parent gone, scope still open',
'forkIn interrupted'
]detached 在父 fiber 早已结束之后还把活干完了;forkIn 的子 fiber 在父结束后继续存在,直到我手动关闭 Scope 才被中断。这四种里我的默认选择是 forkChild,因为「子随父死」是最不容易泄漏的语义;只有在任务的生命周期确实比发起者长时才用 forkDetach,更推荐用 forkIn 或 forkScoped 把它绑到一个有名字的 Scope 上,而不是丢到全局。
还有一个常被问到的点:子 fiber 失败,父 fiber 不会被自动拖下水。我实测让子 fiber Effect.fail,父 fiber 睡 10 毫秒后继续运行,直到 Fiber.join 才看到这个失败:
import { Effect, Fiber } from "effect"
const program = Effect.gen(function*() {
const fiber = yield* Effect.forkChild(Effect.fail("child failed"), { startImmediately: true })
yield* Effect.sleep(10)
return yield* Effect.exit(Fiber.join(fiber))
})
const exit = await Effect.runPromise(program)
console.log(exit._tag)输出 Failure。所以如果你 fork 之后既不 join 也不 await,子 fiber 的失败就是静默的。这是 fork 这类「fire and forget」写法的固有风险,不是 v4 引入的。
「fork 后第一个 tick 才执行」的竞态
默认情况下,forkChild 返回的是 fiber,子 fiber 要等到调度器的下一个 tick 才开始执行。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 订阅者,再发布消息」:
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 返回之前就先开始执行,订阅已经建立,随后的发布能被收到。
解法有三个层次,我的顺序是:
- 能用结构表达就别靠时序:订阅放在 fork 之前完成,把
Subscription传给消费者。这是最稳的写法,不依赖任何调度细节。 - 订阅逻辑必须在子 fiber 里时:
startImmediately: true。它保证的是「子 fiber 先跑到第一个让出点」,不是「子 fiber 已经完成准备」。如果准备动作跨越了多个异步步骤,仍然需要显式握手,比如Deferred。 - 不要用
Effect.sleep或yieldNow凑:凑出来的间隔在负载变化时又会变成新的竞态。
startImmediately 不是 forkChild 独有的,forkScoped、forkIn 和 forkDetach 都接受同一组选项,上面生命周期的例子里我就是靠它让每个 worker 在父 fiber 继续之前先打出 start。在服务里启动后台循环(心跳、消费者、缓存刷新)时,这个选项尤其有用:服务的构造函数可以在返回前确认循环已经开始运行,调用方拿到服务时不必再猜后台任务有没有就绪。
uninterruptible 选项
第二个选项 uninterruptible 的类型是 boolean | "inherit"。我用一个会 sleep 的子 fiber,让父 fiber 5 毫秒后调用 Fiber.interrupt,对比三种取值:
import { Effect, Fiber } from "effect"
const probe = async (label: string, options?: { readonly uninterruptible?: boolean | "inherit" }) => {
const log: Array<string> = []
const body = Effect.gen(function*() {
log.push("start")
yield* Effect.sleep(30)
log.push("finished")
}).pipe(Effect.onInterrupt(() => Effect.sync(() => { log.push("interrupted") })))
const t0 = performance.now()
await Effect.runPromise(Effect.gen(function*() {
const fiber = yield* Effect.forkChild(body, { startImmediately: true, ...options })
yield* Effect.sleep(5)
yield* Fiber.interrupt(fiber)
}))
console.log(label.padEnd(26), log, `interrupt returned after ${Math.round(performance.now() - t0)}ms`)
}
await probe("default")
await probe("uninterruptible: true", { uninterruptible: true })
await probe("uninterruptible: inherit", { uninterruptible: "inherit" })输出(耗时每次略有浮动):
default [ 'start', 'interrupted' ] interrupt returned after 6ms
uninterruptible: true [ 'start', 'finished' ] interrupt returned after 31ms
uninterruptible: inherit [ 'start', 'interrupted' ] interrupt returned after 6ms- 默认和
"inherit"在这个场景下一致,子 fiber 被中断。 true让子 fiber 整个不可中断:中断请求被推迟,Fiber.interrupt要等它自然结束才返回(31 毫秒,而不是 6 毫秒)。"inherit"是继承父 fiber 当前的可中断状态。我另外在Effect.uninterruptible(...)包裹的父 fiber 里 fork,结果子 fiber 同样没有被中断,输出的是finished。
所以 uninterruptible: true 适合「必须做完的收尾任务」,比如落盘、提交 offset。代价很直接:它会让中断调用方被阻塞。对「可能永远不结束」的任务,千万不要这么设,否则关闭流程会卡住。我只验证了通过 Fiber.interrupt 触发的中断;它对 Scope 关闭时的中断是否同样推迟,我没有实测,不写成结论。
怎么选
- 任务是请求或函数的一部分,随调用方结束:
forkChild。 - 任务绑定到某个资源或服务的生命周期:
forkScoped(当前 Scope)或forkIn(scope)(显式 Scope)。 - 任务确实要独立于任何父级:
forkDetach,并明确谁来中断它。 - fork 之后的下一行代码依赖子 fiber 已经开始:
startImmediately: true,或者重构成不依赖时序。 - 子任务必须做完收尾:
uninterruptible: true,同时保证它会结束。
小结
- v3 的
fork和forkDaemon在 v4 叫forkChild与forkDetach;四个 fork 的区别只在子 fiber 的归属:父 fiber、全局、当前 Scope、显式 Scope。 - 默认不会立刻执行子 fiber:父 fiber 里
forkChild之后的代码先运行,父立刻结束时子 fiber 可能一行都没跑。 startImmediately: true让子 fiber 先同步跑到第一个让出点,实测修复了「先 fork 订阅者再发布」丢消息的问题,但不是跨多个异步步骤的就绪保证。uninterruptible: true让Fiber.interrupt等待子 fiber 结束;"inherit"继承父 fiber 的可中断状态。- 子 fiber 的失败不会自动传给父 fiber,不
join就是静默的。