任务与状态机:从 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 是当前状态名,data 与 error 只在对应终态下有值,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 协调)、queued、running
与 cancelling;终态三个:success 带 data,error 带 error,cancelled 带 reason。图 2-1 把两种 parked 合并成一个结点,因为它们的出边完全相同。
六种场景对应六条路径,全部由 core/scenarios.ts 在真实运行时上跑出并锁进测试:
- 正常完成:
idle → queued → running → success。 - 抛错:同一条路径,终点换成
error。task.error就是 taskFn 抛出的那个对象。 - 运行中
cancel():running → cancelling → cancelled,reason 为abort。cancelling是一个真实存在的中间态,taskFn 收到 abort 之后到它真正退出之前,任务停在该态。 - 排队时
cancel():queued → cancelled,同样是abort,但任务从未进入 running,时间轴上只有灰色的排队段。 - 带
after且依赖成功:idle → parked → queued → running → success。任务先挂起,依赖 resolve 后才排队。 - 带
after且依赖失败:parked → cancelled,reason 为dep-failure。任务连队都没排上。
cancelled 的 reason 一共六种:abort、parent、dep-failure、queue-clear、dedupe 与 orphaned-intent。前四种在本页与队列与并发里都能看到,后两种属于协调器,留到那一页。reason
不是诊断附注,task.whenCancelled() 返回的就是它,调用方据此区分「用户主动取消」与「被上游连带」。
注 · 状态名里的 parked:deps 与 parked: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() 都行。
忙循环版的结果起初与预期不符。既然状态机写的是 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 取消。反向不成立,子任务出错不会自动波及父任务。
三种干预的结果(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() 到此为止。下一页看多个任务同时进来时谁先跑:队列与并发。