任务运行时 · 从一次 run() 到可观测的调度层
一个 fetch 加一个 await 就是一个异步任务,前端应用里同时在飞的往往有几十个:搜索框每敲一下、列表每滚一屏、表单每改一格都在派任务。难点不在发起,在于发起之后的一切——谁先跑、最多几个同时跑、旧的要不要取消、同一个请求连发五次算几次、失败了重试几回、组件卸载了还在飞的怎么办。这些问题各自都有现成答案,凑在一起却互相牵制:取消要能穿过重试与去重,优先级要能穿过依赖与延迟。
本系列的对象是 @vega/job,一台把这些问题放进同一份状态机里处理的任务运行时。每一页对着它的一个切面:任务的九个状态与协作式取消、六档优先级的队列、after 依赖与 flow / parallel / pipeline 编排、debounce 与 throttle 的时间窗口、同 key 的三种协调策略、重试与超时、作用域与 effects、事件流与 devtools,最后是建在引擎之上的响应式依赖图。
所有 lab 都在浏览器里驱动真实的运行时,不做假的动画:图上每一根条都对应一条 task:started 到 task:done 的事件,时间是真实流逝的毫秒。这样做的代价是同一次演示两次的毫秒数不完全相同,正文里凡是引用数字,引的都是与时序无关的量——完成次序、放行数、取消原因、重试次数——并锁进了 core/ 的测试。
先把最小单元看清:一个 TaskFn 从 run() 到终态的九个状态,取消为什么只能是协作式的,父任务倒下时子任务怎样被级联。再看队列:并发上限、六档优先级、暂停与清空,以及「同一 tick 内提交」与「跨 await 提交」的差别。
任务与状态机:从 run() 到终态
一次 run() 产生一个 Task 句柄,taskFn 沿九个状态走到三种终态之一。取消是协作式的,cancel() 只翻转 ctx.signal;父任务被取消或出错时,后代一律以 parent 原因级联取消。
队列与并发:优先级与队列开关
默认并发 3,六档 TaskPriority 决定出队次序,同档 FIFO。暂停只挡新任务启动;队列级操作收走的任务以 queue-clear 收尾,与单个 cancel 的 abort 可分辨。
真实运行时与可复现数字
@vega/job 运行时,不用固定帧的动画代替。好处是图上看到的每一次排队、抢占与取消都是引擎真的做了的事;代价是毫秒数不可复现——同一个场景两次演示的 Gantt 会差几毫秒。
因此正文引用的数字只取与真实时间无关的量:完成次序、放行个数、取消原因、重试次数、事件条数。这些量在 core/scenarios.test.ts 里用同一份场景跑出来锁死,引擎或场景一改,测试先红。
凡涉及耗时的结论(pipeline 比 barrier 早多少交付首个结果),正文给的是按阶段时长算出的解析值,测试断言的是不等式而不是具体毫秒。after 把任务连成图,失败沿边传播;flow / parallel / pipeline 把图写成程序,无屏障的 pipeline 比逐阶段的 barrier 早交付首个结果。时间窗口 shaper 把高频调用压成少数几次执行,同 key 协调器决定重复调用是共享、重启还是排队。
依赖与编排:从 after 到 pipeline
after 把任务连成依赖图,任一依赖失败即沿边取消下游;flow / parallel / any 把图写成程序;pipeline 让每个 item 独立流过全部阶段,比逐阶段 barrier 更早交付首个结果。
时间窗口:debounce 与 throttle
高频调用进同一个时间窗口后被压成少数几次执行。debounce 的窗口随新调用延长,throttle 的窗口固定长度,两者共用一台状态机;被合并掉的调用以 CancelledError 收场,一次都没进过运行时的队列。
同 key 协调:重复调用的去向
同一个 key 上连发的调用由 coordinator 裁决:合并成一次执行、后来者取消先到者、排成 FIFO,或原地丢弃。协调是插件化的协议,写一个新策略只有几行。
相关链接
- Query Cancellation — TanStack Query tanstack.com AbortSignal 贯穿请求层的取消模型;本系列的 restart 协调器与它的 switch 语义同源。
-
switchMap — RxJS
rxjs.dev
「新值到来即取消旧内层」的经典表述,对应本系列
restartCoordinator的行为。 -
scheduler — React
github.com
React 的协作式调度器与优先级档位,本系列的
TaskPriority六档命名即取自它。 -
Notes on structured concurrency — Nathaniel J. Smith
vorpus.org
structured concurrency 的立论文章;作用域一页的
await using与级联取消是它在 JavaScript 里的落地。 -
AbortSignal — MDN
developer.mozilla.org
协作式取消的平台原语,
TaskFn收到的ctx.signal就是它。
失败之后是重试、超时与降级三件事的组合。作用域把一组任务的生命期绑到组件上,effects 把「伴随任务成对发生的副作用」抽成协议。最后是观测:事件流的两个类别、inspect() 快照、devtools 面板,以及建在引擎之上的响应式依赖图。
失败之后:重试与降级
错误先分成可重试与结构性两类,三个 middleware 的行为都由此决定:retry() 只吃可重试的那类,withTimeout 到时 abort 并等 taskFn 退出,withFallback 默认不接超时。
作用域与 effects:一组任务的生命期
作用域把一组任务挂在同一个 sentinel 任务下,关掉作用域即级联取消全部后代。effect 协议把伴随任务成对发生的副作用从 taskFn 体里抽出来,setup 的返回值决定 cleanup 何时登记。
观测与调试:事件流与快照
运行时投出的事件是 type / at / payload 三段式,inspect() 只回答此刻。收尾时 whenIdle() 只观察,dispose() 取消一切并发一条 runtime:disposed。
响应式依赖图:push 标记与 pull 求值
createReactiveGraph 在引擎之上叠一层记忆与失效:set 沿反向边标记,get 懒重算并按值相等剪枝。异步之下另有 stale guard、subtree cancel 与协调窗口三道机制。
Vue 集成:composables 与卸载
@vega/job/vue 把 Task 的状态投影成 ref,把 scope 与 flow 的生命期钉在组件实例上。组件卸载走的是 scope.cancel(),在飞的任务以 parent 原因终止。