场景
假设场景:需要发送100个请求,每个请求的时间不固定。
模拟下 100 个请求
// 请求列表
const requestList = [];
for (let i = 1; i <= 100; i++) {
requestList.push(
() =>
new Promise(resolve => {
setTimeout(() => {
console.log('resolve', i);
resolve(i);
}, Math.random() * 1000);
}),
);
}
Promise.all
将 5 个请求分为一组,一组接着一组串行发送。这个“5”是怎么怎么得出来的呢,chrome默认限制同时最多开启 6 条TCP链接
,所以将其分为 5 条一组,留 1 条处理别的请求。
const parallelRun = async max => {
const requestSliceList = [];
for (let i = 0; i < requestList.length; i += max) {
requestSliceList.push(requestList.slice(i, i + max));
}
for (let i = 0; i < requestSliceList.length; i++) {
const group = requestSliceList[i];
try {
const res = await Promise.all(group.map(fn => fn()));
console.log('res>>>', res);
} catch (error) {
console.error(error);
}
}
};
问题: 修改下模拟请求,使其随机产生一个错误,修改如下:
// 请求列表
const requestList = [];
for (let i = 1; i <= 100; i++) {
requestList.push(
() =>
new Promise((resolve, reject) => {
setTimeout(() => {
if (i === 92) {
reject(new Error('error>>>' + i));
} else {
console.log('resolve', i);
resolve(i);
}
}, Math.random() * 1000);
}),
);
}
控制台看下运行结果:有一个请求失败了,这个 Promise.all就失败了,没有返回值一组中一个请求失败就无法获取改组其他成员的返回值,这对于不需要判断返回值的情况倒是可以,但是实际业务中,返回值是一个很重要的数据我们可以接受某个接口失败了没有返回值,但是无法接受一个请求失败了,跟它同组的其他 9 个请求也没有返回值既然,失败的请求会打断 Promise.all。
Promise.allSettled
先来看下权威的 MDN 的介绍
Promise.allSettled() 方法是 promise 并发方法之一。在你有多个不依赖于彼此成功完成的异步任务时,或者你总是想知道每个 promise 的结果时
,使用 Promise.allSettled()
简单说就是:每个请求都会返回结果,不管失败还是成功使用 Promise.allSettled()替换下 Promise.allSettled() :
const parallelRun = async max => {
const requestSliceList = [];
for (let i = 0; i < requestList.length; i += max) {
requestSliceList.push(requestList.slice(i, i + max));
}
for (let i = 0; i < requestSliceList.length; i++) {
const group = requestSliceList[i];
try {
// 使用 allSettled 替换 all
const res = await Promise.allSettled(group.map(fn => fn()));
console.log('res>>>', res);
} catch (error) {
console.error(error);
}
}
};
问题:有一个请求非常耗时,那组的请求返回就会很慢,会阻塞了后续的接口并发。面试官:有没有什么方法可以解决这个问题。
解决方案
分析问题
使用 Promise.all()或是 Promise.allSettled(),每次并发 5 个请求,确实可以满足并发要求,但是效率较低:如果存在一个或多个慢接口,那么会出现以下两个问题:
- 有慢接口的并发组返回会很慢,一个慢接口拖慢了其他 接口,得不偿失;
- 本来我们是可以并发 5 个请求的,但是一个慢接口导致该组的其他并发位置都被浪费了,这会导致这 100 个接口的并发时间被无情拉长。
解决方法
可以维护一个运行池和一个等待队列,运行池始终保持 5 个请求并发,当运行池中有一个请求完成时,就从等待队列中拿出一个新请求放到运行池中运行,这样就可以保持运行池始终是满负荷运行,即使有一个慢接口,也不会阻塞后续的接口入池。
// 运行池
const pool = new Set();
// 等待队列
const waitQueue = [];
/**
* @description: 限制并发数量的请求
* @param {*} reqFn:请求方法
* @param {*} max:最大并发数
*/
const request = (reqFn, max) => {
return new Promise((resolve, reject) => {
// 判断运行吃是否已满
const isFull = pool.size >= max;
// 包装的新请求
const newReqFn = () => {
reqFn()
.then(res => {
resolve(res);
})
.catch(err => {
reject(err);
})
.finally(() => {
// 请求完成后,将该请求从运行池中删除
pool.delete(newReqFn);
// 从等待队列中取出一个新请求放入等待运行池执行
const next = waitQueue.shift();
if (next) {
pool.add(next);
next();
}
});
};
if (isFull) {
// 如果运行池已满,则将新的请求放到等待队列中
waitQueue.push(newReqFn);
} else {
// 如果运行池未满,则向运行池中添加一个新请求并执行该请求
pool.add(newReqFn);
newReqFn();
}
});
};
requestList.forEach(async item => {
const res = await request(item, 5);
console.log(res);
});
其他优秀库
社区已有很多优秀的并发限制库,这里重点介绍下 p-limit安装:
npm install p-limit -S
使用方法:
import plimit from 'p-limit';
const limit = plimit(5);
requestList.forEach(async item => {
const res = await limit(item);
console.log(res);
});
运行效果与上面的队列的运行效果是一致的。
下面看下库源码(精简后):
import Queue from 'yocto-queue';
export default function pLimit(concurrency) {
const queue = new Queue();
let activeCount = 0;
const next = () => {
activeCount--;
if (queue.size > 0) {
queue.dequeue()();
}
};
const run = async (function_, resolve, arguments_) => {
activeCount++;
const result = (async () => function_(...arguments_))();
resolve(result);
try {
await result;
} catch {}
next();
};
const enqueue = (function_, resolve, arguments_) => {
queue.enqueue(run.bind(undefined, function_, resolve, arguments_));
(async () => {
// This function needs to wait until the next microtask before comparing
// `activeCount` to `concurrency`, because `activeCount` is updated asynchronously
// when the run function is dequeued and called. The comparison in the if-statement
// needs to happen asynchronously as well to get an up-to-date value for `activeCount`.
await Promise.resolve();
if (activeCount < concurrency && queue.size > 0) {
queue.dequeue()();
}
})();
};
const generator = (function_, ...arguments_) =>
new Promise(resolve => {
enqueue(function_, resolve, arguments_);
});
return generator;
}
短短 60 行代码就实现了一个功能强大的并发处理库,真是厉害,下面分析下具体实现:
- 首先 p-limit 库默认导出一个函数pLimit,该函数接收一个数字,表示最大并发数
- pLimit函数函数返回一个 generator函数,该函数返回一个 Promise,并且其中调用了 enqueue函数
- enqueue 函数主要是将 run函数加入队列 queue中,之后判断下 activeCount < concurrency && queue.size > 0,表示当前队列大小小于最大并发数且队列不为空,则需要从队列中取出一个请求执行,即执行run函数
- run函数执行时需要先将 activeCount加一,之后执行真正的请求函数 (async () => function_(…arguments_))()
- 之后等待请求完成 await result; 之后执行 next函数
- next函数主要从队列中取出一个新请求执行并将activeCount 减一
总结
本文主要总结了 100 个请求限制并发的方法:
- Promise.all() 最简单的控制并发,但是请求出错会导致该组无返回值
- Promise.allSettled() 解决了Promise.all()的问题,但是却存在慢接口阻塞后续请求,且浪费其余并发位置的问题
- 通过维护一个运行池,当运行池中有请求完成时便从等待队列中取一个心情求入池执行,直到所有的请求都入池
- 介绍了社区的 p-limit库的使用方法和实现原理