队列与并发:优先级与队列开关
任务与状态机看的是一个任务,而应用里同时在飞的往往是几十个。多出来的问题只有两个:同时跑几个,以及谁先跑。运行时把它们交给一条带优先级的队列回答,另外留出四个能从外部拨动的开关:pause、resume、clearQueue 与
cancelAll。本页把队列这一层看清:档位如何排序、提交时机为何会改变次序、被队列级操作收走的任务留下什么痕迹。
1 · 并发上限与优先级 lane
SchedulerOptions.concurrency 是同时运行的任务数上限,默认 3。超出上限的任务停在 queued,等一个槽位空出来。getActiveCount(runtime) 读已占用的槽位数,getQueueSize(runtime) 读还在等的条数,两者都是 live 值。上限本身也能在运行期改:setConcurrency()
调高时立刻把待处理任务补录进来填满新额度,调低则不抢占已经在跑的任务,等它们自然退出。concurrency 与 setConcurrency() 都挂在 RuntimeCore 接口上(src/engine/types.ts),也就是说面向接口写的 mock 与适配器同样要实现它们,本页的 lab 用的就是这两个。
排序的那一维是 priority lane。TaskPriority 给出六档常量:Immediate 为 0,UserBlocking 为 1,Normal 为 2,Low 为 3,Idle 为 4,Background 为 5。数值越小越先跑,同一档内严格 FIFO,不传 priority 时落在 Normal。这套命名取自 React 的
scheduler,档位的含义也对得上:Immediate 给等同于同步的紧急更新,UserBlocking 给点击与输入的直接响应,Background 给真正可以延后的活。
同一个同步块里提交六条任务、并发为 1 时,开跑次序完全由档位决定:imm-1、imm-2、normal-1、normal-2、bg-1、bg-2,与提交次序恰好相反(core/scenarios.test.ts §1)。并发放到 2,前两条开跑的仍是两条
Immediate,最后完成的是两条 Background(同上)。
注 · 档位的数值同时是 lane 索引。调度器给六条 lane 各留一个 bit,pendingLanes & -pendingLanes 取最低置位 bit 就得到当前最高优先级的非空 lane,选档因此是一次位运算,与队列长度无关。推导写在 src/engine/scheduler/priority.ts 的注释里。
2 · 暂停与清空的语义
pause() 只做一件事:不再放行新任务。已经在跑的不受影响,跑完照常落终态;队列里的一条都不开跑。并发 1 的运行时上暂停后,在飞的那条仍走到 success,activeCount 回到 0,queueSize 停在 2,两条排队任务的状态还是 queued;resume()
之后它们按原次序跑完(lab/facts.test.ts §2)。这一对操作各自还向事件总线发一条 runtime:pause 与 runtime:resume。
clearQueue() 把等待中的任务全部丢掉,返回移除的条数,在飞的不动;cancelAll() 在此之上再 abort 所有在跑的任务。单条任务仍由 task.cancel() 收,运行时不提供 remove()。
三者的差别落在 cancelReason 上。直觉上 cancelAll() 收掉在飞任务留下的该是 abort——那条路径确实翻转了 signal。实测四条任务(一条在飞、三条排队)全部以 queue-clear 收尾(lab/facts.test.ts §2)。答案在 engine/task.ts 的
computeCancelReason() 里:cancelReasonOverride ?? (signal.aborted ? "abort" : ...)——显式写入的 reason 排在 signal 归因之前。而 scheduler.ts 对 running 任务调的正是 cancelWithReason("queue-clear"),override 先落地,signal
那条分支根本轮不到。所以「翻了 signal」不等于「reason 是 abort」,abort 只留给没人指定 reason 的那条路径,也就是调用方自己的 task.cancel()。
于是 cancelReason 分得出两类来源:abort 说明有人点名收掉了这一条,queue-clear 说明整批被端掉。task.whenCancelled() 拿到的就是它,调用方据此决定是提示「已取消」还是安排重投。
task.cancel()。可先加满队列,再分别用 pause、clearQueue 与 cancelAll 观察各行的终态与 cancelReason。3 · 提交时机与 microtask 边界
调度并不在 run() 里同步发生。任务入队之后,调度器的启动被 queueMicrotask() 推到当前同步块之后,同一个 tick 里提交的任务全部登记完毕才第一次被它看见,档位排序对这一批完整生效,哪怕最高优先级的那条是最后提交的。跨一次 await 提交则是另一回事:先提交的任务此时已经从
queued 走到 running,而运行中的任务不可抢占,后到的高优先级任务只能排在它后面。
core/scenarios.test.ts §3 把这两种时机各跑了一遍:同一个同步块里先提交 Background 再提交 Immediate,开跑次序是 high 在前;两次提交之间隔一个 await Promise.resolve(),次序翻成
low 在前。一类常见的困惑由此得到解释——优先级看起来没生效,实际是两条任务落在了不同的 tick 里。
还有一条同源的限制:档位只在已经就绪的任务之间起作用。被 after 依赖挂起的任务停在 parked,根本不在队列里,依赖没解决之前档位再高也排不上。依赖边与编排入口见任务编排。
4 · 队列满与队列停摆
前三节的队列没有上限,任何提交都会被收下。SchedulerOptions 另给两个把压力挡在门外的开关,它们决定的不是次序而是准入。
maxSize 限制待处理队列的长度。达到上限后的提交不再排队,当场落 error 终态,错误是 QueueFullError。它的终态是 error 而非 cancelled:引擎对这类结构性拒绝一律走 task.fail(err),不用 task.skip(),好让状态、onTaskError
hook 与被 reject 的 promise 说同一个故事(src/engine/scheduler/scheduler.ts 的 rejectIfBlocked 注释)。
onError: 'stop' 让调度器变成一次性的:第一个失败的任务把它推进终态 stopped,队列里剩下的全部以 QueueStoppedError 作废,此后再提交的任务同样当场失败。默认值 'continue' 则让兄弟任务互不牵连。
五条任务在同一个同步块里提交、第一条抛错,三种配置给出三幅不同的图景(lab/facts.test.ts §4):默认配置下五条全部执行,只有第一条 error;maxSize: 2 下只有前两条被收下,第三条起的三条都是 QueueFullError;onError: 'stop' 下 taskFn
体只执行了一次,其余四条是 QueueStoppedError。
SchedulerOptions 下的终态。可切换配置,对照被拒绝的条数与错误类名。
警示 · QueueFullError 是 maxSize 这一档唯一的诊断信号,和 CancelledError / TimeoutError / QueueStoppedError / TaskError 一样从 @vega/job 导出。这几个类之间没有继承关系,QueueFullError
不是 TaskError 的子类,想统一兜住得逐个 instanceof。按错误消息字符串匹配同样不可取——message 不在稳定契约里。
队列排的是已经就绪的任务。一旦先后次序来自数据依赖,排序这一维就不够用了,得把任务连成图:任务编排。