先记住这个答案
用一个等待队列和一个运行计数器就能实现。调用 add(task) 时,返回一个新的 Promise,把任务函数连同这个 Promise 的 resolve 和 reject 一起放进队列,然后尝试启动。启动的规则是:只要正在运行的数量小于上限、队列里还有任务,就取出一个执行并把计数加一。任务结束时,不管成功还是失败,都把计数减一,再尝试启动一次,空出来的位置就被下一个任务补上。
- 等待队列存任务,计数器记正在运行的数量
add返回 Promise,把resolve和任务一起存进队列- 任务结束(成功或失败)都要计数减一并补位
- 队列里存函数,不要存已经创建的 Promise
面试最常考的写法和它的输出
这道题常见的题面是:最多同时跑 2 个任务,依次加入耗时 1000、500、300、400 毫秒的任务,问打印顺序。
SchedulerJavaScriptclass Scheduler {
constructor(limit) { this.limit = limit; this.running = 0; this.queue = [] }
add(taskFn) {
return new Promise((resolve, reject) => {
this.queue.push({ taskFn, resolve, reject }) // 先排队,resolve 留到任务真正完成时再调
this.run()
})
}
run() {
while (this.running < this.limit && this.queue.length) {
const { taskFn, resolve, reject } = this.queue.shift()
this.running++
taskFn().then(resolve, reject).finally(() => { this.running--; this.run() }) // 结束就补位
}
}
}
const s = new Scheduler(2)
const wait = (ms) => () => new Promise((r) => setTimeout(r, ms))
const addTask = (ms, order) => s.add(wait(ms)).then(() => console.log(order))
addTask(1000, 1); addTask(500, 2); addTask(300, 3); addTask(400, 4)查看输出与解释
2
3
1
4推演一下时间线。一开始任务 1 和 2 同时开跑,3 和 4 在队列里等。第 500 毫秒任务 2 完成,打印 2,空出的位置给任务 3,它在 800 毫秒完成,打印 3,接着任务 4 开跑,要到 1200 毫秒才结束。任务 1 在 1000 毫秒完成,打印 1。最后打印 4。
为什么 add 要返回 Promise
调用方需要知道自己加进去的那个任务什么时候完成、结果是什么。但任务可能要在队列里等很久才开始,add 调用时根本拿不到结果。
所以 add 先返回一个「欠条」,也就是新建的 Promise,并把兑现欠条的 resolve 存进队列。等这个任务真的跑完,调度器再调用 resolve 把结果交出去。调用方只管 await s.add(task),不用关心任务排了多久的队。
这也是为什么队列里存的是 taskFn 这个函数。函数只是「待办事项」,调用它才会真正发请求。如果调用方传进来的是 request() 返回的 Promise,请求在传参那一刻就已经发出去了,调度器再怎么排队也限制不了并发。
这份实现还有哪些漏洞要补
面试时写出上面的版本基本合格,能主动补上下面两点会加分。
- 任务同步抛错
- 如果
taskFn本身不是 async 函数,执行时直接throw,那么taskFn().then(...)这一行根本走不到.then。这时running已经加了一,却永远不会减,调度器会少一个位置。改成Promise.resolve().then(taskFn),同步错误也会变成拒绝,统一走finally。 - 任务一直不结束
- 某个请求挂住不返回,它占的位置就一直不释放。上限是 2 的话,挂住两个整个调度器就停了。实际项目里要给每个任务加超时,比如传一个
AbortSignal.timeout(ms)给请求,超时就拒绝,位置自然被释放。
容易答错的地方
- 只在成功时计数减一
- 把
running--写在.then里,任务一失败,计数就永远少减一次。失败几次之后,调度器就不再执行任何任务。计数必须放在finally里,或者成功和失败两条路都写。 - 用
Promise.race在外面凑并发 - 有人先把所有任务都调用一遍拿到 Promise,再用
Promise.race等空位。这时任务早就全部开始了,限制的只是「等待」而不是「执行」,和题目要求不符。 - 忘了在任务结束后再调一次
run() - 只在
add里启动,队列里剩下的任务要等下一次add才会被取出来。没有新的add,它们就永远在排队。
面试官还会怎么问?
如果题目改成传入一组任务、按原顺序返回全部结果呢?
那就变成了「限制并发的 Promise.all」。在加入时记下每个任务的下标,完成后写到 results[index],全部完成后返回整个数组。结果顺序和完成顺序无关,只看下标。
怎么支持任务优先级?
把队列换成按优先级排序的结构。数量少时每次 push 后按优先级排一次序就够了,任务很多时用二叉堆,取出最高优先级是 O(log n)。
这个调度器和浏览器同域名的连接数限制有什么关系?
HTTP/1.1 下浏览器对同一个域名同时打开的连接数有限制,超出的请求会在浏览器内部排队。自己写调度器的好处是排队由代码控制,可以决定谁先谁后、能不能取消,还能把重要请求排在前面,而不是交给浏览器随意排。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。