系统设计 / 任务运行时 · 从一次 run() 到可观测的调度层 / 任务与状态机:从 run() 到终态 待审核 1 / 10
Task 生命周期

任务与状态机:从 run() 到终态

一个异步函数加一个 await 就是一个任务。把它交给运行时而不是直接调用,换来的是三件直接调用给不了的东西:它在哪个状态、能不能中途停下、停下时它派出去的那些子任务怎么办。本页对着 @vega/job 的最小单元把这三件事看清:run() 收进一个 taskFn,吐出一个 Task 句柄;句柄沿一台九态状态机走到终态;取消沿父子关系向下传播。

1 · TaskFn 与 Task:一次调用的两端

运行时接受的工作单元叫 TaskFn,签名是 (ctx) => Promise<T>。它与普通异步函数只差一个参数:ctx 里带着 signal(取消信号)、progress(进度上报)、runtime(派子任务用的作用域化运行时)与 meta(调用方塞进来的任意数据)。不读 ctx 的 taskFn 也是合法的 taskFn,只是它对取消一无所知,§3 会回到这一点。

run(runtime, taskFn, options) 立即返回一个 Task 句柄,taskFn 本身此时还没开始跑。句柄上能读到的是状态而不是结果:status 是当前状态名,dataerror 只在对应终态下有值,getSnapshot() 做一次原子读,subscribe() 订阅每次状态变化。结果本身挂在 promise 字段上,started 是另一个 Promise,在任务真正进入 running 时 resolve。这个设计把「任务」与「任务的结果」拆开:UI 绑定的是句柄,等待的是 promise。

options 里与本页有关的是三项。label 只给 devtools 看,不影响调度;after 让本任务等别的任务成功后再入队;parent 把本任务挂到另一个任务下面,父任务倒下时它一起倒。后两项都会改变状态机的走法。

2 · 九个状态与它们之间的边

TaskState 是带数据的 discriminated union,九个状态分三层。起点是 idle;中间态有 parked:deps(等 after 依赖)、parked:coordinator(被同 key 协调器排队,见同 key 协调)、queuedrunningcancelling;终态三个:successdataerrorerrorcancelledreason。图 2-1 把两种 parked 合并成一个结点,因为它们的出边完全相同。

图 2-1 · 一个 Task 沿状态机走出的路径,下方时间轴是同一次运行的 Gantt,日志是 subscribe(runtime) 收到的事件。可在六种场景间切换,对照状态机上被点亮的边。

六种场景对应六条路径,全部由 core/scenarios.ts 在真实运行时上跑出并锁进测试:

  • 正常完成:idle → queued → running → success
  • 抛错:同一条路径,终点换成 errortask.error 就是 taskFn 抛出的那个对象。
  • 运行中 cancel()running → cancelling → cancelled,reason 为 abortcancelling 是一个真实存在的中间态,taskFn 收到 abort 之后到它真正退出之前,任务停在该态。
  • 排队时 cancel()queued → cancelled,同样是 abort,但任务从未进入 running,时间轴上只有灰色的排队段。
  • after 且依赖成功:idle → parked → queued → running → success。任务先挂起,依赖 resolve 后才排队。
  • after 且依赖失败:parked → cancelled,reason 为 dep-failure。任务连队都没排上。

cancelled 的 reason 一共六种:abortparentdep-failurequeue-cleardedupeorphaned-intent。前四种在本页与队列与并发里都能看到,后两种属于协调器,留到那一页。reason 不是诊断附注,task.whenCancelled() 返回的就是它,调用方据此区分「用户主动取消」与「被上游连带」。

注 · 状态名里的 parked:depsparked:coordinator 在事件流上是同一个 task:parked 事件,靠 payload 里的 reason 字段区分。本系列的 Gantt 把 parked 段画成浅琥珀色,与普通排队的浅灰区分开。

3 · 协作式取消

task.cancel() 做的事只有一件:把 ctx.signal.aborted 翻成 true。它不会中断正在执行的 JavaScript,也没有办法中断。这叫 cooperative cancellation:taskFn 得自己在合适的地方看一眼信号,看到了就退出。I/O 型 taskFn 天然满足,因为 sleep(ms, signal)fetch(url, { signal }) 都接受信号并在 abort 时 reject;CPU 型 taskFn 则要在循环里显式让出,await sleep(0, ctx.signal)ctx.signal.throwIfAborted() 都行。

图 3-1 · 两个跑 2 秒 CPU 工作的 taskFn,一个每轮让出主线程并检查信号,一个同步自旋到底。可在任务运行期间点 cancel(),对照两者的终态与实际退出时刻。

忙循环版的结果起初与预期不符。既然状态机写的是 cancel 之后 cancelling → taskFn 退出后 cancelled,直觉上它该在跑完之后落 cancelled。实测(core/scenarios.test.ts §3)是:定时 10ms 后发出的 cancel() 根本排不上主线程,等它能执行时 taskFn 已经 resolve,终态是 success,迭代数接近整个预算。图 3-1 里点击按钮的事件同样要等自旋结束才会被处理。这条状态迁移的前提是 taskFn 会检查 ctx.signal——取消在这个库里是协作式的,cancel() 只翻转 signal,不看信号的 taskFn 根本不存在「收到 abort」这件事。

警示 · 不让出主线程的同步循环既不能被取消,也会把页面上的一切都冻住,包括那个用来取消它的按钮。把长计算切成带 await 的小段不是为了让它更快,是为了让它可以被打断。

4 · 父子任务与级联

taskFn 通过 run(ctx.runtime, …) 派出的任务自动带上 parent,运行时据此维护一棵任务树,inspect(runtime).tree 能把它读出来。树的作用只有一个方向:父任务被取消或出错时,所有后代一律以 parent 为 reason 取消。反向不成立,子任务出错不会自动波及父任务。

图 4-1 · P 派出 A 与 B,A 再派出 A1 与 A2,共五个结点。可在四种干预间切换,对照树上各结点的终态与 cancelReason。

三种干预的结果(core/scenarios.test.ts §4):

  • 取消 P:A、B、A1、A2 四个后代全部 cancelled,reason 都是 parent;P 自己是 abort
  • 只取消 A:A 是 abort,A1 与 A2 是 parent,兄弟 B 不受影响,正常 success
  • P 抛错:A 与 B 同样以 parent 取消。TaskCancelReason 这个联合里只有一个 "parent",两种触发共用它,所以子任务分不出父任务是被显式取消还是自己失败了。

写这个场景时踩到一处反向传播。第一版的 P 用 Promise.all 等两个子任务,于是「只取消 A」这一栏里 B 也变成了 cancelled:A 的 promise 以 CancelledError reject,Promise.all 随之 reject,P 抛错,B 作为 P 的后代被级联。改成 Promise.allSettled 之后 B 才独立。级联本身是单向的,但一个 await 就能把子任务的失败变成父任务的失败,再由父任务向下传给兄弟。

建议 · 父任务里等子任务用 allSettled 还是 all,取决于要不要让一个子任务的失败拖垮同级。要拖垮的场合有更直接的写法:作用域与 effects里的 scope 把一组任务绑在同一个 sentinel 任务下,scope.cancel() 一次取消全部。

一次 run() 到此为止。下一页看多个任务同时进来时谁先跑:队列与并发