【JS】一个思路搞定三道Promise并发编程题,手摸手教你实现一个Promise限制器
壹 ❀ 引
之前在整理手写Promise
相关资料时,在文章推荐区碰巧看到了一道手写Promise
并发控制调度器的笔试题(大厂可能爱考),结果今天同事又正好问了我一个关于Promise
调度处理的场景问题,这就让我瞬间想起了前面看的题,出于兴趣我也抽时间尝试实现了下,外加上几道相关的题统一做个整理,本文开始!!
贰 ❀ 题一
我们假定有一个请求request
与一个限制最多同时3
个请求的调度器,要求实现一次最多处理limit
个数的请求:
// 假设请求API为
function request(params) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(params), 1000);
});
}
// 最多处理3个请求的调度器
function Scheduler(limit=3){
// ...
};
const createPromise = Scheduler();
createPromise(1).then((res) => console.log(res));
createPromise(2).then((res) => console.log(res));
createPromise(3).then((res) => console.log(res));
createPromise(4).then((res) => console.log(res));
createPromise(5).then((res) => console.log(res));
// 预期,等1秒后输出1 2 3 ,再等一秒输出4 5
比如上述代码limit
是3,因此最终输出效果为,等待1s后输出1 2 3
,之后又过一秒输出4 5
。
我们先大致分析下思路,首先Scheduler
返回了一个方法createPromise
,我们能通过createPromise
直接创建Promise
,那么在Scheduler
内部一定得返回一个方法可用于生成Promise
。
其次,createPromise
被调用多次,但请求是分批次处理的,那我们可以在Scheduler
内创建一个数组用于存储createPromise
的参数。另外,因为有limit
限制最多处理3
个请求,我们还需要一个变量用于记录当前已经在处理的请求数。其它先别管,先搭建模板:
// 最多处理3个请求的调度器
function Scheduler(limit = 3) {
const pending = [];
let count = 0;
// 返回一个创建请求的方法
return function (param) {
//内部返回一个promise
return new Promise((resolve, reject) => {});
};
}
到这里,起码createPromise(1)
能得到一个Promise
,并顺利调用.then()
方法了。在手写Promise
一文中,我们知道new Promise(fn)
其实是会将fn
传入到Promise
同步调用,因此我们可以在fn
内部进行调用请求相关参数的存储操作。
另外,已知有请求接口request
,很明显还没有地方做这个请求处理,而且有趣的是题目要求一次最多处理3个请求
,那么我们要时时感知请求数量的变化,比如现在只有2个请求了,可以再加入一个请求,用递归肯定是合理的,考虑到方便递归,我们将request
的相关操作以及limit
判断封装在一个方法run
中,再次补全代码:
function Scheduler(limit = 3) {
const pending = [];
let count = 0;
// 处理request以及limit判断的地方
const run = () => {
// 数组为空吗?超出limit限制了吗?
if (!pending.length || count >= limit) return;
// 依次取出之前存储的参数
const param = pending.shift();
count++;
request(param).finally(() => {
count--;
// 递归,继续判断能不能执行下一个request
run();
});
};
// 返回一个创建请求的方法
return function (param) {
//内部返回一个promise
return new Promise((resolve, reject) => {
// 存储数据
pending.push(param);
// 开始请求
run();
});
};
}
以上代码其实已经满足了请求的调度限制,但我们希望createPromise(1)
创建的Promise
能感知状态变化,所以再次修改,将创建Promise
处的resolve reject
也作为参数存储起来,如下:
// 这里只贴需要修改的代码
request(param)
.then((res) => resolve(res))
.catch((err) => reject(err))
.finally(() => {
count--;
// 递归,继续判断能不能执行下一个request
run();
});
//内部返回一个promise
return new Promise((resolve, reject) => {
// 存储数据
pending.push([param, resolve, reject]);
});
而.then((res) => resolve(res))
其实可以简写成.then(resolve)
,所以最终代码为:
function Scheduler(limit = 3) {
const pending = [];
let count = 0;
// 处理request以及limit判断的地方
const run = () => {
// 数组为空吗?超出limit限制了吗?
if (!pending.length || count >= limit) return;
// 依次取出之前存储的参数
const [param, resolve, reject] = pending.shift();
count++;
request(param)
.then(resolve)
.catch(reject)
.finally(() => {
count--;
// 递归,继续判断能不能执行下一个request
run();
});
};
// 返回一个创建请求的方法
return function (param) {
//内部返回一个promise
return new Promise((resolve, reject) => {
// 存储数据
pending.push([param, resolve, reject]);
// 开始请求
run();
});
};
}
运行代码,可以看到已符合预期:
假设我们将limit
修改为2,效果如下
叁 ❀ 题二
这一道题的要求与上者类似,实现一个限制并发的异步调度器Scheduler
,保证同时运行的任务最多2个,完善如下代码,使程序能正常输出:
class Scheduler {
add(promiseCreator) { ... }
// ...
}
const timeout = time => new Promise(resolve => {
setTimeout(resolve, time);
})
const scheduler = new Scheduler();
const addTask = (time,order) => {
scheduler.add(() => timeout(time).then(()=>console.log(order)))
}
addTask(1000, '1');
addTask(500, '2');
addTask(300, '3');
addTask(400, '4');
// output: 2 3 1 4
我们先分析题意,首先timeout
方法调用会得到一个Promise
,我们会通过调用addTask
来模拟请求行为,而方法内部核心调用的其实是scheduler.add(Promise)
。看样子我们还是能跟上题一样定一个存储Promise
的数组pending
,以及当前请求的数量count
。
让我们再看addTask
执行的输出顺序,因为addTask
调用是同步行为(微任务要走也得先把四次addTask
跑完),所以我们可以理解为当第一个Promise
准备开始执行时,pending
中已经存储过4个Promise
了,我们将四个Promise
按它们的值来命名为P1、P2、P3、P4
,现在开始模拟这个过程:
-
由于限制器为2,所以一开始
P1 P2
就进入准备状态了,由于定时器的缘故,500ms更快P2
先执行,因此先输出2。 -
因为释放了
P2
,于是P3
被通知也可以准备运行了,它的时间是300ms,P1
看着是1000ms但其实已经过了500ms,但依旧比不过300ms,所以紧接着输出3,注意,此时再过200msP1
就可以执行了。 -
限制再次被放开,
P4
也开始执行,前面说了此时的P1
仅需200ms就能执行,而P4
需要400ms,因此先输出1,最后输出4。
那么问题来了,我们需要去手动控制或者说判断这些Promise
之间的时间差,然后决定谁应该先执行吗?很明显不需要,我们要做的就是保证永远有2个Promise
在等待执行,至于时间的差异,定时器会公平的到点修改Promise
状态,并不需要人为干预。
那么大概思路了解了,开始补全代码,思路与上一道题完全一致:
class Scheduler {
constructor() {
// 记录promise的数组
this.pending = [];
// 限制器
this.limit = 2;
// 记录当前已被启动的promise
this.count = 0;
}
add(promiseCreator) {
// 单纯存储promise
this.pending.push(promiseCreator);
// 启动执行,至于能不能走run内部会控制
this.run();
}
run() {
// 假设pending为空,或者调用大于限制直接返回
if (!this.pending.length || this.count >= this.limit) {
return;
}
this.count++;
this.pending
.shift()()
.finally(() => {
this.count--;
// 轮询
this.run();
});
}
}
运行代码,已经能正确输出2 3 1 4
,而且思路与题目一完全一致。不知道你发现没,第二题其实与第一题一模一样,所谓定时器时间不同只是一个幌子,咱们只用控制好执行的数量,至于顺序定时器自己会帮我们安排好。
肆 ❀ 题三
题三同样是限制器问题,不过这次参数是直接传入了一个数组,要求同样是是并发最多同时处理3个请求,但要求如果全部成功则返回结果的数组,且结果顺序与参数顺序保持一致,如果失败则直接返回失败,是不是有点Promise.all()
的意思了:
// 假设请求API为
function request(params) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(params), 1000);
});
}
// 最多处理3个请求的调度器
function Scheduler(list=[], limit=3){
// ...
};
Scheduler([1,2,3,4,5]).then(console.log); // 1 2 3 4 5
我们简单分析下需求,首先Scheduler
能调用.then
,那么内部一定得返回一个Promise
;其次,前两题是调一次咱们存一个参数,然后排队按照限制最大数有序执行,第三题参数直接是一个数组,那咱们完全可以遍历,以此模拟前两题依次执行的行为,然后在内部一样的做个执行限制岂不美哉?
但有两点需要注意,一是成功状态下,我们怎么保证执行结果的顺序与参数一致,第二是跳出递归的条件是什么?大家可以思考下自己尝试写一写,这里我直接上代码:
// 最多处理3个请求的调度器
function Scheduler(list = [], limit = 3) {
let count = 0;
// 用于统计成功的次数
let resLength = 0;
// 浅拷贝一份,原数据的length我们还有用
const pending = [...list];
const resList = [];
// 一定得返回一个promise
return new Promise((resolve, reject) => {
const run = () => {
if (!pending.length || count >= limit) return;
count++;
const index = list.length - pending.length;
const params = pending.shift();
request(params)
.then((res) => {
// 使用输出来验证限制器生效没
console.log('用于验证限制器:', res);
count--;
resLength++;
// 按index来保存结果
resList[index] = res;
// 全部成功了吗?没有就继续请求,否则resolve(resList)跳出递归;
resLength === list.length ? resolve(resList) : run();
})
.catch(reject) // 有一个失败就直接失败
};
// 遍历,模拟前两次依次调用的动作,然后在run内部控制如何执行
list.forEach(() => run());
})
}
Scheduler([1, 2, 3, 4, 5]).then(console.log); // 1 2 3 4 5
注释写的很详细了,这里就不再解释了。可以思考下,为什么index
不直接在forEach
时传递过去呢?接下来说说实现过程中我踩过的坑。
其实一开始我是这么个结构:
// 最多处理3个请求的调度器
function Scheduler(list = [], limit = 3) {
const run = (value,index,resolve,reject) => {
// ...
};
return new Promise(function (resolve, reject) {
// 遍历,模拟前两次依次调用的动作,然后在run内部控制如何执行
list.forEach((value, index) => run(value,index,resolve,reject));
})
}
我们知道循环调用run
的过程是同步的,调用当时我们还能知道index
相关参数,但因为限制的存在,你的这次调用可能并不能立刻执行,而是要依赖后续的递归,而递归时咱们又要从哪获取这四个参数呢?所以想了想还不如直接用一个new Promise
将整个执行包裹,也符合这是一个大Promise
的设定。
第二个坑是,为什么我要加一个resLength
来统计成功的次数,其实一开始我没加这个参数,而跳出递归的判断是这么写的:
// 全部成功了吗?没有就继续请求,否则resolve(resList)跳出递归;
resList.length === list.length ? resolve(resList) : run();
然后在不同时间resolve
的例子测试情况下,出现了[empty, empty, empty, empty, 5]
这样的结果,思考下为什么会出现?
其实很简单,假设前四个请求都要5S,而第五个请求只要1S,那么自然最后一个请求最先结束,因为resList[4] = res;
,这就直接生成了前四个元素是empty
的数组,而empty
是占位的,且此时数组的length
已经是5了,满足条件直接resolve()
导致了最终的错误。
伍 ❀ 总
回到文章开头同事提到的需求,大概意思是有一个批量注册的需求,考虑到批量的数量问题,需要加一个请求限制器,比如一次10个10个处理,同时还需要知道所有注册结束后,哪些成功哪些失败了,而Promise.all()
很明显不能帮我们既拿到成功又拿到失败的数据,而基于题三,我们只用定一个对象,两个数组分别保存成功失败的数据,比如:
// 同样,不管成功还是失败,我们让resLength都自增,满足条件后再统一resolve
// 你可以在最终的结果中判断rejected长度是否大于0,如果大于就表示有失败的,然后就能提示失败了几个,非常完美
const res = {
resolved:[],
rejected:[]
};
有兴趣的同学可以模拟实现下这个需求。然后我抛出一个问题,题三虽然执行结果顺序与参数顺序一致,本质上因为这个执行过程就是有序的,因为大家都是等待1S,那么假设我的请求时间不相等,
比如我们修改request
部分代码为:
const time = [1, 3, 5, 2, 6];
// 假设请求API为
function request(params) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(params), time[Math.floor(Math.random() * 5)] * 1000);
});
}
以上代码还能保证结果的顺序与参数一致吗?为什么会一致?大家可以思考下这个过程,说到这个点其实就与Promise.all
的效果完全一致了,考虑到篇幅问题,我们下一篇文章来模拟实现Promise.all
与Promise.race
,那么到这里本文结束。