现在面试过程中,手写题是必不可少的环节,其中“请求并发控制”更是高频考点。这道题主要考察对异步编程的理解。
题目
// 设计一个函数,可以限制请求的并发,同时请求结束之后,调用callback函数
// sendRequest(requestList:,limits,callback):void
sendRequest(
[()=>request('1'),
()=>request('2'),
()=>request('3'),
()=>request('4')],
3, // 并发数
(res)=>{
console.log(res)
})
// 其中 request 可以是:
function request (url, time = 1) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('请求结束:' + url);
if (Math.random() > 0.5) {
resolve('成功')
} else {
reject('错误;')
}
}, time * 1e3)
})
}
明确概念
⚠️ 首先需要明确几个关键概念:
- 并发:指多个任务在宏观上看起来是同时执行的。由于 CPU 执行指令速度极快,它可以在多个任务之间快速切换执行,因此并非真正的“同时”,而是交替执行。
- 并行:指在多个物理 CPU 或分布式系统中,任务被真正地同时执行。
- 并发控制:指系统中有多个并发任务,一旦某个任务完成,就立即开始执行队列中的下一个任务,以保持指定的最大并发数。
- 切片控制:将总任务分成若干批次(切片),一个批次完全执行完毕后,再开始执行下一个批次。这种方式的效率通常低于并发控制。
实现思路
核心思路是:首先启动允许的最大并发数任务,之后每当一个任务执行完毕,就从待执行队列中“捞起”下一个任务继续执行,直到所有任务完成。
我们可以将这个过程拆分为几个关键步骤:
- 初始化并启动第一批并发任务。
- 定义一个执行器,用于执行单个任务并更新状态。
- 任务完成后,触发一个“捞起”逻辑,判断是否还有剩余任务或执行最后的回调。
实现方案一:通过计数器管理
这种方案通过维护一个当前并发计数器来控制流程。
function sendRequest(requestList, limits, callback) {
const promises = requestList.slice() // 取得请求list(浅拷贝一份)
// 得到开始时,能执行的并发数
const concurrentNum = Math.min(limits, requestList.length)
let concurrentCount = 0 // 当前并发数
// 第一次先跑起可以并发的任务
const runTaskNeeded = () => {
let i = 0
// 启动当前能执行的任务
while (i < concurrentNum) {
i++
runTask()
}
}
// 取出任务并且执行任务
const runTask = () => {
const task = promises.shift()
task && runner(task)
}
// 执行器
// 执行任务,同时更新当前并发数
const runner = async (task) => {
try {
concurrentCount++
await task()
} catch (error) {
// 错误处理,可根据需要扩展,例如重试逻辑
} finally {
// 并发数--
concurrentCount--
// 捞起下一个任务
picker()
}
}
// 捞起下一个任务
const picker = () => {
// 任务队列里还有任务并且此时还有剩余并发数的时候 执行
if (concurrentCount < limits && promises.length > 0) {
// 继续执行任务
runTask()
// 队列为空的时候,并且请求池清空了,就可以执行最后的回调函数了
} else if (promises.length == 0 && concurrentCount == 0) {
// 执行结束
callback && callback()
}
}
// 入口执行
runTaskNeeded()
}
实现方案二:利用 Promise.race 和 Set 管理
这种方案的核心是利用 await Promise.race(pool) 来等待当前正在执行的任何任务完成。
async function sendRequest(requestList, limits, callback) {
// 维护一个promise队列,用于最后等待所有任务
const promises = []
// 当前的并发池,用Set结构方便删除
const pool = new Set() // set也是Iterable<any>[]类型,因此可以放入到race里
// 开始并发执行所有的任务
for (let request of requestList) {
// 开始执行前,先await判断当前的并发任务是否超过限制
if (pool.size >= limits) {
// 这里因为没有try catch ,所以要捕获一下错误,不然影响下面微任务的执行
await Promise.race(pool).catch(err => err)
}
const promise = request() // 拿到promise
// 删除请求结束后,从pool里面移除
const cb = () => {
pool.delete(promise)
}
// 注册下then的任务
promise.then(cb, cb)
pool.add(promise)
promises.push(promise)
}
// 等最后一个for await 结束,这里是属于最后一个await后面的微任务
// 注意这里其实是在微任务当中了,当前的promises里面是能确保所有的promise都在其中(前提是await那里命中了if)
Promise.allSettled(promises).then(callback, callback)
}
方案二要点总结
- 利用
race 的特性:Promise.race(pool) 会等待并发池中最快完成的任务(无论是成功还是失败),从而释放出一个并发“空位”。
- 利用
for await:for...of 循环结合 await 确保了在并发数达到上限时,代码会暂停,等待空位。循环结束后的 Promise.allSettled 会在所有任务(包括最后一批)状态都确定后调用回调。
- 动态并发池:使用
Set 结构存储当前正在执行的 Promise。通过闭包引用,在每个 Promise 完成时(无论成功/失败)从池中删除自身,实现池大小的动态管理。
- 执行流程:可以想象成一场接力赛。
for 循环负责把选手(请求)送到起跑线。如果跑道(并发池)满了,就等最快的那个选手跑完一个赛段(await race)空出跑道,下一位选手立刻接上。allSettled 则在终点线等待所有选手完成比赛。

功能拓展思考
- 获取结果:第二种方案已将每个请求的
Promise 存入 promises 数组,最终通过 allSettled 可以拿到所有结果。第一种方案可以增加一个 Map 或数组来收集每个任务执行后的结果。
- 失败重试:可以在执行器(
runner)或请求包装层增加重试逻辑,例如在 catch 块中判断重试次数并重新执行任务。
总结
这类题目是前端面试中考察异步编程功底的经典问题,深入理解 JavaScript 的事件循环和 Promise 工作机制是解答的关键。无论是准备前端面试还是在实际的前端项目中处理如文件分片上传、大量接口轮询等场景,掌握并发控制都十分重要。
希望这两种实现思路能帮助你彻底理解 Promise 并发控制的原理。如果你有更巧妙的实现或有其他面试难题,欢迎在技术社区交流讨论。
|