前端知库

怎样实现一个简单的 Promise 调度器,限制并发并按优先级执行任务?

调度器接收还没启动的任务函数和优先级,放进按优先级排好的等待队列;正在运行的任务少于上限时,就从队头取一个启动,任务结束再补下一个。add 返回的 Promise 跟随任务的结果,单个任务失败不影响队列继续跑。

参考答案

简答

场景是页面一下要发很多请求,但最多只能同时发 2 个,而且首屏要用的数据要优先发。调度器提供一个 add(task, priority) 方法,task 是一个返回 Promise 的函数,还没调用。add 返回一个 Promise,任务真正执行完时才兑现或拒绝。

内部维护两样东西:一个按优先级排好的等待队列,和一个“正在运行几个”的计数。每次有任务加入或者有任务结束,就检查一下:运行数没到上限、队列里还有任务,就从队头取一个启动。任务结束时,不管成功失败,都把计数减一,再去取下一个。

为什么要传函数而不是传 Promise?因为 Promise 一创建,里面的请求就已经发出去了,调度器没法再控制它什么时候开始。传函数,调度器想什么时候调用就什么时候调用。

要注意三点。同一段同步代码里加入的任务,要等它们都进了队列再开始调度,否则第一个加进来的会抢先启动,优先级就白排了。优先级相同的按加入顺序执行。单个任务失败只让它自己的 Promise 拒绝,计数照样要减,否则这个位置就永远空不出来。

实现

先看整体结构:waiting 是等待队列,running 是正在运行的数量,runNext 负责“有空位就补上”。add 把任务按优先级插进队列,并安排一次 runNext。

JavaScript
function createScheduler(limit) {
  const waiting = [];
  let running = 0;

  function runNext() {
    while (running < limit && waiting.length > 0) {
      const item = waiting.shift();
      running++;
      Promise.resolve()
        .then(item.task)
        .then(item.resolve, item.reject)
        .finally(() => {
          running--;
          runNext();
        });
    }
  }

  function add(task, priority = 0) {
    return new Promise((resolve, reject) => {
      const item = { task, priority, resolve, reject };
      // 数字越大越先执行;插到第一个优先级更低的任务前面,同优先级保持加入顺序
      const index = waiting.findIndex((other) => other.priority < priority);
      if (index === -1) waiting.push(item);
      else waiting.splice(index, 0, item);
      // 推迟到微任务里再调度,让同一段同步代码里加入的任务先全部排好序
      Promise.resolve().then(runNext);
    });
  }

  return { add };
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const scheduler = createScheduler(2);
const log = [];

function job(name, ms) {
  return async () => {
    log.push(`开始 ${name}`);
    await sleep(ms);
    log.push(`结束 ${name}`);
    return name;
  };
}

const results = [
  scheduler.add(job('A', 100), 1),
  scheduler.add(job('B', 20), 1),
  scheduler.add(job('C', 20), 1),
  scheduler.add(job('D', 20), 5),
  scheduler.add(() => {
    log.push('开始 E');
    throw new Error('E 失败');
  }, 3),
];

Promise.allSettled(results).then((settled) => {
  console.log(log.join(' | ')); // 开始 D | 开始 E | 开始 A | 结束 D | 开始 B | 结束 B | 开始 C | 结束 C | 结束 A
  console.log(settled.map((r) => r.value ?? r.reason.message).join(',')); // A,B,C,D,E 失败
});

A 明明第一个加进来,为什么 D 和 E 先开始?因为 add 没有当场调度,而是用 Promise.resolve().then(runNext) 推迟到微任务里。五个 add 都是同步执行的,等第一次 runNext 跑起来,队列已经排成了 D(5)、E(3)、A(1)、B(1)、C(1)。上限是 2,于是 D 和 E 先启动。

E 一启动就同步抛错,为什么调度器没崩?任务是通过 Promise.resolve().then(item.task) 调用的,then 回调里抛出的错误会让返回的 Promise 拒绝,而不是往外抛。所以 E 的错误交给了 item.reject,E 的 Promise 拒绝。随后 finally 把计数减一、补上 A,两个位置里一个是 D、一个是 A。

D 20 毫秒后结束,补上 B;B 结束补上 C;C 在 60 毫秒左右结束,A 到 100 毫秒才结束。几个时长故意拉开了差距,因为定时器的实际触发时间会有误差,时长太接近时先后可能颠倒。日志的顺序正好对应这个过程。结果数组按 add 的调用顺序排列,跟谁先完成没有关系,因为每个 add 返回的是各自的 Promise。

为什么失败时计数一定能减回去

finally 的回调不管前面成功还是失败都会执行,MDN 也说明它不接收参数、也不改变链上的结果。这里写在 .then(item.resolve, item.reject) 之后,前一步两个分支都已经处理了,链到这里一定是成功状态,所以不会产生未处理的拒绝。计数减一和 runNext 放在这里,就保证了每个任务结束时都会腾出一个位置。

如果把计数放在任务成功的分支里,一旦有任务失败,running 就永远少减一次。失败几次,并发上限就少几个,最后整个队列卡住。

后加入的高优先级任务能插队吗

能插到等待队列的前面,但不会打断已经在跑的任务。在 Node.js 22.23 里用 createScheduler(1) 测过:先加一个 20 毫秒的任务让它跑起来,5 毫秒后再加一个优先级 0 的和一个优先级 9 的,结果执行顺序是“正在跑的那个 → 优先级 9 → 优先级 0”。另外测了 10 个任务、上限 3 的情况,同时运行的数量最多就是 3。

队列这里用的是数组插入,findIndex 加 splice 是 O(n)。任务数量很大时可以换成二叉堆,见《前端任务怎样按优先级排队?二叉堆为什么不需要每次完整排序?》。

常见追问

一个任务失败,要不要让后面的任务都停下来?

看业务。上面的写法是各管各的,适合互不依赖的请求。如果任务之间有依赖,一个失败后面就没意义,就要在失败时清空等待队列,并通知正在运行的任务中止。这种“整体失败”的写法见《怎样实现有明确失败语义的异步并发池?》。

怎么取消还在排队的任务?

可以让 add 再返回一个取消函数,或者接收一个 AbortSignal。取消时如果任务还在 waiting 里,就把它移出去并拒绝它的 Promise;如果已经开始了,调度器停不了它,要靠任务自己响应 AbortSignal,比如把 signal 传给 fetch。

为什么要用 Promise.resolve().then(runNext),而不是 setTimeout?

两种都能让同步加入的任务先排好序。微任务在当前同步代码结束后马上执行,不会额外等一轮任务;setTimeout 要等到下一个任务,至少多一点延迟。这里只需要“等同步代码跑完”,用微任务就够了。