本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
今天要学习的是p-limit,是用来对前端并发请求进行控制的。读完本文,你将了解并发的概念、并发与并行的区别、js中同步和异步的概念;清楚需要对并发请求进行限制的原因;掌握源码调试技能;掌握p-limit的使用方法和实现原理。你还可以为以后的学以致用打下基础,说不定在今后开发中你会用到它。开始学习吧!
1.学习准备
参考资料:
Node.js 并发能力总结: https://mp.weixin.qq.com/s/6LsPMIHdIOw3KO6F2sgRXg
https://www.npmjs.com/package/p-limit
源码路径:
https://github.com/sindresorhus/p-limit
2.思考:并发的概念以及并发限制的必要
在开始学习具体源码之前,先思考两个问题:
1.什么是并发,为什么需要并发?
2.需要并发限制的原因?
2.1并发的概念
并发是指一个应用程序中存在多个任务在执行,同时刻或者说看起来同一时刻(并发)这些任务都在执行。如果计算机只有一个CPU, 应用程序可能不会在同一时间完成多个任务。但在应用程序内部要一次完成多个任务, 就需要在不同任务之间切换,如下图所示:
2.2 并行的概念
一个和并发相似的概念就是并行,并行是指计算机有多个CPU或者CPU内核,并同时在多个任务上取得进展,如下图所示:
综上,并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔内发生。
2.3 同步与异步
在JS中谈到并发并行,那么也要说到同步和异步。同步就是顺序执行,执行完一个再执行下一个,后一个需要等待前一个。异步就是彼此独立,在等待的时候可以做自己的事情。
JS的语言执行环境是单线程,一次只能完成一件任务。如果有多个任务,那么当前没有执行的任务就需要排队。为了解决这个问题,JS将任务的执行模式分为了同步和异步。
异步就是一个任务分成两段,先执行第一段,然后转而执行其他任务,等准备好了再执行第二段。这样排在异步任务后面的代码就不用等待异步任务的结束而是可以提前执行。在JS中像网络请求,读取文件之类的操作都是异步的。
2.4 并发控制的必要
今天我们要讨论的就是网络请求这种异步任务的并发,有时候在业务中会碰到批量请求数据的需求,一下子要调用好多后台接口。对于浏览器来说,对于同一域名下的并发请求数量有限制,比如Chrome中只允许6个并发请求,多出来的请求只能排队,等待发送。在http2协议中,浏览器不再限制并发请求数量。对于服务端,多个并发请求可能会对服务端产生压力。另外,完成的网络请过程需要经过DNS寻址、与服务器建立连接、发送数据、等待服务器响应、接收数据这样一个漫长而复杂的过程,如等待时间过长则可能造成不好的用户体验。综上,对并发异步请求进行限制是十分有必要的。
3.源码阅读与解析
3.1 p-limit的使用
在源码目录中有一个test.js文件,这是测试用例文件,我们可以通过这个文件来了解p-limit的用法:
import pLimit from './index.js';
test('concurrency: 4', async t => {
const concurrency = 5;
let running = 0;
const limit = pLimit(concurrency);
const input = Array.from({length: 100}, () => limit(async () => {
running++;
t.true(running <= concurrency);
await delay(randomInt(30, 200));
running--;
}));
await Promise.all(input);
});
上面的代码时测试文件中的一个用例,通过这个用例可以总结出p-limit的使用方法:
import pLimit from './index.js';
const limit = pLimit(最大并发数);
const input = [limit(请求1), limit(请求2),...,limit(请求n)]
Promise.all(input)
简单概述一下p-limit的使用方法:
1.引入p-limit
2.调用p-limit,参数为最大并发数,返回值limit为一个函数
3.对每一请求都调用limit,组成数组input
4.Promise.all(input)
3.2 p-limit源码解析
代码路径:index.js,如下代码为删减后的概要,稍后均详细分析:
import Queue from 'yocto-queue';
export default function pLimit(concurrency) {
//对concurrency参数的检查(代码略)
const queue = new Queue();
let activeCount = 0;
const next = () => {}
const run = async (fn, resolve, args) =>{}
const enqueue = async (fn, resolve, args) => {}
const generator = (fn, ...args) => {}
Object.defineProperties(generator, {
// 一些额外属性的定义
});
return generator;
}
通过前面p-limit的例子我们了解到,使用时要指定最大并发数,可以想到,如果一次批量请求数超过了最大并发数,那么一些请求就要等待,等待就需要队列。p-limit使用yocto-queue,它提供了队列数据结构,如果在数组上执行大量的push和shift操作,那么使用此包是个不错的选择。
activeCount表示当前正在执行的请求数量,初始化为0。然后定义了next, run, enqueue, generator方法。 接着使用Object.defineProperties为generator方法定义了一些属性。最后返回generator方法。
3.2.1 next方法
next()方法用于执行下一个等待的请求,完整代码如下:
const next = () => {
activeCount--;
if (queue.size > 0) {
queue.dequeue()();
}
};
可以执行下一个请求的原因是因为当前执行的一些请求中,某个执行完毕了,所以需要将activeCount减少1。然后检查队列中如果有元素则出队。
3.2.2 run方法
run方法用来执行异步请求任务,完整代码如下:
const run = async (fn, resolve, args) => {
activeCount++;
const result = (async () => fn(...args))();
resolve(result);
try {
await result;
} catch {}
next();
};
执行异步请求任务时activeCount加1,定义了异步函数result用于执行当前的fn,执行结果传给resolve,为了保证next的顺序 await result, next()方法让排队的任务执行。
3.2.3 enqueue方法
enqueue方法用于将任务入队执行
const enqueue = (fn, resolve, args) => {
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函数的结果入队。
接着定义了立即执行的异步函数。此函数需要等到下一个微任务之后,才能将“activeCount”与“concurrency”进行比较,因为当run函数退出队列并被调用时,“activeCount”是异步更新的。if语句中的比较也需要异步进行,以获取`activeCount的最新值。
if判断逻辑是判断当前的正在执行的任务数是否小于最大并发数,并且队列中还有排队的任务则任务出队执行。
3.2.4 generator方法
const generator = (fn, ...args) => new Promise(resolve => {
enqueue(fn, resolve, args);
});
返回一个promise,promise中调用入队enqueue方法。
3.2.5 generator额外属性
Object.defineProperties(generator, {
activeCount: {
get: () => activeCount,
},
pendingCount: {
get: () => queue.size,
},
clearQueue: {
value: () => {
queue.clear();
},
},
});
使用Object.defineProperties为generator方法定义了3个属性:activeCount返回当前正在执行的任务数;pendingCount返回当前排队等待的任务数;clearQueue清空等待队列。
注意到最终返回的是generator方法,enqueue调用的又是enqueue方法。又注意到再使用p-limit的时候,实际上每一个异步任务都调用了一次generator方法,也就相当于调用了enqueue方法。enqueue方法中使用的queue定义在本方法外部,所以queue被多个任务共享,对应JS底层的机制其实就是闭包。
下面以一张流程图总结一下相关函数的调用过程:
3.3 调试加深理解
3.3.1 调试准备
首先安装好相关依赖:
pnpm i
测试用例和源码都打好断点:
打开package.json文件,鼠标移至scripts中的test, 点击调试脚本:
开始调试过程:
3.3.2 测试用例1
先看第一测试用例也就是concurrency等于1的情况:
可以看到fn就是要执行的异步任务,可以看一下fn详细信息:
在enqueue方法中,单步跳过之后:
发现队列size为1,因为一个异步任务已经入队,直到第三个也入队:
3.3.3 测试用例2
执行到run函数:
执行到next函数:
如下情形:activeCount<concurrency并且队列不为空的时候,应该唤醒队列的元素
如下图所示,队列中仅剩的一个异步任务被唤醒执行,队列为空:
接下来就是不断调用next的过程,直到activeCount为0:
3.4 相关第三方包
在测试用例文件中用到了一些第三方npm包,我们简单了解一下:
delay,创建延时的promise , 详见: www.npmjs.com/package/del…
inRange, 用于检查数字是否在给定范围内,详见:www.npmjs.com/package/in-…
time-span,用于产生时间差,详见:www.npmjs.com/package/tim…
random-int ,生成随机整数,详见:www.npmjs.com/package/ran…
4.总结和收获
本文首先介绍了并发和并行、异步和同步等相关概念;接着分析了并发控制的必要性;然后结合p-limit的源码中的测试用例介绍了p-limit的使用方法,结合源代码分析了p-limit的实现,结合调试过程加深对p-limit执行过程的理解。
我们可以考虑一下自己实现一个并发控制的方法该如何做呢?可以看一下参考文献中的第5篇文章。此外还有没有其他实现并发控制的库呢?可以看参考文献中的第6篇文章。
5.参考文献
【1】一文读懂并发与并行
【3】JS 异步编程六种方案