面试官:如何设计一个控制并发数的任务队列?

1,811 阅读4分钟

并发题目

前段时间在看掘金的面试题相关文章的时候,发现一个很有意思的题目,题目如下

设计一个 Task 队列,传入一个数字当做当前最大运行数的限制,队列里可以放若干个 Task(异步,Promise 等),当入队的任务超过限制,就延迟到当前运行的任务运行完再执行。后续还可以将 Task 入队,并且遵循之前的规则

当时这道题就吸引了我的注意力,这题看上去就是非常实用的一道编程题目,我们确实需要这样的一个并发控制器去控制我们发 http 请求的数量,尤其是在一些比较大型的应用,需要一次性发几十上百个请求的时候,建立的 TCP 连接很可能阻塞了其他的资源,因此加上这样一个并发控制,可以让资源得以调度,一个例子就是其实在我们的浏览器当中自带的有这样的请求并发控制的,控制每个页面可以发送的请求数量,让页面不至于卡死。

那么说干就干,下面我就开始了自己的思考

从测试用例推导

首先我先试着从用例推导,试想我们有以下几个异步函数

let [a, b, c, d, e] = (function(){
    const result = []
    for(let i = 0; i < 5; i++) {
        let timeLimit = 1000
        let fn = function () {
            return new Promise((resolve) => {
                setTimeout(() => {
                    console.log(i)
                    resolve(i)
                }, timeLimit)
            })
        }
        result.push(fn)
    }
    return result
}())

当这几个函数作为异步任务调用时,假设有一个函数 taskControll ,它有个两个参数,第一个参数是一个队列,可以传入一个函数队列 [a,b,c,d,e];第二个参数是一个数字,可以指定其作为执行任务的极限

第一版:简易的 TaskControll

说干就干,我们先撸一个简易版的出来 taskControll,他会接受若干任务,并且先执行限量个,再根据 Task 执行情况,在 then 里恢复最大限制,然后继续执行执行 run,在 max

function taskControll(list, max)  {
    const run = () => {
    	while(max) {
        max--
        let task = list.shift()
        task && task().then(() => {
					max++
          run()
        })
      }
    }
    run()
}

taskControll([a,b,c,d], 2)

运行结果: image.png 这样一个简易的并发控制就做好了,能够实现面试题要求的最大运行数限制和运行完再继续执行新任务

第二版:TaskPool 并发池

等等不对,似乎上面的 taskControll 有个缺点,就是必须调用时就把所有的任务传入,假如我想再在别的代码里新加任务是不可行的。

我又思考了一会儿,我们是不是应该构造一个类,来达到一处创建,处处使用呢?因为这个并发控制显然是一个类似池或队列的操作,所以我又有了如下的代码

class TaskPool {
	constructor (list, max) {
  	this.list = list
    this.max = max
    this.run()
  }
  add (task) {
    this.list.push(task)
    this.run()
  }
  run () {
    while(this.max && this.list.length > 0) {
      let task = this.list.shift()
      if (task) {
      	this.max--
        task().then(() => {
          this.max++
          this.run()
        })
      }
    }
  }
}
let taskPool = new TaskPool([
    a,b,c
], 2)
taskPool.run()
taskPool.add(d)
taskPool.add(e)

运行结果: image.pngTask 里加入一个 add 函数,调用时可以往当前的 list 中加入 task。加入 task 的同时还会在加入时运行一遍 run,这里考虑的是防止前面的任务已经完成,后面的任务就无法调用的情况

优化:加入回调

继续思考一下,如果我放入 TaskPool 里的任务完成之后,我该如何通知呢?我们当然可以提前对应每个 Task 里做好回调,但是如果是我们需要当前队列里的任务完成后,统一做什么事情呢?于是就有了下面的解决方案

class TaskPool {
	constructor (list, max, callback) {
  	this.list = list
    this.max = max
    this.len = this.list.length
    this.callback = callback
    this.runIdx = 0
    this.run()
  }
  add (task) {
    this.list.push(task)
    this.len = this.list.length
    this.run()
  }
  run () {
    while(this.max && this.runIdx < this.len) {
      let task = this.list[this.runIdx]
      this.runIdx++
      if (task) {
      	this.max--
        task().then(() => {
          this.max++
          if (this.list.indexOf(task) === this.len - 1) {
            this.callback()
          } else {
          	this.run()
          }
        })
      }
    }
  }
}
function callback() {
	console.log('callback')
}
let taskPool = new TaskPool([
    a,b,c
], 2, callback)
taskPool.run()
taskPool.add(d)
taskPool.add(e)

运行结果: image.png

这里加入的 runIdx 可以让 Task 按顺序执行,add 时会实时改变 this.len ,当最后一个函数执行完毕后就会调用回调

小结

并发控制在前端中属于一个基础设施,大家平时在浏览器中就默认在使用,本文就带大家实现了一个简单的并发池子,通过异步任务检测就可以做到很好的异步控制的效果。如果发现代码中的问题,欢迎大家指正