题目要求
实现一个 JavaScript 异步任务调度器 Scheduler,要求同时运行的任务数量不超过 n 个。
示例
例如目前有4 个任务,所需执行时间分别为,1000ms、500ms、300ms、400ms。
那么当 n === 2 的时候,在该调度器中的执行完成顺序应该为 2、3、1、4。
0ms: 任务1、2开始执行 500ms: 任务2完成,任务3开始 800ms: 任务3完成,任务4开始 1000ms: 任务1完成 1200ms: 任务4完成
设计思路
模拟异步任务
为了测试调度器,我们需要模拟异步任务(比如接口请求)。
javascript
/**
* 创建一个延迟执行的任务
* @param {Function} task - 要执行的任务函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function} 返回一个返回 Promise 的函数
*/
const createTask = (task, delay) => {
return () => {
return new Promise((resolve) => {
setTimeout(() => {
task();
resolve();
}, delay);
});
};
};
createTask 函数接收两个参数:任务函数和延迟时间,返回一个返回 Promise 的函数。
为什么要返回函数而不是直接返回 Promise?
核心原因是控制执行时机:
- 直接返回 Promise:Promise 一旦创建就会立即执行,setTimeout 立刻开始计时,这样任务就不受调度器控制了。
- 返回函数:函数不会立即执行,只有调度器在合适的时机调用 task() 时,Promise 才会被创建,任务才真正开始执行。
简单来说,就是把"执行权"交给调度器。调度器通过 await task() 来决定何时执行任务,这样才能实现并发控制。这就像给调度器一个"开关",而不是一个已经启动的任务。
调度器设计
为了支持创建多个独立的调度器实例,同时保护内部状态不被外部直接修改,我们选择实现一个 Scheduler 类。
构造函数
根据题目要求"同时运行的任务数量不超过 n 个",我们需要在构造函数中定义三个关键变量:
maxConcurrency:最大并发数,通过构造函数传入,作为并发控制的上限runningCount:当前正在执行的任务数量,用于判断是否达到并发上限queue:等待队列,用于存储超出并发限制后需要等待的任务
注意:这里 queue 存储的不是任务本身,而是 Promise 的 resolve 函数。这是设计的关键点------我们不需要管理任务队列,只需要一个"唤醒机制"。当有任务完成时,调用队列中的 resolve() 就能让等待的任务继续执行。
核心功能
功能 1:判断是否需要等待?通过对比maxConcurrency 和runningCount 来判断
功能 2:如何让任务"挂起"等待?显然需要创建一个 promise ,将其resolve 函数放到queue 中等待队列
功能 3:执行任务,执行前后更新runningCount
功能 4:任务完成后,通过shift 调用queue中的resolve唤醒下一个任务
代码实现
可复制到控制台执行:
javascript
/**
* 任务调度器类
* 用于控制并发任务的执行数量,防止同时执行过多任务
*/
class Scheduler {
constructor(maxConcurrency) {
this.queue = []; // 等待队列,存储的是 Promise 的 resolve 函数
this.runningCount = 0; // 当前正在执行的任务数量
this.maxConcurrency = maxConcurrency; // 最大并发数
}
/**
* 添加任务到调度器
* @param {Function} task - 要执行的异步任务
* @returns {Promise} 返回任务执行结果
*/
async addTask(task) {
// 如果当前运行的任务数已达到最大并发数,则需要等待
if (this.runningCount >= this.maxConcurrency) {
// 核心设计:不存储任务本身,而是创建一个 Promise 让当前任务在此处等待
// 将这个 Promise 的 resolve 函数存入队列,等有空闲时再调用它来恢复执行
await new Promise((resolve) => {
this.queue.push(resolve);
});
}
// 开始执行任务
this.runningCount++;
let res = await task(); // 等待任务执行完成
this.runningCount--; // 任务完成,运行数减 1
// 如果有任务在等待,唤醒队列中的下一个任务
if (this.queue.length) {
const resolve = this.queue.shift(); // 取出等待队列的第一个 resolve
resolve(); // 调用 resolve,让对应的任务从 await 处继续执行
}
return res;
}
}
/**
* 创建一个mock的异步任务
* @param {Function} task - 要执行的任务函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function} 返回一个返回 Promise 的函数
*/
const createTask = (task, delay) => {
return () => {
return new Promise((resolve) => {
setTimeout(() => {
task();
resolve();
}, delay);
});
};
};
// 创建一个最大并发数为 2 的调度器实例
const scheduler = new Scheduler(2);
scheduler.addTask(
createTask(() => {
console.log("任务 1");
}, 1000),
);
scheduler.addTask(
createTask(() => {
console.log("任务 2");
}, 500),
);
scheduler.addTask(
createTask(() => {
console.log("任务 3");
}, 300),
);
scheduler.addTask(
createTask(() => {
console.log("任务 4");
}, 400),
);
思考
- 这个问题在实际开发中有哪些应用场景?
这个模式在实际应用中非常常见,比如:
- 控制 HTTP 请求并发数
- 限制文件读写操作的并发
- 批量数据处理时的流量控制
- 都说 JavaScript 是单线程的,怎么会有并发呢?
JavaScript 引擎是单线程执行的,但浏览器本身是多进程多线程模型。
-
JS 引擎线程:单线程,负责执行 JavaScript 代码
-
定时器触发线程:负责处理 setTimeout、setInterval
-
HTTP 异步请求线程:负责处理网络请求
-
事件触发线程:负责管理事件队列
-
GUI 渲染线程:负责页面渲染
当我们发起异步操作(如 fetch、setTimeout)时,实际上是把任务交给了浏览器的其他线程处理。这些线程可以真正并行工作,完成后通过事件循环(Event Loop)把回调放入任务队列,再由 JS 引擎线程执行。
- 如果要实现任务优先级,该如何改造?
可以把 queue 从数组改为优先队列(最小堆),每个等待的任务带上优先级。任务完成时,从堆中取出优先级最高的任务唤醒。
javascript
// 存储格式:{ resolve, priority }
this.queue.push({ resolve, priority: task.priority })
// 唤醒时按优先级排序
const { resolve } = this.queue.sort((a, b) => a.priority - b.priority).shift()
只需要在任务工厂里加上优先级:
typescript
// 改造 createTask,支持优先级
const createTask = (task, delay, priority = 0) => {
const taskFn = () => {
return new Promise((resolve) => {
setTimeout(() => {
task()
resolve()
}, delay)
})
}
// ✅ Function 是 Object 的子类型,可以添加属性
taskFn.priority = priority
return taskFn
}