一个极端业务场景引发的思考
在业务开发过程中,我们经常会遇到多个异步任务并发执行的情况,待所有异步任务结束之后再执行我们的业务逻辑。
通常情况下,我们会采用 ES6 标准下的
这样的 promise 对象可能是你发出的 http 请求,亦或是普通的库表查询操作,看起来平平常常没什么了不起,但是如果这样的 promise 不是一次来个三五个,而是成百上千个地同时出现,不好意思,小船说翻就翻。为什么?因为你在瞬间发出了大量的 http 请求(tcp 连接数不足可能造成等待),或者堆积了无数调用栈导致内存溢出。
这个时候,我们就需要对
并发控制的实现思路
从工具开发者的角度来思考,我们不能限制用户想要执行的异步任务数量,但是我们可以规定单次并发执行的 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | /** * * @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 点:
- 从 array 第 1 个元素开始,初始化 promise 对象,同时用一个 executing 数组保存正在执行的 promise
- 不断初始化 promise,直到达到 poolLimt
- 使用 Promise.race,获得 executing 中 promise 的执行情况,当有一个 promise 执行完毕,继续初始化 promise 并放入 executing 中
- 所有 promise 都执行完了,调用 Promise.all 返回
demo
1 2 3 4 5 6 | 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
直接上代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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
1 2 3 4 | 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 并没有将
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | 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 传入函数的执行结果,源码也很简单:
1 2 3 4 | const pTry = (fn, ...arguments_) => new Promise((resolve) => { resolve(fn(...arguments_)) }) |
p-limit 的使用方式:
1 2 3 4 5 6 7 8 9 10 11 | 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 的执行过程做一下总结:
- 对传入 limit 的函数进行 Promise 实例化
- 在 enqueue 内判断是否达到并发限制,如未达到限制,则执行 run 函数,并将所有参数透传给 run;如已达到限制,将用户函数放入暂缓执行队列
- 利用 pTry 执行用户函数,并将执行结果 resolve 给外层,同时在 then 方法中获得当前 promise 的执行情况,无论是 resolve 还是 reject 都会执行下一个 promise 的实例化
- 当所有 promise 都达到 resolve 状态,调用外层的
Promise.all 返回
总结
所谓 promise 并发限制,其实根源上就是控制 promise 的实例化。如果是通过第三方函数,那么就把创建 promise 的控制权交给第三方即可。
或者,另外一种更简单的思路是,将大量的异步任务分而治之,即每次