js 的异步并发控制

一个极端业务场景引发的思考

在业务开发过程中,我们经常会遇到多个异步任务并发执行的情况,待所有异步任务结束之后再执行我们的业务逻辑。
通常情况下,我们会采用 ES6 标准下的Promise.all([promise1, promise2, promise3,...]).then( )方法来应对这样的场景需求,
Promise.all 可以保证,promises 数组中所有 promise 对象都达到 resolve 状态,才执行 then 回调。
这样的 promise 对象可能是你发出的 http 请求,亦或是普通的库表查询操作,看起来平平常常没什么了不起,但是如果这样的 promise 不是一次来个三五个,而是成百上千个地同时出现,不好意思,小船说翻就翻。为什么?因为你在瞬间发出了大量的 http 请求(tcp 连接数不足可能造成等待),或者堆积了无数调用栈导致内存溢出。
这个时候,我们就需要对 Promise.all做出限制了,我们要限制单次并发量,但最终的执行结果还是和常规的 Promise.all保持一致。

并发控制的实现思路

从工具开发者的角度来思考,我们不能限制用户想要执行的异步任务数量,但是我们可以规定单次并发执行的 promise 数量,更进一步讲就是控制 promise 的实例化数量,以规避高并发带来的种种问题。当本次 promise 全部 resolve 或者有单个 promise 最先达到 resolve 状态,再将余下的 promise 依次放入队列。
GitHub 上有不少针对该功能实现的开源项目,并已经发布到 npm 上,比如,async-pool、p-limit 以及功能比较丰富的 es6-promise-pool,在这里我们选取前 2 个项目来进行简要分析。

实现与分析

1、async-pool

asyncPool 提供了两种实现,一种是基于 ES6 标准的 Promise,另外一种是利用 ES7 的 async 函数来实现。

(1)asyncPool-es6.js
/**
 *
 * @param { 并发限制 } poolLimit
 * @param { promise 数组 } array
 * @param { callback } iteratorFn
 */
function asyncPool(poolLimit, array, iteratorFn) {
  let i = 0
  const ret = []
  const executing = []
  const enqueue = function () {
    // ① 边界条件,array 为空或者 promise 都已达到 resolve 状态
    if (i === array.length) {
      return Promise.resolve()
    }
    const item = array[i++]

    // ② 生成一个 promise 实例,并在 then 方法中的 onFullfilled 函数里返回实际要执行的 promise,
    const p = Promise.resolve().then(() => iteratorFn(item, array))
    ret.push(p)

    // ④ 将执行完毕的 promise 移除
    const e = p.then(() => executing.splice(executing.indexOf(e), 1))
    // ③ 将正在执行的 promise 插入 executing 数组
    executing.push(e)

    let r = Promise.resolve()
    // ⑥ 如果正在执行的 promise 数量达到了并发限制,则通过 Promise.race 触发新的 promise 执行
    if (executing.length >= poolLimit) {
      r = Promise.race(executing)
    }

    // ⑤ 递归执行 enqueue,直到满足 ①
    return r.then(() => enqueue())
  }
  return enqueue().then(() => Promise.all(ret))
}

以上代码大致按执行顺序做了注释,总结起来有以下 4 点:

  1. 从 array 第 1 个元素开始,初始化 promise 对象,同时用一个 executing 数组保存正在执行的 promise
  2. 不断初始化 promise,直到达到 poolLimt
  3. 使用 Promise.race,获得 executing 中 promise 的执行情况,当有一个 promise 执行完毕,继续初始化 promise 并放入 executing 中
  4. 所有 promise 都执行完了,调用 Promise.all 返回

demo

import asyncPool from "tiny-async-pool";

const timeout = i => new Promise(resolve => setTimeout(() => resolve(i), i));
return asyncPool(2, [1000, 5000, 3000, 2000], timeout).then(results => {
  ...
});
(2)asyncPool-es7.js

直接上代码:

async function asyncPool(poolLimit, array, iteratorFn) {
  const ret = []
  const executing = []
  for (const item of array) {
    const p = Promise.resolve().then(() => iteratorFn(item, array))
    ret.push(p)
    const e = p.then(() => executing.splice(executing.indexOf(e), 1))
    executing.push(e)
    if (executing.length >= poolLimit) {
      await Promise.race(executing)
    }
  }
  return Promise.all(ret)
}

实现方案和 es6 没有任何区别,但是得益于 async 函数简洁的语法,代码行数和逻辑清晰度上了不止一个台阶。

demo

import asyncPool from 'tiny-async-pool'

const timeout = (i) => new Promise((resolve) => setTimeout(() => resolve(i), i))
const results = await asyncPool(2, [1000, 5000, 3000, 2000], timeout)

2、p-limit

p-limit 包的实现思路可以说和 async-pool 有异曲同工之妙,本质上都是控制 promise 实例化的数量,只是 p-limit 并没有将Promise.all封装进去,也没有使用Promise.race,而是利用 then 方法获得当前 promise 的执行情况,来决定是否将下一个 promise 放入执行队列。

const pLimit = (concurrency) => {
  if (!((Number.isInteger(concurrency) || concurrency === Infinity) && concurrency > 0)) {
    return Promise.reject(new TypeError('Expected `concurrency` to be a number from 1 and up'))
  }

  const queue = []
  let activeCount = 0

  const next = () => {
    activeCount--

    if (queue.length > 0) {
      queue.shift()()
    }
  }

  const run = (fn, resolve, ...args) => {
    activeCount++

    const result = pTry(fn, ...args)

    resolve(result)

    result.then(next, next)
  }

  const enqueue = (fn, resolve, ...args) => {
    if (activeCount < concurrency) {
      run(fn, resolve, ...args)
    } else {
      queue.push(run.bind(null, fn, resolve, ...args))
    }
  }

  const generator = (fn, ...args) => new Promise((resolve) => enqueue(fn, resolve, ...args))

  return generator
}

这里 p-try 是作者的另外一个 npm 包,主要用来实例化 promise 并 resolve 传入函数的执行结果,源码也很简单:

const pTry = (fn, ...arguments_) =>
  new Promise((resolve) => {
    resolve(fn(...arguments_))
  })

p-limit 的使用方式:

const pLimit = require('p-limit')

const limit = pLimit(1)

const input = [limit(() => fetchSomething('foo')), limit(() => fetchSomething('bar')), limit(() => doSomething())]

;(async () => {
  // Only one promise is run at once
  const result = await Promise.all(input)
  console.log(result)
})()

针对 p-limit 的执行过程做一下总结:

  1. 对传入 limit 的函数进行 Promise 实例化
  2. 在 enqueue 内判断是否达到并发限制,如未达到限制,则执行 run 函数,并将所有参数透传给 run;如已达到限制,将用户函数放入暂缓执行队列
  3. 利用 pTry 执行用户函数,并将执行结果 resolve 给外层,同时在 then 方法中获得当前 promise 的执行情况,无论是 resolve 还是 reject 都会执行下一个 promise 的实例化
  4. 当所有 promise 都达到 resolve 状态,调用外层的 Promise.all 返回

总结

所谓 promise 并发限制,其实根源上就是控制 promise 的实例化。如果是通过第三方函数,那么就把创建 promise 的控制权交给第三方即可。
或者,另外一种更简单的思路是,将大量的异步任务分而治之,即每次 Promise.all 一部分任务,待所有异步任务都处理完毕,再将所有执行结果 return 出去。

你可能感兴趣的:(技术分享)