分析p-limit是如何限制并发数的

1,554 阅读9分钟

说明

我们经常会遇到这样的需求:首页或者其他地方,需要同时发起多个请求,这个时候我们往往会通过Promise.all去一次性处理多个请求。

但是,Promise.all在处理多个异步请求时,只是把多个请求的结果数据汇总到一起输出,内部的异步请求依旧是发送给浏览器,让浏览器去处理并发的网络请求。

我们知道,同一个域名下,浏览器处理并发请求的个数是有限制的。chrome浏览器同一域名下,不同GET/POST请求的并发数量是6。当发送的请求数量达到6个,并且都没有得到响应时,后面的请求会置入队列等待发送。

试想一下,如果不限制并发数,那么后果就是,如果同一时间用户发送非常多的请求,那么浏览器就需要同时处理如此之多的请求,服务器很有可能直接蹦了,所以限制并发是非常重要的。

p-limit这个包就是用于限制异步并发的,假设有n个异步操作需要执行时,同一时间只能处理指定数量个,等某一个异步执行完毕,自动加载执行下一个,直到全部n个异步执行完毕,保证cpu稳定执行。

一、资源准备

1、项目克隆

# 克隆项目
git clone https://github.com/sindresorhus/p-limit

# 进入到目录
cd p-limit

image.png

2、目录分析

我们打开package.json文件,看到有:

"exports": "./index.js",`

即,入口文件是根目录下的index.js文件。我们后面再详细分析index.js文件。

image.png 我们打开test.js文件,先了解这个插件到底是怎么用的。因为测试代码中使用了一些测试的npm包,所以我截取了一段稍微直观点的关于p-limit使用的代码,如下:

image.png 当然,上面其实还是不够直观,所以下面我再提取了github中使用的案例:

import pLimit from 'p-limit';

// 设置并发数
const limit = pLimit(1);

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

// Only one promise is run at once
// fetchSomething('foo')、fetchSomething('bar')、doSomething()同一时间只有一个会被执行
const result = await Promise.all(input);
console.log(result);
// 从Promise.all的结果来看,三个异步都被执行了,但是其实在一个时间点上只会执行一个

现在,关于如何使用p-limit,读者大概已经有所了解,接下来进行调试分析。

二、调试

为了方便理解,我这里没有使用源码test.js中的测试案例进行调试,因为测试工具的代码和p-limit内部执行的逻辑没有耦合,为了避免影响理解,我在根目录单独建了个测试demo的test-ali.js文件:

image.png 接下来进入调试模式。

打开调试终端: image.png 切换到指定目录,并执行test-ali.js文件: image.png 我这边采用这种方式进入终端调试,读者也可根据自己的需求去使用不同方式进入调试模式。

这里我就不过多阐述打断点的技巧了,大家可以多打几次调试试试,找准到合适的断点位置,去捋清楚打在哪里方便调试。

下面就对index.js源码进行分析了。

三、事前猜测

按照惯例,大多数情况下,我在分析源码前都会抛出一个猜想,如果我们自己实现手动限制并发数,应该怎么做合适?

其实我个人大多数是在边看边猜的,但是在这里我觉得有必要让读者先思考一下,下面我会写上一个大概的猜想(不一定完全一致),这样在后面分析源码前,读者对整个的流程就心里有数。

  1. 既然是限制并发,那么必然需要一个数据结构去存储所有的异步操作,这个结构是什么呢🤔?
  2. 考虑到多个,限制了数量,等执行完一个自动下一个,这个数据结构必然涉及到顺序;
  3. 相信读者很容易就有个答案:数组实现队列,先进先出。不过!数组可以,但性能上不够好,数组实现先进先出的push+shift,会导致数组原有的顺序结构重排,性能差。所以答案是:链表实现队列(不了解链表的读者可自行查阅了解,当然回头我会补充);
  4. 既然控制执行,那么必然不是直接把异步丢入链表,这样就没法控制执行,因此需要在外面包裹一个函数,在函数中执行异步操作,这样我们就可以通过控制该函数的调用去控制异步执行;
  5. 显然,我们不能直接把[函数,函数]形式丢给Promise.all,我们应该丢的是[Promise1,Promise2,...]Promise.all,所以我们需要在这个函数外面再套一层Promise,并把函数内部的异步操作的返回值丢给这个Promiseresolve

以上就是我们自己所构思的实现限制并发数的设计思路(当然,我能说详细是因为我看了源码😁)。

四、源码分析

1.pLimit

先简略代码:

import Queue from 'yocto-queue';

/*
* concurrency 指定最大并发数
*/
export default function pLimit(concurrency) {
    if (!((Number.isInteger(concurrency) || concurrency === Number.POSITIVE_INFINITY) && concurrency > 0)) {
        throw new TypeError('Expected `concurrency` to be a number from 1 and up');
    }
    
    // 创建一个链表的队列
    const queue = new Queue();
    let activeCount = 0;
    const next = () => {};
    const run = async (fn, resolve, args) => {};
    const enqueue = (fn, resolve, args) => {};
    const generator = (fn, ...args) => {};
    
    Object.defineProperties(generator, {
        activeCount: {
            get: () => activeCount,
        },
        pendingCount: {
            get: () => queue.size,
        },
        clearQueue: {
            value: () => {
                queue.clear();
            },
        },
    });
    return generator;
}

分析:

  1. yocto-queue插件就是用来创建一个链表队列的,并使用enqueue()方法把某个节点插入到队列中,使用dequeue()方法执行出队列,这里我不过多阐述这个插件的使用,读者可以查阅对应的源码yocto-queue,或者看我以前文章:根据数组创建单向链表链表实现队列
  2. index.js导出的是pLimit方法,该方法返回值是generator,即我们测试中const limit = pLimit(3)拿到的是generator;
  3. generator是一个函数,同时用Object.defineProperties追加了几个属性:activeCount=当前正在执行的异步个数,pendingCount=队列中的异步个数(正在执行的都出队列了,在队列中都是等待的),clearQueue=清空队列的方法;
  4. next/run/enqueue/generator方法我们下面再详细介绍;

2.generator

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

我们在测试test-ali.js中执行的limit(() => foo("foo"))方法就是在执行generator函数。

  • () => foo("foo"),很显然foo("foo")是异步操作(这里是假设例子),而外面这个箭头函数,就是我们前面【事前猜测】分析中的那个套在异步操作外面的函数;
  • generator接收到这个函数fn参数时,外面又套了一个我们前面说到的Promise,并把这个Promiseresolve作为参数传递到enqueue中,这个resolve很显然是用做传递异步操作foo("foo")返回值的;

3.enqueue

  • 每一次在外面调用limit()时,都会执行enqueue函数;
  • queue.enqueue是yocto-queue插件处理入队列的方法,把经过run处理后的fn插入队列,run后面再分析;
  • 考虑到执行limit()和queue.enqueue是同步,即此时已经插入队列,此处使用async+await创建了一个微任务阻塞操作,目的是等全部异步操作插入队列,然后再微任务异步来执行activeCount < concurrency判断,进而执行queue.dequeue的出队列操作。以测试demo举例简单来说,就是同步插入a、b、c、d、e异步到队列后,执行a过程中遗留的async + await这个微任务阻塞,判断activeCount < concurrency后,执行出队列a异步操作,同理执行出队列b和c的异步操作,但是到d和e时,由于activeCount的3 < concurrency的3为false,因此d和e不会被执行,保证限制并发为3个。
const enqueue = (fn, resolve, args) => {
    // run函数是把fn追加到队列中的,我们后面再分析
    queue.enqueue(run.bind(undefined, fn, resolve, args));

    (async () => {
        // This function needs to wait until the next microtask before comparing
        // `activeCount` to `concurrency`, because `activeCount` is updated asynchronously
        // when the run function is dequeued and called. The comparison in the if-statement
        // needs to happen asynchronously as well to get an up-to-date value for `activeCount`.
        await Promise.resolve();
        if (activeCount < concurrency && queue.size > 0) {
            // 出队列,注意queue.dequeue()拿到的是run函数,后面一个()是执行run函数
            queue.dequeue()();
        }
    })();
};

4.run

  • run函数是处理插入的异步fn操作的,前面分析过,fn是异步操作外面套了一个箭头函数;
  • 我们是把run函数整个插入到队列,很显然出队列时会执行run函数。
  • 所以执行run时activeCount++;
  • 此处拿到fn的返回值,用resolve传递出去(前面我们提到了),这里其实使用async又嵌套了一个promise;
  • fn()是异步执行,因此使用await阻塞,确保执行异步操作执行完,再去调用next方法,
const run = async (fn, resolve, args) => {
    activeCount++;
    const result = (async () => fn(...args))()
    resolve(result);
    try {
        await result;
    } catch {}

    next();
};

5.next

  • 前面的run保证异步执行完之后,执行该next方法;
  • activeCount--,更新实际处理执行中的异步操作个数;
  • 如果队列中还有异步操作,则继续使用queue.dequeue执行下一个;
const next = () => {
    activeCount--;
    if (queue.size > 0) {
        queue.dequeue()();
    }
};

6.几个疑问

疑问1:enqueue中加的异步微任务阻塞是否有必要?

笔者测试发现,可以去除这个微任务阻塞,如下:

const enqueue = (fn, resolve, args) => {
    queue.enqueue(run.bind(undefined, fn, resolve, args));

    if (activeCount < concurrency && queue.size > 0) {
        queue.dequeue()();
    }
};

从逻辑上讲,没有必要等所有队列都同步插入之后,再去执行阻塞的异步代码,去执行队列中的任务吧?应该也可以插入一个到队列同时就执行该任务的出队列操作,直到达到了限定并发数时,只插入队列不执行出队列,其余逻辑不变。

疑问2:run函数中是否有必要再用async嵌套一层promise?

虽然说,这种嵌套的promise最后返回的结果依旧是最里面promise返回的resolve,但是笔者不理解这里为何需要加上,按如下,不加:

const run = async (fn, resolve, args) => {
    activeCount++;
    const result = fn(...args);
    try {
        await result;
        resolve(result);
    } catch {}
    next();
};

从笔者的角度看,貌似上面两个疑问这样处理也没有什么问题,猜测可能是作者为了解决某种情况的特例才这么设定的,若有读者知晓,还望告知,谢谢!

五、总结

以上就是关于p-limit的分析过程。

通过上面的分析,我们可以看到,其实整体实现思路并不复杂,无非是多做了promise的嵌套,耐着性子去分析去阅读,还是能够理解的。

不管怎样,个人觉得看这种工具源码之前,我们可以就工具的目的,去构思、去假设自己如果来实现,应该是怎么一个过程?当然很多时候我们不一定想的出来,但至少我们思考过,每一次多思考一点,下一次也许就能完整构思出自己的逻辑。