如何手写 Promise 并发限制调度器?
简化版
并发限制调度器的核心是维护任务队列、正在运行数量和结果数组。每次只启动不超过 limit 个任务,任务结束后递减运行数并补启动下一个,最后按输入顺序返回全部结果。
详细版
任务应是函数数组,而不是已经执行的 Promise 数组,因为 Promise 一旦创建,请求通常已经发出,调度器就失去限流意义。
function runWithLimit(tasks, limit) {
return new Promise((resolve, reject) => {
const results = new Array(tasks.length)
let nextIndex = 0
let running = 0
let finished = 0
function launch() {
while (running < limit && nextIndex < tasks.length) {
const index = nextIndex++
running += 1
Promise.resolve()
.then(() => tasks[index]())
.then(value => {
results[index] = value
finished += 1
running -= 1
if (finished === tasks.length) resolve(results)
else launch()
}, reject)
}
}
if (tasks.length === 0) resolve([])
else launch()
})
}
面试中要说明失败策略:上面是快速失败版,一旦某个任务失败,整体 reject;如果要收集所有结果,需要把失败包装进结果对象,类似 allSettled。
完整版教学
一、为什么 Promise.all 不是并发限制
const promises = urls.map(url => fetch(url))
await Promise.all(promises)
这段代码在 map 阶段就创建了所有请求,假设有 urls.length = 1000,浏览器、网关和后端都可能被同时压住。Promise.all 只是等待这些请求都结束,并没有“最多 5 个同时跑”的能力。
记忆钩子:并发限制一定要拿“任务函数”,不能只拿“已经起跑的 Promise”。
二、调度器的三个变量
| 变量 | 作用 | 例子 |
|---|---|---|
nextIndex | 下一个待启动任务下标 | 从 0 递增到 n |
running | 当前正在执行数量 | 不允许超过 limit |
finished | 已完成任务数量 | 达到 n 时 resolve |
假设 10 个任务、limit = 3,初始启动 0、1、2;当任务 1 先完成,running 从 3 变 2,立刻补启动任务 3。这样队列始终尽量跑满,但峰值不会超过 3。
三、完整实现快速失败版
function runWithLimit(tasks, limit) {
if (!Number.isInteger(limit) || limit <= 0) {
return Promise.reject(new TypeError('limit must be a positive integer'))
}
return new Promise((resolve, reject) => {
const results = new Array(tasks.length)
let nextIndex = 0
let running = 0
let finished = 0
let stopped = false
function launch() {
if (stopped) return
while (running < limit && nextIndex < tasks.length) {
const index = nextIndex++
running += 1
Promise.resolve()
.then(() => tasks[index]())
.then(value => {
results[index] = value
running -= 1
finished += 1
if (finished === tasks.length) resolve(results)
else launch()
}, error => {
stopped = true
reject(error)
})
}
}
if (tasks.length === 0) resolve([])
else launch()
})
}
Promise.resolve().then(() => tasks[index]()) 的作用是把同步抛错也纳入 Promise 错误链。否则任务函数一旦同步 throw,外层构造器可能提前中断,调度状态也会难以维护。
四、按输入顺序返回
即使任务 2 比任务 0 更早完成,也要写入 results[2]。并发调度关注执行时机,结果汇总仍应保持输入顺序,否则调用方很难把结果映射回原请求。
limit = 2
任务耗时: T0=100ms, T1=20ms, T2=30ms
0ms: 启动 T0、T1
20ms: T1 完成,启动 T2
50ms: T2 完成
100ms: T0 完成
结果: [R0, R1, R2]
如果业务要按完成顺序流式消费,应该另设 onProgress(index, value) 回调,而不是破坏最终结果数组的顺序。
五、失败策略怎么选
| 策略 | 行为 | 适用场景 |
|---|---|---|
| 快速失败 | 任一失败立即 reject,不再启动新任务 | 强依赖批处理 |
| 全部收集 | 每项都返回 fulfilled/rejected 状态 | 批量上传、批量检测 |
| 可重试 | 单项失败后按次数重试 | 弱网络任务 |
| 可取消 | 外部信号中断队列和运行中任务 | 页面卸载、用户取消 |
快速失败并不等于已经运行的任务会被真正取消。若任务是 fetch,仍要给每个任务传入 AbortSignal,调度器只负责不再启动后续任务。
六、limit 与吞吐的数字感
如果单个请求平均 200ms,100 个请求在理想情况下:
limit=1 -> 约 100 * 200ms = 20s
limit=5 -> 约 ceil(100 / 5) * 200ms = 4s
limit=20 -> 约 ceil(100 / 20) * 200ms = 1s
但 limit 不是越大越好。浏览器连接限制、服务器限流、数据库连接池和用户设备性能都会成为瓶颈,过高并发会让失败率上升,甚至让所有请求一起变慢。
七、常见误区与追问
- 误区:把 Promise 数组传给调度器也能限流。 Promise 创建时任务通常已经开始,必须传任务函数。
- 误区:
limit越大越快。 超过系统瓶颈后只会增加排队、重试和失败。 - 误区:快速失败会取消运行中任务。 它只影响聚合 Promise 和后续启动,取消要靠任务自身支持。
- 追问:同步 throw 怎么处理? 用
Promise.resolve().then(task)包住,让错误进入 reject 分支。 - 追问:结果为什么按输入顺序? 调用方需要稳定映射回原任务,下标不能随完成顺序变化。
- 追问:空任务数组返回什么? 返回 fulfilled 的空数组。
八、加强记忆
并发限制可以记成“水池模型”:队列是待放水的桶,running 是池子里正在流的管,limit 是同时打开的最大水龙头数。每结束一个任务就腾出一个槽位,再补启动下一个任务。真正写代码时守住三件事:任务函数延迟启动、运行数不超过上限、结果按输入下标回填。