并发与运行时底层
L18 ★ 指令解释器:Effect 蓝图与 runLoop
本课目标
- 能复述一个 Effect 从构造到 Exit 的执行链
- 理解 continuation 栈与 trampoline 的作用
前置课时尚未完成:L17
可以自行跳转学习,但按顺序推进会更省力;学情分析会把这类缺口归入「前置缺口」。
Effect 是一棵指令树
每个 Effect 值都是一个不可变的指令节点:已有成功值、同步 thunk、异步注册、成功续体、失败续体、finalizer 续体、让出指令等。pipe / Effect.gen 只是在不断往这棵树上加节点,不执行任何一步。runtime 的工作就是解释这棵树。
runLoop 的心智模型(伪代码,示意内部结构)
← 代码可左右滑动 →
// 简化示意,非真实源码
function runLoop(fiber, effect0) {
let cur = effect0
while (true) {
// 1) 先处理外部消息(中断请求等)
if (fiber.queue.length > 0) cur = drainQueue(fiber, cur)
// 2) 检查是否该协作让出
fiber.currentOpCount++
if (fiber.scheduler.shouldYield(fiber)) {
cur = flatMap(yieldNow(), () => cur) // 插入让出指令
enqueueToScheduler(fiber, cur)
return
}
// 3) 解释当前指令;结果推入 / 弹出 continuation 栈
cur = interpret(fiber, cur, fiber.contStack)
}
}💡 真实实现是一个 trampoline 循环:用显式的 continuation 栈代替 JS 调用栈,因此十万层 flatMap 也不会栈溢出。
一次执行的完整链路
- 构造:Effect.succeed / gen / pipe 生成不可变指令树。
- 启动:run* 创建 root fiber,带上初始 Context、fiber 局部状态与初始 effect。
- 解释:runLoop 逐条执行指令,遇 async 就挂起并注册回调,回调来了重新入队。
- 让出:超过操作预算后插入 yieldNow,把续体交给 Scheduler 排队。
- 收尾:成功产出 A、失败收集 Cause、中断运行 finalizer,最后得到 Exit<A,E>。
🔬 固定指令集 = 只有一个解释器
Effect 没有用户可定义的 handler,指令集是运行时内置且固定的。所有上层能力(retry、timeout、并发、资源、追踪)都是把效果翻译成这些指令的组合子。这也解释了为什么「程序即值」能带来如此强的可组合性:解释器不变,组合方式无限。
交互演练
关键术语
primitive instruction
运行时内置的最小指令(sync / async / 续体 / finalizer / yield)
trampoline
用循环与显式栈代替递归调用的解释执行方式
常见误区
× 以为 async 回调里会「继续执行同一个调用栈」——其实是 fiber 重新入队后恢复。
随堂测验(即时反馈,答题计入掌握度)
Q1runLoop 为什么用显式 continuation 栈?
Q2Effect.gen 构建程序时发生了几步计算?