手写调度器 scheduler
代码实现
// 所有任务先统一入队,再由 run() 调度;闭包保存队列和并发计数。
function createScheduler(max) {
const queue = []
let running = 0
function addTo(task) {
return new Promise((resolve, reject) => {
queue.push({ task, resolve, reject })
run()
})
}
function run() {
while (running < max && queue.length > 0) {
const { task, resolve, reject } = queue.shift()
running++
// 在 Promise 链中调用 task,统一处理返回值和错误。
Promise.resolve()
.then(task)
.then(resolve, reject)
.finally(() => {
running--
run()
})
}
}
return addTo
}
// 只有槽位已满才入队;队列保存包装好的 runTask。
function createScheduler2(max) {
const queue = []
let running = 0
function addTo(task) {
return new Promise((resolve, reject) => {
const runTask = () => {
running++
Promise.resolve()
.then(task)
.then(resolve, reject)
.finally(() => {
running--
if (queue.length > 0) {
const nextTask = queue.shift()
nextTask()
}
})
}
if (running < max) {
runTask()
} else {
queue.push(runTask)
}
})
}
return addTo
}
笔记记录
手写调度器 scheduler
要解决什么问题
任务可以不断提交,但同时占用的执行槽位不能超过 max。超出的任务排队,某个任务完成或失败后,后面的任务再补上。
下面保留两种闭包写法,都用 queue 和 running 控制并发,每次添加任务都返回它自己的 Promise:
createScheduler:所有任务先入队,再由同一个run()调度。createScheduler2:有空位就安排执行,只有槽位已满才把包装好的函数入队。
调用方式与执行时机
const addTask = createScheduler(2)
const delay = (value, ms) => () =>
new Promise(resolve => setTimeout(() => resolve(value), ms))
addTask(delay('A', 100)).then(console.log).catch(console.error)
addTask(delay('B', 30)).then(console.log).catch(console.error)
addTask(delay('C', 10)).then(console.log).catch(console.error)
// A、B 先获得槽位,C 排队。
// B 完成后 C 才启动,正常计时下输出顺序为 B、C、A。createScheduler(2) 只创建调度器并返回 addTo,此时没有任务执行。调用 addTask(task) 才会入队并触发调度。
run() 同步取出任务并增加 running,先占住槽位;真正的 task() 在 .then(task) 的微任务中调用。因此 running 包括已经获得槽位、即将开始执行的任务。
主流程
先看第一种“统一入队”的写法:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 提交 | queue.push({ task, resolve, reject }) | 把任务和它自己的 Promise 完成函数绑在一起 |
| 取任务 | queue.shift() | 按提交顺序取出等待任务 |
| 占槽位 | running++ | 在任务真正开始前更新并发计数 |
| 执行 | .then(task) | 启动任务并等待返回结果 |
| 通知调用方 | .then(resolve, reject) | 完成或拒绝这一条任务对应的 Promise |
| 释放并补位 | .finally(...) | 无论成功失败,都减少计数并继续调度 |
run() 有两个入口:新任务入队时,以及已有任务结束时。这让队列既能在首次提交时启动,也能在槽位释放后继续推进。
为什么这里用 while
while (running < max && queue.length > 0) {
// 取出一个任务,占用一个槽位,安排执行。
}只要有空闲槽位和等待任务,就持续安排执行,直到槽位用满或队列为空。循环里没有 await,因此不会等当前任务完成才安排下一个。
它也不会一次启动所有任务:每安排一条,running 就同步加一,达到上限后循环停止。
在每次只添加一条、每次只释放一个槽位的简单流程中,if 也可能正常工作;while 更直接地表达了“把所有可用槽位补满”。
为什么把 task 放进 Promise 链
Promise.resolve().then(task) 会在 Promise 回调中调用 task。任务正常返回一个值、返回 Promise,或者同步抛错,都可以沿着后面的成功或失败分支处理。
如果改成 Promise.resolve(task()),会先执行 task() 再调用 Promise.resolve,同步异常就可能跳过后面的链式清理。这里把函数本身交给 .then(),统一捕获错误。
要传入“尚未执行的任务函数”,例如 () => fetch('/api/item')。如果传入的是已经发出的请求,调度器就无法控制它何时开始。
为什么需要 finally
只有成功时减少 running 会导致失败任务一直占着槽位,后续任务可能卡在队列里。放在 finally 中,无论结果如何都能释放槽位,并再次调用 run()。
失败只会拒绝当前任务对应的 Promise,不会停止整个队列。调用方仍需处理返回 Promise 的拒绝,例如添加 .catch()。
第二种:槽位已满才入队
createScheduler2 的调用方式相同,只需把示例中的工厂函数换成 createScheduler2(2)。
每次调用 addTo(task),先创建一个 runTask。这个包装函数通过闭包记住当前任务的 task、resolve 和 reject,内部负责占位、执行、通知结果,以及结束后补位。
if (running < max) {
runTask()
} else {
queue.push(runTask)
}有空位时直接调用 runTask(),没有空位时才排队。这里“直接调用”仍然只是同步增加 running、安排 Promise 链;真正的 task() 同样在微任务里执行。
例如上限为 2,依次提交 A、B、C:A、B 直接获得槽位,队列里只留下 C 的 runTask。B 结束后,先把 running 从 2 减到 1,再取出 C 的包装函数执行,计数立即回到 2。
为什么队列里放 runTask
排队的不只是原始任务,还需要记住任务结果应该交给谁。第一版用对象显式保存 { task, resolve, reject },第二版让 runTask 的闭包保存这三者,因此出队后直接调用即可。
为什么这里用 if 就够了
每个任务结束只释放一个槽位,所以只需要取出一个等待任务补上。running-- 和 nextTask() 之间没有异步等待,补位函数又会同步执行 running++,不会让多个等待任务抢占同一个槽位。
这依赖当前实现的前提:并发上限固定、每次完成释放一个槽位。如果以后支持动态调高上限,再考虑集中调度、一次补满多个槽位。
两种写法怎么对照
| 对比点 | 统一入队:createScheduler | 满额才入队:createScheduler2 |
|---|---|---|
| 队列内容 | 保存任务及 resolve、reject 的对象 | 闭包保存这些信息的 runTask 函数 |
| 提交任务 | 先入队,再调用 run | 有空位直接安排,否则入队 |
| 完成后 | 调用 run,用 while 补满可用槽位 | 取出一个 runTask,补上刚释放的槽位 |
| 阅读重点 | 调度逻辑集中在 run | 单个任务的执行和清理集中在 runTask |
在这里的固定并发场景中,两版都能限制并发、按顺序启动等待任务,并在失败后继续推进队列。主要区别是代码组织方式;可以根据自己更容易复述哪种流程来选择。
需要记住的边界
max约定为正整数,task约定为函数;此实现没有额外参数校验。max = 0时,任务会一直排队。- 队列保证等待任务按先入先出启动,不保证按提交顺序完成。
- 如果某个任务永远不结束,它就会一直占用槽位;这里没有超时或取消功能。
- 每次添加任务返回独立的 Promise;与可重启任务共用一个外层 Promise、只采纳最新轮次的设计不同。
面试时怎么讲
我用闭包保存等待队列和运行计数,每次添加任务都创建一个 Promise,把任务与它的 resolve、reject 一起入队。调度函数通过 while 在并发上限内取任务,再放进 Promise 链执行。成功或失败分别通知对应调用方,finally 统一释放槽位并继续调度,因此单个任务失败不会阻塞后面的任务。
第二种可以这样讲:我把单个任务的执行和清理封装成 runTask,用闭包保存任务及其 Promise 完成函数。有空位就直接安排执行,槽位满了才把 runTask 入队。任务结束时在 finally 释放一个槽位,再取出一个等待任务补上。