听风是风

学或不学,知识都在那里,只增不减。

导航

【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,现在开始模拟这个过程:

  1. 由于限制器为2,所以一开始P1 P2就进入准备状态了,由于定时器的缘故,500ms更快P2先执行,因此先输出2。

  2. 因为释放了P2,于是P3被通知也可以准备运行了,它的时间是300ms,P1看着是1000ms但其实已经过了500ms,但依旧比不过300ms,所以紧接着输出3,注意,此时再过200ms P1就可以执行了。

  3. 限制再次被放开,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.allPromise.race,那么到这里本文结束。

posted on 2022-02-17 23:47  听风是风  阅读(1948)  评论(0编辑  收藏  举报