面试题:手把手教小白实现一个简单的Scheduler限制并发数量

528 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

想知道如何实现一个简单的限制并发数量,首先我们需要知道以下几点

JS如何执行并发请求

如我们所知,JS是单线程的,其工作原理是将每一个任务切分成多个片段然后交给v8引擎去快速交替按照顺序执行这些片段。

一般情况下,在单线程中,所有的任务队列是需要排队,前一个任务执行完毕才会执行下一个,但如果前一个任务执行的时间很长,后面的任务就一直被阻塞一直等待着吗?所以这个时候我们的JS异步就显得尤为重要了。

那么JS是如何实现异步请求的呢?或者说并发请求的? 答案就是消息队列和事件循环啦!!

这里可能又有人要问了,异步就是并发吗?那我们简单解释一下

什么是异步?

所谓异步是指你不需要等待函数调用结果返回, 调用之后可以马上去做另外一件事

什么是并发?

并发是指有两个任务A和B,在一段时间内,通过在任务间切换来完成两个任务,这种情况叫并发。

即可以不同步地执行多个任务, 所谓同步就是执行完这件事, 再处理另外一件事

**可见并发和异步某种程度上有相似的性质, 这是因为异步其实就是并发的一种方式

那么我们再提一嘴和并发经常一起被问区别的并行

什么是并行

和并发不同的是并行是一个微观概念,假设我们的CPU有两个核心,那么我们就可以同时完成A,B两个任务。即可以在同一时间段同时完成多个任务的情况可以称为并行

我们再来简单说说上面的提到的消息队列和事件循环,当然这次我们的重点不是他们,就一句话概括啦~

消息队列

我们知道队列是一个先进先出的结构,而这个队列里面存入的一般是我们上面提到的JS的任务

事件循环

主线程不断重复的从消息队列中取出消息执行,当消息队列为空时,就会等待消息队列中有消息的存在,而主线程只有将当前消息执行完毕后,才会去执行下一个消息。这个过程就叫做事件循环。

代码实现

了解了上面的概念之后,我们就要逐步用代码实现啦

我们首先选择用一个类来实现,那么既然是限制并发,那么构造函数里面必有得一个参数就是limit限制参数,其次我们需要一个队列,来存储我们获取到得任务,这个就定义一个数组来实现啦。

class Scheduler {
  constructor(limit) {
    this.limit = limit;//限制并发数量
    this.queue = [];//存储消息的消息队列
  }
  ……
  }

想一想接下来该干什么,我们既然有了可以存储消息的消息队列了,我们是不是该实现一个消息存储的方法了?

我们借助定时器来帮我们模拟执行时间不同的异步请求,同时为确保我们的是执行完当前任务再执行下一个任务,我们这里需要借助Promise来帮我们来包裹一下异步请求,使我们的返回结果是一个Promise对象,可以调用then

add(time, order) {
     //模拟不同的异步请求任务
    const foo = () => {
      return new Promise((reslove, reject) => {
        setTimeout(() => {
          console.log(order);//注意!!order是为了帮我们查看执行完毕的是插入的第几个任务
          reslove();
        }, time);
      });
    };
    //向队列中插入任务
    this.queue.push(foo);
  }

有了任务队列和任务存储逻辑了,接下来就是事件循环做任务的拿取和执行啦,既然我们要限制并发的数量,那么limit这个参数肯定是要用到的,那么如何控制一次只获取limit个数量的任务呢?那最直接的方式就是循环啦!

  taskStart() {
    for (let i = 0; i < this.limit; i++) {
       ……
    }
  }

这个事件循环并不完整,让我们来思考两个问题

  • 循环里面该做什么呢?我们一次循环了两个异步任务,但并未获取出来执行。
  • 我们两个任务的执行时间完全不同,什么时候进行下一个任务的执行呢?当然是先执行完毕的那个异步任务先获取执行队列中下一个任务啊!

显然这一套逻辑在for循环中实现是不可能的,我们需要另外封装一个请求函数,来帮助我们实行

  taskStart() {
    for (let i = 0; i < this.limit; i++) {
      this.request();//两次循环都去到执行任务的逻辑中
    }
  }
    //请求队列中异步消息执行的函数
  request() {
    if (!this.queue.length) return;//如果消息队列中一个任务都没有了就返回
    let item = this.queue.shift();//从头部拿取第一个任务,先进先出的原则
    item().then(() => {//先执行完的任务,再调取任务队列中下一个任务
      this.request();
    });
  }
}

至此,我们的scheduler类实现完毕了

接下来就是进行调试,创建实例化对象,调用里面的方法看看能不能行

let scheduler = new Scheduler(2);//创建一个实例化对象传入一个参数,限制一次执行2个任务
//定义一个调用插入任务队列的函数,省去了反复写scheduler.add这么一大坨调用示例的麻烦
const addTask = (time, order) => {
  scheduler.add(time, order);
};

addTask(1000, "1");//同等于scheduler.add(1000, "1");
addTask(500, "2");
addTask(300, "3");
addTask(400, "4");

scheduler.taskStart();

看看结果:

image.png

我们来算一算是否对的上 我们先插入队列的是order为1、2 的两个任务,当然就是这两个先执行

       addTask(1000, "1")   
       addTask(500, "2");
       

1的执行时间为1000秒后,而2的执行时间为500毫秒,故先打印2

2执行完毕后立即去获取执行3,addTask(300, "3"),3在300毫秒后执行,500+300=800还是小于1的1000毫秒,所以再打印3

3执行完毕立马又去获取执行任务4,addTask(400, "4"),4在400毫秒后执行,这个时候事件累加成了500+300+400=1200毫秒,1200毫秒大于1000毫秒,所以1应该先执行完毕了,先打印1,再打印4,答案没错!我们成功啦!

以下附上完整代码

class Scheduler {
  constructor(limit) {
    this.limit = limit;
    this.queue = [];
  }

  add(time, order) {
    const foo = () => {
      return new Promise((reslove, reject) => {
        setTimeout(() => {
          console.log(order);
          reslove();
        }, time);
      });
    };
    this.queue.push(foo);
  }

  taskStart() {
    for (let i = 0; i < this.limit; i++) {
      this.request();
    }
  }

  request() {
    if (!this.queue.length) return;
    let item = this.queue.shift();
    item().then(() => {
      this.request();
    });
  }
}

let scheduler = new Scheduler(2);

const addTask = (time, order) => {
  scheduler.add(time, order);
};

addTask(1000, "1");
addTask(500, "2");
addTask(300, "3");
addTask(400, "4");

scheduler.taskStart();

// 2314