Effect.fnUntracedEager 什么时候该用?

在空函数体、runSync 循环三十万次的微基准里,Effect.fnUntracedEager 每次调用约 0.06 微秒,Effect.fnUntraced 约 0.4 微秒。但快的代价是:哪怕那个 effect 之后永远不运行,函数体的前缀副作用也已经发生了。

只在函数体前缀是纯计算、没有副作用的热路径上用 Effect.fnUntracedEager;前缀里有日志、写入、读取可变状态这类副作用时,一律用 Effect.fnUntraced。

原因在执行时机:fnUntraced 的函数体要等运行时才开始,符合「Effect 是描述」;fnUntracedEager 在调用时就立刻同步执行函数体,直到遇到第一个异步 effect 才交还给运行时。三个 Effect.fn 系函数的整体对比见 Effect.fn 和 fnUntraced 怎么选?,Effect 为什么是描述而不是过程,见 Effect 和 Promise 有什么区别?。

fnUntracedEager 和 fnUntraced 的执行时机差在哪?

下面用一个日志数组记录函数体什么时候开始:

import { Effect } from "effect"

const log: Array<string> = []
const lazy = Effect.fnUntraced(function*(n: number) {
  log.push(`lazy body ${n}`)
  yield* Effect.sync(() => log.push("lazy step"))
  return n
})
const eager = Effect.fnUntracedEager(function*(n: number) {
  log.push(`eager body ${n}`)
  yield* Effect.sync(() => log.push("eager step"))
  return n
})

const l = lazy(1)
log.push("after lazy call")
const e = eager(1)
log.push("after eager call")
Effect.runSync(l)
Effect.runSync(e)
console.log(log)

输出:

[
  'after lazy call',
  'eager body 1',
  'after eager call',
  'lazy body 1',
  'lazy step',
  'eager step'
]

lazy 的函数体在 runSync 之后才开始;eager 的函数体在调用那一刻(eager(1))就开始执行了,后面的 yield* 步骤才等到运行时。

函数体里有异步 effect 会怎样?

官方 JSDoc 对它的描述是:全部 yield 的 effect 都是同步的,才会完全 eager。一旦函数体里出现异步 effect(比如 Effect.sleep),eager 只会执行到那一步之前,剩下的交给运行时。

我实测过这种情况:异步之前的同步部分先于调用之后的下一行代码,异步之后的部分在运行之后才发生。

什么时候用,什么时候不用?

规则很窄:

  • 用在函数体前缀纯计算、无副作用的热路径上,比如参数归一化、缓存命中的快路径。
  • 前缀里有日志、写入、读取可变状态这类副作用时,一律用 fnUntraced,因为前缀会在调用点提前发生,和「描述」的预期不符。
  • 不确定时用 fnUntraced。性能数字在空函数体上的量级差距,见选择那篇里的微基准,别拿它当对 I/O 型函数的收益预期。

它比 fnUntraced 快多少?

同一个微基准里,fnUntracedEager 约 0.06 微秒,fnUntraced 约 0.4,带 span 的 Effect.fn 约 3.5,返回 Effect.gen 的普通函数约 0.8。这是空函数体、同步、带 runSync 开销的结果,只说明量级关系,绝对数字请在你的环境重测。

版本是 effect 4.0.5,本文的日志输出在这个版本上实际跑过;性能数字是我这台机器上的微基准,只能看量级。「全部 yield 的 effect 都是同步才完全 eager」引自官方 JSDoc 的描述,我没有单独核对原文;异步 effect 出现后 eager 部分的行为我跑过,但这里没有附代码。