JavaScript异步任务队列如何调度

684 阅读2分钟

有个很常见的面试题,有m个异步任务需要执行,但限制同一时刻只能执行n个任务。如果m<=n,直接全部执行就好;如果m>n,就把超出数量的任务放入等待序列,待n个任务中有结束的任务,递补上去。

一个典型的任务调度系统,包含这么几个要素:

  1. 执行池,即任务执行所在地,有同时执行任务数量上限,即题目中的n。
  2. 任务队列,即超出最大同时可执行任务数之外的任务,需要进入一个排队等待的序列,待执行中的任务释放之后,按顺序递补上去。
  3. 通知机制。执行中的某个任务完成之后,如何通知任务队列递补一个新任务开始执行。

如果只是个一次性任务,无返回值,那么只需要把任务推入队列等待即可;如果任务有返回值,那在任务注册的时候,就需要把一个将来某个时间才能完成的执行结果与任务本身关联起来。

具体看这样一个JavaScript题目:

/**
 * 异步并发控制器
 *
 * 该函数返回一个执行函数(executor), 该执行函数接收一个异步任务函数(task),
 * executor 被调用时, 会根据 capacity 来执行 task: 如果正在执行的异步任务数不超过 capacity,
 * 则立即执行, 否则会等到任意一个正在执行的 task 结束后再执行. 并返回值为 task 的返回值的 Promise.
 */
 
function createAsyncWorker(capacity) {
  return async (fn) => {}
}

一个比较简洁的实现方式:

function createAsyncWorker(capacity) {
  let count = 0;
  const queue = [];

  return async (fn) => {
    count++;

    if (count > capacity) {
      // 一个没有resolve的Promise,将始终处于pending状态,
      // 叠加await,就是一个同步阻塞器,后续代码不会继续执行。
      // 把resolve函数拿到别的地方执行,就起到了开关和通知的作用。
      await new Promise((resolve) => {
        queue.push(resolve);
      });
    }

    // 一个任务执行完之后,查询任务队列是否有未执行的任务
    // 如果有,打开阻塞开关,即通知任务开始执行
    return fn().finally(res => {
      count--;
      if (queue.length) {
        const r = queue.shift();
        r();
      }
      return res;
    });
  }
}

/* ----------------- 以下是测试用例 -----------------*/

function testCreateAsyncWorker(createParallelTaskExecutorImpl) {
  const assert = require('assert');
  const executor = createParallelTaskExecutorImpl(2);
  const runTask = (id, delay, expectedDelay, fail) => {
    const start = Date.now();
    const check = (rejected) => (v) => {
      assert.strictEqual(rejected, fail, `promise status of task ${id} should be ${fail}, received ${rejected}`);
      const realDelay = Date.now() - start;
      assert.strictEqual(
        Math.round(realDelay / 100) * 100,
        expectedDelay,
        `delay of task ${id} should be ${expectedDelay}, received ${realDelay}`,
      );
      assert.strictEqual(
        v,
        delay,
        `${rejected ? 'error of rejected' : 'result of resolved'} task ${id} should be ${delay}, received ${v}`,
      );
    };
    executor(
      () =>
        new Promise((resolve, reject) => {
          setTimeout(() => {
            if (fail) {
              reject(delay);
            } else {
              resolve(delay);
            }
          }, delay);
        }),
    )
      .then(check(false), check(true))
      .catch((e) => {
        console.error(e);
      });
  };
  runTask(1, 100, 100, false);
  runTask(2, 200, 200, true);
  runTask(3, 300, 400, false);
  runTask(4, 400, 600, true);
  runTask(5, 100, 500, false);
  runTask(6, 200, 700, true);
  runTask(7, 100, 700, false);
  runTask(8, 200, 900, false);
}

try {
  testCreateAsyncWorker(createAsyncWorker);
} catch (error) {
  console.error(error);
}