一个异步任务队列库的设计与实现

518 阅读4分钟

第一版设计与实现

实际需求

浏览器插件在A平台页面利用A页面接口发起近80个请求获取数据,类似爬虫功能,A页面对此接口做了访问限制,导致并发发起80个请求很容易出现请求失败,从根本上我们不能完全避免请求失败的情况发生,但我们可以通过降低并发请求量来尽可能的减少被限制的发生,因此我们需要做一个基于Promise请求队列的模块。

最初设计

  1. 将80个请求放入数组中,一批请求完毕wait一定时间继续执行下一批请求

  2. 根据第一点,我们需要设置这个队列每批请求的并发量和每批请求之间的wait时间

最初实现

基于以上思路,我们可以很快实现这个需求

// 模拟异步请求
function asyncTask() {
  return new Promise(r => {
    setTimeout(() => {
      r(1)
    }, 100);
  })
}

const queue = []

// 生成30个异步请求,放到数组中
for(let i = 0; i < 30; i++) {
  queue.push(asyncTask)
}

// 每批请求之间的等待函数
async function wait(time) {
  return new Promise(r => {
    setTimeout(() => {
      r()
    }, time);
  })
}

以上是一些辅助函数和请求数据源的设置,接下来我们实现队列的核心模块

/**
 * @param {*} max          每批请求最大量
 * @param {*} interval     每批请求间隔等待时间
 * @returns
 */
function requestQueue(max, interval) {
  // 要返回一个promise,promise更改状态意味着整个队列执行完毕,暂时不考虑队列出错情况
  return new Promise(r => {
    const len = queue.length;
    const result = [];

    // 执行任务循环体
    function executeTask(index = 0) {
      const rest = len - index;
      const executeArr = [];

      // 为不影响数据源,使用executeArr来维护当前执行数据
      for(let i = 0; i < (max > rest ? rest : max); i++) {
        executeArr.push(queue[index + i]());
      }
      
      // 每批请求使用Promise.all
      Promise.all(executeArr).then(async res => {
        console.log(res);
        result.push(...res);
        index += (max > rest ? rest : max)
        
        // index === len表明队列执行完毕,可以更改外层promise状态,若没处理完毕,则执行下一批请求
        if(index === len) {
          r(result)
        }else {
          // 等待一定时间
          await wait(interval)
          // 继续执行下一批请求
          executeTask(index)
        }
      })
    }

    // 最初执行
    executeTask()
  })
}

简单说一下这里核心部分:

  • requestQueue返回一个promise
  • 明确队列是否执行完成的边界情况,这里我维护了一个index变量,当然还有别的实现方式
  • 每批请求使用Promise.all处理

至此,我们可以愉快的调用这个方法,同时也满足了我们最初的设计需求

// 每批请求4个并发量,每批请求间隔1000ms
requestQueue(4, 1000).then(res => {
  console.log(res);
})

第二版设计与实现

实际需求

第一版实际上已经能够对付大多数的请求队列场景,但我们发现,每批请求中如果有一个请求响应非常慢,则会造成当前这批请求的阻塞,从而阻塞了整个队列。

改进设计

  1. 每批请求的颗粒度还是太大,我们将队列的颗粒度精确到每个请求,相当于每个请求独立,单个请求阻塞不会影响其他请求

我们通过两张图来看看现状和目标:

现状
现在如果子任务1耗时非常久,但子任务2和3已完成,完全可以继续执行接下来的任务
现状

改进实现

// 改变模拟异步请求函数,为的是能让任务耗时不同
function asyncTask(time) {
  return function() {
    return new Promise(r => {
      setTimeout(() => {
        r(time)
      }, time);
    })
  }
}

const queue = []

// 先放入耗时5000ms的异步请求
queue.push(asyncTask(5000))

// 生成5个耗时100ms异步请求,放到数组中
for(let i = 0; i < 5; i++) {
  queue.push(asyncTask(100))
}

对辅助方法做了一些改变,接着再来改变核心模块

function requestQueue(max, interval) {
  return new Promise(r => {
    const len = queue.length;
    const result = [];
    let index = 0;
    let hasFinishedCount = 0;

    function run() {
      let start = 0;
      for(let i = 0; i < (max > len ? len : max); i++) {
        executeTask(queue[start + i], start + i)
        index += start + i
      }
    }

    function executeTask(task, resultIndex) {
      task().then(async res => {
        console.log(res);
        // 按照源数组位置放入结果数组中
        result[resultIndex] = res;
        hasFinishedCount++
        // 判断队列是否完成
        if(hasFinishedCount === len) {
          r(result)
        }else {
          // 先检查一遍还有没有待执行的任务
          if(index < len - 1) {
            // 等待一段时间
            await wait(interval);
            // 等待一段时间后,还需要再检查一遍队列中有没有待执行的任务
            if(index < len - 1) {
              executeTask(queue[++index], index)
            }
          } 
        }
      })
    }
    
    run()
  })
}
  • 接收的参数还是一样,但这里不再使用Promise.all,每个请求独立执行,同时每个请求都能执行下一个请求

  • 关键还是边界情况,维护了一个hasFinishedCount变量

此时设置并发数为2,会发现第一个耗时为5000ms的任务并不会影响其他任务的执行,提高了整体的执行效率,可以和第一版做对比

// 每批请求2个并发量,每批请求间隔1000ms
requestQueue(2, 1000).then(res => {
  console.log(res);
})

结束

到这里我们就完成了异步任务队列的核心模块,当然我个人封装的库在这基础上添加了许多功能:

  • 引入了状态机制,队列可以暂停、继续以及清空
  • 队列可以容错,即一个任务报错不会结束队列执行
  • 可以设置任务优先级
  • 可以设置每个任务的完成回调
  • 更好的模块封装方便使用的api

如果有兴趣,可以去github看看,整体代码也只有200行。若对你有帮助,欢迎star!!!有任何问题,请提issue一起讨论