本文详解一道关于Promise并发控制的经典面试题,要求在限制同时下载链接数量的前提下快速完成图片加载。文章梳理了三种解决方案:基于递归调用的常规思路、拆分计数与调度函数的优化版,以及利用async/await和Promise.race实现的巧妙方案,帮助开发者深入理解异步并发处理。
前言
撰写此文时我曾有所犹豫,因为此前已发表过一篇涉及并发控制的类似文章,当时仅提供了一种解决思路。随后我在网络上又发现了另外两种可行的方案。反思过往,自己研究问题往往流于表面,未能深入。因此,今天决定将这三种方法整合在一起,借由这道经典的面试题重新进行梳理和总结。
题目是这样的:
假设存在一个包含 8 个图片资源 URL 的数组 urls(例如 urls = ['http://example.com/1.jpg', …., 'http://example.com/8.jpg']),并且已经定义了一个函数 loadImg。该函数接收一个 URL 作为输入,并返回一个 Promise 对象。当图片下载成功时,Promise 执行 resolve;若下载失败,则执行 reject。
核心约束条件是:在任意时刻,同时发起的请求数量不得超过 3 个。
请编写代码实现上述需求,目标是尽可能快地完成所有图片的下载任务。
已有代码如下:
var urls = [
'https://www.kkkk1000.com/images/getImgData/getImgDatadata.jpg',
'https://www.kkkk1000.com/images/getImgData/gray.gif',
'https://www.kkkk1000.com/images/getImgData/Particle.gif',
'https://www.kkkk1000.com/images/getImgData/arithmetic.png',
'https://www.kkkk1000.com/images/getImgData/arithmetic2.gif',
'https://www.kkkk1000.com/images/getImgData/getImgDataError.jpg',
'https://www.kkkk1000.com/images/getImgData/arithmetic.gif',
'https://www.kkkk1000.com/images/wxQrCode2.png'
];
function loadImg(url) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = function () {
console.log('一张图片加载完成');
resolve();
}
img.onerror = reject
img.src = url
})
};
面对这道题目,我脑海中立刻浮现出高效排队购买地铁票的场景,其逻辑与下图所示情景相似:

这种排队机制与并发请求的场景高度一致:服务窗口仅有三个,当排队人数超过窗口数量时,后续人员必须等待。
首先想到的解决方案是利用递归来实现,正如前文所采用的方法,具体代码如下:
//省略代码
var count = 0;
//对加载图片的函数做处理,计数器叠加计数
function bao(){
count++;
console.log("并发数:",count)
//条件判断,urls长度大于0继续,小于等于零说明图片加载完成
if(urls.length>0&&count<=3){
//shift从数组中取出连接
loadImg(urls.shift()).then(()=>{
//计数器递减
count--
//递归调用
}).then(bao)
}
}
function async1(){
//循环开启三次
for(var i=0;i<3;i++){
bao();
}
}
async1()
这是最常规的思路。我将加载图片的函数 loadImg 封装在 bao 函数内部,通过条件判断决定是否发送请求。请求完成后,继续递归调用该函数。
上述代码将所有逻辑集中在一个函数中进行递归调用,我们可以对其进行优化,代码如下:
var count = 0;
// 封装请求的异步函数,增加计数器功能
function request(){
count++;
loadImg(urls.shift()).then(()=>{
count--
}).then(diaodu)
}
// 负责调度的函数
function diaodu(){
if(urls.length>0&&count<=3){
request();
}
}
function async1(){
for(var i=0;i<3;i++){
request();
}
}
async1()
优化后的代码将一个递归函数拆分为两个部分:一个函数专门负责计数和发送请求,另一个函数负责调度逻辑。
既然请求已经被封装为 Promise,我们可以尝试使用 Promise 结合 async/await 来完成这一任务,代码如下:
//省略代码
// 计数器
var count = 0;
// 全局锁
var lock = [];
var l = urls.length;
async function bao(){
if(count>=3){
//超过限制利用await和promise进行阻塞;
let _resolve;
await new Promise((resolve,reject)=>{
_resolve=resolve;
// resolve不执行,将其推入lock数组;
lock.push(_resolve);
});
}
if(urls.length>0){
console.log(count);
count++
await loadImg(urls.shift());
count--;
lock.length&&lock.shift()()
}
}
for (let i = 0; i < l; i++) {
bao();
}
大致思路是:遍历执行 urls.length 长度的请求。当并发数超过限制时,超出的请求通过 await 结合 Promise 进行阻塞处理,并将 resolve 填充到 lock 数组中继续执行。在并发过程中,一旦有图片加载完成,就从 lock 中取出一项 resolve 执行。lock 数组在此处相当于一个“叫号机”。
以上代码还可以进一步优化为:
// 计数器
var count = 0;
// 全局锁
var lock = [];
var l = urls.length;
// 阻塞函数
function block(){
let _resolve;
return new Promise((resolve,reject)=>{
_resolve=resolve;
// resolve不执行,将其推入lock数组;
lock.push(_resolve);
});
}
// 叫号机
function next(){
lock.length&&lock.shift()()
}
async function bao(){
if(count>=3){
//超过限制利用await和promise进行阻塞;
await block();
}
if(urls.length>0){
console.log(count);
count++
await loadImg(urls.shift());
count--;
next()
}
}
for (let i = 0; i < l; i++) {
bao();
}
这是最后一种方案,也是我个人非常喜欢的方案。经过长时间的思考才完全理解,其大致思路如下:
利用 Promise.race 来实现。首先并发请求 3 个图片资源,从而得到 3 个 Promise 实例,组成一个数组 promises。然后不断调用 Promise.race 来返回最快改变状态的 Promise,并从数组(promises)中删除该 Promise 对象实例,同时加入一个新的 Promise 实例,直到所有的 URL 都被处理完毕。
代码如下:
//省略代码
function limitLoad(urls, handler, limit) {
// 对数组做一个拷贝
const sequence = [].concat(urls)
let promises = [];
//并发请求到最大数
promises = sequence.splice(0, limit).map((url, index) => {
// 这里返回的 index 是任务在 promises 的脚标,
//用于在 Promise.race 之后找到完成的任务脚标
return handler(url).then(() => {
return index
});
});
(async function loop() {
let p = Promise.race(promises);
for (let i = 0; i < sequence.length; i++) {
p = p.then((res) => {
promises[res] = handler(sequence[i]).then(() => {
return res
});
return Promise.race(promises)
})
}
})()
}
limitLoad(urls, loadImg, 3)
第三种方案的巧妙之处在于使用了 Promise.race。并且在循环时用 then 链串起了执行顺序。
以上便是关于并发控制的一点点思考,有使用promise的,有不使用promise的,关键在于灵活运用,通过这次梳理,你有哪些思考呢
总结
到此这篇关于Promise面试题详解之控制并发的文章就介绍到这了,更多相关Promise控制并发内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!