如何限制并发数

376 阅读5分钟

1. 使用案例

我们知道Promise.all方法可以实现并行执行,那么,我们就来看一下使用前后的变化。 使用之前:

import delay from 'delay';
import timeSpan from 'time-span';
const end = timeSpan()
const fetchData = async () => {
    await delay(1000) // 延时1s
    return 10
}
const fetchData1 = async() => {
    await delay(2000) // 延时2s
    return 20
}
const fetchData2 = async () => {
    await delay(3000) // 延时3s
    return 30
}
const input = [
    fetchData(),
    fetchData1(),
    fetchData2()
];
const result = await Promise.all(input)
console.log(result); // [ 10, 20, 30 ]
console.log(end()) // 3011.592865 运行的毫秒数

我们执行完代码发现,执行代码用时是约等于3s,说明三个任务基本是并发执行的。

那么,我们现在使用p-limit来限制一下并发数

import pLimit from 'p-limit';
import delay from 'delay';
import timeSpan from 'time-span';
const limit = pLimit(1);
const end = timeSpan()

const input = [
    fetchData,
    fetchData1,
    fetchData2
];

const result = await Promise.all(input.map(item => limit(async () => await item())))
console.log(result); // [ 10, 20, 30 ]
console.log(end()) // 6012.97549 运行的毫秒数

当限制并发数为1时,三个任务相当于串联执行,前一个执行完再执行下一个。这个时候的执行时间为三个任务的时间总和,即约6s。

2. 限制并发

2.1 并发

我们知道代码的执行一般是按顺序执行的,所以同步任务是不存在并发的。但如果是异步任务的话,即使有先后顺序,它的执行的结果也是不受控制的。比如Promise.race()最终返回resolvePromise是不确定的。

举个最常见的例子:我们要控制并发请求数,实际上就是控制在并发数任务在pending的过程中,不再继续发送请求。

比如:我连续发送了10个请求,控制并发数为1。

-如果第一个请求成功,那么剩余的请求不再发送,成功结果作为剩余请求的结果返回。

-如果第一个请求失败,那么继续发送第二个请求,请求成功的话,剩余请求不再发送,成功结果作为剩余请求的结果返回。

-请求失败则依此类推。

2.2 如何控制并发数

包装返回

由p-limit的使用可知: pLimit函数接受并发数作为参数,并返回一个函数A。这个函数A的参数也是一个函数,而执行函数A返回的是一个Promise函数。 那么,PLimit大概长这样:

const PLimit = function(concurrency) {
    return function(fn) {
        return new Promise(resolve => {})
    }
}

接下来就是如何控制异步函数的执行了。

任务分类

首先,我们需要将任务按状态先归类,分别为待执行任务正在执行的任务已执行的任务这三个类别。 为了让任务有顺序的执行,将任务存放在队列中,先进先出。所以,我们使用yocto-queue来模拟队列结构。

  • 在每次调用PLimit包装后的函数时,我们就将任务放到队列中。这就是待执行的任务。
  • 任务需要执行的时候移出队列,也就是正在执行的任务。
  • 任务出队的时机是正在执行的任务数小于传入的并发数并且还有待执行的任务时。
  • 每次调用PLimit包装后的函数时,除了入队操作,检查任务是否符合执行条件。符合条件则立即执行出队操作,否则不做操作

捋清楚思路后就可以上代码了

import Queue from 'yocto-queue';
const PLimit = function(concurrency) {
const queue = new Queue()
let activeCount = 0 // 正在执行的任务数
    return function(fn) {
        return new Promise(resolve => {
            queue.enqueue(fn)
            if(concurrency > activeCount && queue.size > 0) {
                queue.dequeue()
            }
        })
    }
}

执行器

由于只在调用PLimit包装后的函数时判断执行,后续的执行操作就无法继续。那么我们需要一个执行器,包括任务执行、状态更新及流程控制。

  • 每次调用PLimit包装后的函数即检查是否要执行,保证并发的个数
  • 正在执行的任务数是不断变化的,随着任务的执行而变化,任务出队时增加,任务完成后减少。
  • 当一个任务执行结束则可以直接执行下一个任务,保持并发数

这个执行器大概长这样:

const runner = async function(fn) {
    activeCount++ // 任务开始执行 正在执行的任务数+1
    await fn()
    activeCount--
    if(queue.size > 0) {
        queue.dequeue()
    }
}

由于每个任务的执行都会改变任务状态,我们考虑将入队的函数包装成执行器,保证并发的有序执行。

import Queue from 'yocto-queue';
const PLimit = function(concurrency) {
const queue = new Queue()
let activeCount = 0 // 正在执行的任务数
const runner = async function(fn) {
    activeCount++ // 任务开始执行 正在执行的任务数+1
    // 这个fn实际是对原始的异步函数进行了包装 直接执行返回的是Promise
    // 这里需要注意下
    const result = fn()
    await result // 保证内部函数已执行
    activeCount-- // 函数执行结束更新正在执行的任务数
    if(queue.size > 0) { // 存在待执行任务就继续执行
        queue.dequeue()() // 出队执行器并调用
    }
}
    return function(fn) {
        return new Promise(resolve => {
            queue.enqueue(runner.bind(undefined, fn)) // 仅将执行器入队 借助bind方法 仅穿参数不调用
            if(concurrency > activeCount && queue.size > 0) {
                queue.dequeue()() // dequeue为出队执行器 出队后再调用执行器
            }
        })
    }
}

添加返回值

因为我们对包装后的Promise函数并没有reslove调用,外层永远拿不到结果。 改造后如下:

import Queue from 'yocto-queue';
const PLimit = function(concurrency) {
const queue = new Queue()
let activeCount = 0 
const runner = async function(fn, resolve) {
    activeCount++ 
    const result = fn()
    resolve(result) // 直接调用resolve返回函数执行结果
    await result
    activeCount--
    if(queue.size > 0) {
        queue.dequeue()()
    }
}
    return function(fn) {
        return new Promise(resolve => {
            queue.enqueue(runner.bind(undefined, fn, resolve)) // 将resolve作为参数传给执行器
            if(concurrency > activeCount && queue.size > 0) {
                queue.dequeue()()
            }
        })
    }
}

那么,到这里最核心的内容就结束了,看一下p-limit的源码,实际上是对上面的代码进行了优化和容错处理。

3. 总结

并发的处理其实还有其他的处理方式,但是p-limit的处理方式还是挺简单、好理解的,仅通过一个队列和一个变量就控制了整个流程。值得借鉴!学习源码确实会有很多收获,还是要多学习、多思考、多总结。