- 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第31期,链接:p-limit 限制并发数。
说明
我们经常会遇到这样的需求:首页或者其他地方,需要同时发起多个请求,这个时候我们往往会通过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
2、目录分析
我们打开package.json文件,看到有:
"exports": "./index.js",`
即,入口文件是根目录下的index.js文件。我们后面再详细分析index.js文件。
我们打开test.js文件,先了解这个插件到底是怎么用的。因为测试代码中使用了一些测试的npm包,所以我截取了一段稍微直观点的关于p-limit使用的代码,如下:
当然,上面其实还是不够直观,所以下面我再提取了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文件:
接下来进入调试模式。
打开调试终端: 切换到指定目录,并执行test-ali.js文件: 我这边采用这种方式进入终端调试,读者也可根据自己的需求去使用不同方式进入调试模式。
这里我就不过多阐述打断点的技巧了,大家可以多打几次调试试试,找准到合适的断点位置,去捋清楚打在哪里方便调试。
下面就对index.js源码进行分析了。
三、事前猜测
按照惯例,大多数情况下,我在分析源码前都会抛出一个猜想,如果我们自己实现手动限制并发数,应该怎么做合适?
其实我个人大多数是在边看边猜的,但是在这里我觉得有必要让读者先思考一下,下面我会写上一个大概的猜想(不一定完全一致),这样在后面分析源码前,读者对整个的流程就心里有数。
- 既然是限制并发,那么必然需要一个数据结构去存储所有的异步操作,这个结构是什么呢🤔?
- 考虑到多个,限制了数量,等执行完一个自动下一个,这个数据结构必然涉及到顺序;
- 相信读者很容易就有个答案:数组实现队列,先进先出。不过!数组可以,但性能上不够好,数组实现先进先出的push+shift,会导致数组原有的顺序结构重排,性能差。所以答案是:链表实现队列(不了解链表的读者可自行查阅了解,当然回头我会补充);
- 既然控制执行,那么必然不是直接把异步丢入链表,这样就没法控制执行,因此需要在外面包裹一个函数,在函数中执行异步操作,这样我们就可以通过控制该函数的调用去控制异步执行;
- 显然,我们不能直接把
[函数,函数]
形式丢给Promise.all
,我们应该丢的是[Promise1,Promise2,...]
给Promise.all
,所以我们需要在这个函数外面再套一层Promise
,并把函数内部的异步操作的返回值丢给这个Promise
的resolve
;
以上就是我们自己所构思的实现限制并发数的设计思路(当然,我能说详细是因为我看了源码😁)。
四、源码分析
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;
}
分析:
- yocto-queue插件就是用来创建一个链表队列的,并使用enqueue()方法把某个节点插入到队列中,使用dequeue()方法执行出队列,这里我不过多阐述这个插件的使用,读者可以查阅对应的源码yocto-queue,或者看我以前文章:根据数组创建单向链表 和 链表实现队列。
- index.js导出的是pLimit方法,该方法返回值是generator,即我们测试中
const limit = pLimit(3)
拿到的是generator; - generator是一个函数,同时用
Object.defineProperties
追加了几个属性:activeCount=当前正在执行的异步个数,pendingCount=队列中的异步个数(正在执行的都出队列了,在队列中都是等待的),clearQueue=清空队列的方法; - 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
,并把这个Promise
的resolve
作为参数传递到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的嵌套,耐着性子去分析去阅读,还是能够理解的。
不管怎样,个人觉得看这种工具源码之前,我们可以就工具的目的,去构思、去假设自己如果来实现,应该是怎么一个过程?当然很多时候我们不一定想的出来,但至少我们思考过,每一次多思考一点,下一次也许就能完整构思出自己的逻辑。