系统设计 / 任务运行时 · 从一次 run() 到可观测的调度层 / 作用域与 effects:一组任务的生命期 待审核 7 / 10
作用域与 effects

作用域与 effects:一组任务的生命期

组件卸载的时候,它派出去的任务未必都已结束。逐个持有 Task 句柄再逐个 cancel() 是可行的:句柄存在某处,新派的记得加进去,卸载时遍历一遍。这套记账每加一处调用点就要改一次,漏一个就漏一个。作用域把这份记账交给运行时。一组任务共享一个生命期,关掉生命期就关掉全部,新派的自动进组。

任务周围还有另一类东西需要成对管理:保存时要显示 spinner、要挡住关页提示、要先把乐观值写进 store,任务抵达终态时这些动作各有一个反动作。写进 taskFn 体里意味着每个 taskFn 都得自己 try / finally 一遍,而且反动作只在 taskFn 正常退出时才跑得到。effect 协议把这一对绑成一个对象挂到任务上,由运行时负责在终态时执行反动作。本页看这两件事怎样用同一套父子关系实现。

1 · 作用域与 sentinel 任务

createScope(runtime, options) 返回一个 TaskScope。它做的第一件事是派一个 sentinel 任务:一个没有 taskFn 体、创建即 running 的空任务。scope.run() 派出的每个任务都以它为 parent,孙任务再挂在子任务下。作用域自己不需要任何取消逻辑,任务与状态机 §4 的父子级联原样够用。scope.cancel() 做的事只是取消 sentinel,剩下的交给任务图。

sentinel 是任务图上的真结点:inspect().tree 里查得到,devtools 面板把它当作一棵有名字的子树的根。它只有一处特殊,concurrencyExempt:调度器不把它计入并发预算。并发上限为 1 的运行时里建一个作用域之后仍然能跑一个真任务,此时 inspect().running 有两条(lab/facts.test.ts §1)。这条豁免是必须的,否则 concurrency: 1 的运行时建完作用域就再也调度不动任何任务。

createScope 的第一个参数可以是 runtime,也可以是另一个 TaskScope。后者把新作用域的 sentinel 挂到父作用域的 sentinel 下,嵌套关系直接落在同一棵树上,取消照样只是一次级联。

嵌套是内建的,从签名上就能看出来:createScope 有两个重载,(rt: Runtime, options?)(parent: TaskScope, options?)。传 TaskScope 的那个会经一个 symbol slot 取出父 scope 的 runtime 与 sentinel,把后者作为 parent 传下去——所以父 scope 一 cancel,整棵子树跟着走内核的 parent 级联,不需要自己转发 outer.signal

图 1-1 · 外层作用域派出 A 与 B,嵌套的内层作用域派出 C 与 D,两个 sentinel 是树上的内部结点。可在四种干预间切换,对照取消停在哪一层。

四种干预的结果(lab/facts.test.ts §1):

  • 不干预:四个任务全部 success;两个 sentinel 在收尾时以 cancelled 结束。sentinel 永远不会 success,它没有 taskFn 可以 resolve。
  • 取消内层:C 与 D 以 parent 取消,A 与 B 照常完成,outer.cancelled 仍是 false。内层的关闭不向上传播。
  • 取消外层:四个任务连同内层的 sentinel 全部 parent;被直接取消的那个 sentinel 自己的 reason 是 abort,与后代不同。
  • 外部 signal abort:与取消外层完全同一条路径,后代拿到的 reason 同样是 parent

2 · 作用域的关闭路径

关闭一个作用域有四种写法,它们通向同一个内部的 lifetime.dispose()scope.cancel()、传进去的外部 AbortSignal abort、await using 块退出时的 Symbol.asyncDispose、以及 disposeRuntime(runtime) 沿任务图级联到 sentinel。共同点有两条。第一,scope.cancelled 在 dispose 内同步翻转,紧接着读它一定是 true。第二,后代拿到的 cancelReason 一律是 parent,分不出作用域是被谁关的。这一点与父任务被取消和父任务出错在子任务眼里同样不可区分,出自同一个设计。

await usingcancel() 的差别在等待。Symbol.asyncDispose 先做同样的取消,再 await 整棵子树 drain:不只是子孙任务抵达终态,还包括每个在飞的 effect setup-promise 与 cleanup 都落定。所以 await using scope = createScope(runtime) 这个块退出之后,块里派出的任何后台工作都不会再有残留。

作用域关闭之后调用 scope.run() 不会报错,它返回一个已经是终态的句柄:状态 cancelled、reason parent、promise 以 CancelledError reject,而且这个任务根本没有进任务图。事件总线上这段窗口只有一条 task:rejected,payload 带着 reason: 'scope-closed'scopeId,没有 task:registeredlab/facts.test.ts §2)。「没进任务图」与「总线上没声音」是两件事:生命周期那对事件确实不发,但 task:rejected 这条诊断照发不误——它正是为这类「拒绝在登记之前」的路径准备的(TaskRejectedPayload 的四种 reason 全是这类)。

ScopeOptions.errorPolicy 决定一个子任务出错时兄弟怎么办。默认 supervisor 让失败互相隔离,对应 Kotlin 的 supervisorScopefailFast 让首个 error 关掉整个作用域,对应 Kotlin 默认的 coroutineScope。同一组三个任务,A 抛错、B 与 C 各跑一段:supervisor 下是 error / success / success 且作用域仍存活,failFast 下是 error / cancelled / cancelled 且 scope.cancelled 为 true(lab/facts.test.ts §2)。

警示 · 触发 failFast 的只有 error 一种终态。cancelled 不触发,无论它来自 task.cancel()、父级级联还是 disposeRuntime(runtime)。取消是结构性的、有意为之的,把它当作故障会让 disposeRuntime(runtime) 的收尾误伤一整片作用域。

3 · TaskEffect 协议

RunOptions.effects 收一个数组,每个元素是一个 TaskEffect。这个类型只有一个实质成员 setup(ctx),其余两个字段 nametraceContext 都只影响诊断。

setup 一律同步执行,在主任务真正开始之前跑完,所以订阅注册、引用计数、ctx.run() 派子任务这些动作与主任务的调度是原子的。返回值决定 cleanup 何时登记:返回 void 或一个函数,cleanup 同步登记;返回 Promise,cleanup 要等它 resolve 之后才登记,若那时主任务已经落定,late-register 路径会立刻执行它。两种登记方式的差别在一条实测的顺序里看得最清楚(lab/facts.test.ts §3):

sync-setup → async-setup-body → taskFn-end → sync-cleanup → async-setup-resolved → async-cleanup

同步 effect 的 cleanup 与主任务终态同拍;异步 effect 的 setup 体虽然同样在第一拍跑,它的 cleanup 却排到主任务结束之后。需要在临界区之前拿到资源的 effect 只能用异步形态,也只能靠一个 barrier promise 与 taskFn 协作。lockEffect 就是这样做的,它是内置 effect 里唯一走异步 setup 的一个。

ctx 给出四样东西:main 是主任务句柄,run 派子任务且 parent 默认就是 mainscope 是本 effect 自己的生命期句柄,signal 在这个句柄 dispose 时 abort。ctx.scope.spawn(child) 挂一个嵌套 effect,父 effect dispose 时先级联到子 effect 再跑自己的 cleanup,两层都是 LIFO。cleanup 的触发点因此有两个:主任务抵达终态,或这个 effect 的 scope 被显式 dispose。

作用域本身也能挂 effects。ScopeOptions.effects 里的 effect 挂在 sentinel 上,作用域创建时 mount、关闭时 cleanup,适合整组任务共享一份资源的场合:一个根 span、一把作用域级的锁、一次进出遥测。实测里这类 effect 的 setup 看到 sentinel 是 runningcleanup 看到的是 cancelledlab/facts.test.ts §3)。想要「全部子任务成功则根 span 记 ok」这种语义的调用方得在 cleanup 里自己看子任务的结果,不能直接读 sentinel 的终态。

注 · setup 的同步抛错、它返回的 promise 的 reject、以及 cleanup 的抛错,都不会打断主任务,而是经 hooks.onIsolatedError 上报。这个出口收的是单个 report 对象 { scope, error, meta },不是三个位置参数——scope'hook' | 'listener' | 'callback' | 'effect' | 'cleanup' | 'internal' 之一,meta 的形状随它变(engine/hooks.tsIsolatedErrorReport 是个按 scope 辨识的联合)。把它当三元组写,handler 一进来就会在 error.message 上抛 TypeError;而这个 TypeError 又会被诊断层的兜底包装接住打到 console.error,于是错得悄无声息。lab/facts.test.ts §5 里那条断言用的是对象形态。

4 · loadingEffect 的显示窗口

一个请求跑 80ms,spinner 闪一下又消失,比不显示还难看;一个请求跑 3s 却没有任何反馈,用户会以为点击丢了。loadingEffect 用两个时间常数分开处理这两端:delay(默认 100ms)是显示之前的等待,短任务在这个窗口里跑完就永远不显示;duration(默认 300ms)是一旦显示的最短停留,避免刚出现就消失。

指示器不是一个定时器。effect 在 setup 里用 ctx.run 派了一个真任务,after: main.started 让它等主任务开跑,再 sleep(delay),然后把引用计数加一并同时等 sleep(duration)main.promise。这个子任务在 Gantt 上是主任务下面的一条,depStuckTimeoutMs: false 让它豁免依赖超时的熔断——主任务在队列里排多久,它就等多久。

引用计数使得同一个实例可以挂到多个并发任务上:先到的那个让指示器出现,最后一个完成的才让它消失。

图 4-1 · 一批任务共用一个 loadingEffect 实例,时间轴上每个任务下面那条是 effect 用 ctx.run 派出的指示器子任务。可调 delay 与 duration 观察指示器出现与消失的时刻。

两条锁进测试的事实(lab/facts.test.ts §4):任务耗时 60ms 而 delay 为 200ms 时,visible 全程采样下来没有一次是 true;三个错开派出、耗时递增的任务共用一个实例时,visible 的变化序列恰好是 false → true → false,指示器只出现一次,并在最后一个任务完成后才隐藏。

5 · 乐观更新与回滚

乐观更新是「先按成功渲染,失败再撤回」。撤回这一步是它全部的难点:撤回逻辑与写入逻辑分居两处,中间隔着一个 await,取消路径上还容易整个漏掉。optimisticEffect 把三个回调收在一个对象里:apply 在 mount 时同步写入乐观值,commit(data) 在主任务 success 时用服务端返回的真值替换它,rollback(cause) 在 error 与 cancelled 时还原。后两个是同一个 cleanup 的两条分支,由主任务的终态选。

cause 是一个可辨识联合,{ reason: 'error', error }{ reason: 'cancelled' },调用方据此决定要不要弹提示——用户自己撤销的保存通常不该再弹一条失败提示。

图 5-1 · 一次乐观保存的三种结局:成功走 commit,服务端拒绝与中途取消都走 rollback。可切换结局观察 store 卡片的底色与 effect 回调的次序。

三种结局各走一条分支,回调序列分别是 apply → commit、apply → rollback(error)、apply → rollback(cancelled)(lab/facts.test.ts §5)。commit 拿到的 email 与 apply 写进去的不同,这是刻意的:服务端归一化过的值才是最终值,乐观值只是一次预测。

apply 抛错时 cleanup 根本不会登记。实测确认了这条:commit 与 rollback 都没有被调用,主任务本身照常 success,只有一条 isolated error 被报出来(lab/facts.test.ts §5)。这个设计防的是「没改成还撤一遍」——apply 失败意味着 store 从未进入乐观态,此时执行 rollback 会把一个无关的旧快照写回去。

6 · 未保存守卫的引用计数

unsavedGuardEffect 的形状与 loadingEffect 同源,去掉了两个时间常数:引用计数从 0 变 1 时调用 block(),它返回一个 unblock 函数;计数归零时 unblock 被调用。典型的 block 实现是注册一个 beforeunload 监听器,或者向路由器登记一个拦截。effect.active 暴露给 UI,用来渲染「还有未保存的修改」这类指示。

这个 effect 的价值在于它把「什么时候该拦」从业务代码里彻底移走。散在各处的 try / finally 无法回答「现在还有没有别的保存在飞」,而引用计数天然回答了这个问题,取消也算一次 settle。

图 6-1 · 同一个守卫实例挂在每个保存任务上,引用计数决定 block 与 unblock 的时机。可派多个并发保存并中途取消一个,观察 block 只发生一次。

三个并发保存里取消掉一个,blockunblock 各只发生一次,最后 active 回到 false(lab/facts.test.ts §6)。

7 · 内置 effects 的其余成员

src/effects/ 下另有六个 effect,形状都落在前面几节的两类里:只登记 cleanup 的,与用 ctx.run 派子任务的。

  • toastEffect 与 loading 一样按引用计数聚合,多了一个 dedupWindowMs:全部任务 settle 之后再等这么久才触发回调,窗口期内有新任务挂入就取消等待并入下一批。批内 error 是粘性的,任意一次失败最终都按失败提示。
  • cacheInvalidateEffect 只在 success 时触发 invalidate(data)。失败、取消、作用域提前关闭一律跳过:保留旧缓存比用一次空响应刷掉它更合理。
  • concurrencyBadgeEffect 是裸的引用计数,暴露 count 而不是布尔的 visible,给「3 个保存中」这类读数用。
  • heartbeatEffectctx.run 派一个 while 循环子任务,按 intervalMs 周期调 beat(signal),主任务抵达终态时循环退出。单次 beat 抛错不会停掉心跳。
  • lockEffect 是唯一走异步 setup 的:mount 时 await acquire(signal) 拿到 release,release 作为 cleanup 在终态时同步释放。taskFn 内必须 await lock.wait(ctx.signal) 才进临界区,直接 await lock.acquired 会让取消中的任务卡住。
  • tracingEffect 开一个 span,按终态关闭并附上耗时,是唯一用到 TaskEffect.traceContext 字段的内置 effect。

建议 · 这些 effect 分两类,混用会出错。loadingEffect / toastEffect / concurrencyBadgeEffect / cacheInvalidateEffect 可复用,同一个实例挂到多个任务上正是它们做聚合的方式;optimisticEffect / lockEffect / tracingEffect 持有一次性内部状态,每次 run()(包括每次 retry)都要新建实例,二次挂载会直接抛错。判据是这个 effect 有没有跨任务共享的计数或句柄。

九个内建 effect 的选项与语义写在各自源文件的 JSDoc 里,本节的说明是照着 src/effects/*.ts 逐个读出来的。

作用域与 effects 都是把生命期交给运行时管。下一页看运行时把这些交接过程如何吐出来:事件流与 devtools