优雅完成高频面试题《请求并发数控制》
众所周知,大厂面试官喜欢问的问题有很多,但是有一类问题是必问的,那就是并发。并发问题是面试中的常客,而且往往是面试官的必问问题,因为并发问题是一个程序员必须要掌握的基本功,也是一个程序员的基本素养。并发问题的考察,不仅仅是考察你是否掌握了并发的基本概念,更重要的是考察你是否能够在并发的场景下,写出高效、优雅的代码。
今天,我们就来看看一个经典的并发问题:请求并发数控制。
问题描述
不知道有没有同学用过 TIM,它是腾讯公司出品的一款聊天工具。但今天说的不是这款软件,而是 TIM.js, 它主要用于实时聊天的SDK。
但 TIM.js 中,有一个问题,就是它要求用户调用它的接口时,必须控制并发数:每秒最多只能调用 5 次接口。如果超过 5 次,就会报错。
这个问题,其实是一个非常经典的问题,而且在实际工作中,也经常会遇到。比如,我们在做爬虫的时候,我们也需要控制并发数,否则,我们的爬虫就会被网站封掉。
解决方案
相必大家都在网上看过很多解决方案。今天,我们引入一个很常见的概念来解决这个问题:PV操作。
PV操作 和 信号量
PV操作,是操作系统中的一个概念,它是用来解决并发问题的。在操作系统中,PV操作是通过信号量来实现的。 简单来说,信号量就是一种对于某种资源的计数器,它可以用来控制对于某种资源的并发访问。而P操作和V操作,就是对于信号量的操作。分别对应着对于信号量的减少和增加。
在我们的问题中,我们可以将并发数看作是一个信号量,而对于并发数的控制,就是对于这个信号量的操作。我们可以通过P操作来减少并发数,通过V操作来增加并发数。
代码实现
我们可以通过一个 signal
变量来表示并发数,通过 p
和 v
函数来实现P操作和V操作。
typescript
function limit<Args extends readonly [], T>(limit: number, request: (...args: Args) => T): (...args: Args) => Promise<T>
{
const signal = limit;
const tasks: Array<() => Promise<T>> = [];
async function run() {
if (signal > 0 && tasks.length > 0) {
const task = tasks.shift();
if (task) {
// P操作
signal--;
await task();
// V操作
signal++;
run();
}
}
}
return async (...args) => {
return new Promise<T>((resolve, reject) => {
tasks.push(async () => {
try {
const result = await request(...args);
resolve(result);
} catch (error) {
reject(error);
}
});
run();
});
}
}
调用例子
typescript
const request = limit(5, () => {
return fetch('https://www.baidu.com');
});
for (let i = 0; i < 100; i++) {
// 即使我们并发发起了100次请求,但是我们的并发数控制在了5个
request();
}
如果你看懂了这段代码,那么恭喜你,你已经实现了 npm 上流行的一个包:p-limit 的核心逻辑。
但是,我们代码还有改进空间,比如我们对信号量 的操作过于简单,实际上,我们需要的其实不是一个简单的信号量,而是一个真实存在的资源。
在这里例子中,我们的资源 其实就是一个可用的并发数 。但是在实际工作中,我们的资源可能是一个数据库连接,或者是一个文件句柄,或者是一个内存缓存。这些资源,都是有限的,我们需要对它们进行合理的管理。
资源池模式
资源池模式,是一种常见的并发控制模式。它的核心思想是:将资源抽象为一个池,然后对池中的资源进行管理。
在我们的例子中,我们的资源就是一个并发数。我们可以将并发数抽象为一个池,然后对这个池进行管理。
资源池模式的核心,就是对资源的管理。我们可以通过一个 Pool
类来实现对资源的管理。
typescript
class Pool<T extends object> {
#resources: T[]
async acquire(): Promise<T> {
// 获取资源
}
release(resource: T) {
// 释放资源
}
}
在这个类中,我们需要实现两个方法:acquire
和 release
。acquire
方法用于获取资源,release
方法用于释放资源。
release 方法
relase 方法比较简单,它只需要将资源放回池中即可。
typescript
class Pool<T extends object> {
#resources: T[]
async acquire(): Promise<T> {
// 获取资源
}
release(resource: T) {
this.#resources.push(resource);
}
}
acquire 方法
acquire 方法比较复杂,它需要实现对资源的获取。在获取资源时,我们需要考虑两种情况:
- 池中有空闲资源,直接返回
- 池中没有空闲资源,等待资源释放后,再返回
我们需要引入一个通知机制,告知等待的线程,资源已经释放,可以返回了。
typescript
class Pool<T> {
#resources: T[]
// releaseResolvers 是一个Promise的resolvers对象,包含promise和resolve方法
#releaseResolvers: {
promise: Promise<T>,
resolve: (resource: T) => void,
} | undefined;
async acquire(): Promise<T> {
// 获取资源
if (this.#resources.length > 0) {
return this.#resources.pop()!;
} else {
if (!this.#releaseResolvers) {
this.#releaseResolvers = {
promise: new Promise<T>((resolve) => {
this.#releaseResolvers!.resolve = resolve;
}),
resolve: () => {},
}
}
// 等待资源释放
await this.#releaseResolvers.promise;
// 重新获取资源
return this.acquire();
}
}
release(resource: T) {
this.#resources.push(resource);
if (this.#releaseResolvers) {
// 通知等待的任务
this.#releaseResolvers.resolve(resource);
this.#releaseResolvers = undefined;
}
}
constructor(resources: T[]) {
this.#resources = resources;
}
}
至此,我们就实现了一个资源池。我们可以通过这个资源池,来实现对于并发数的控制。
由于我们把很大一部分逻辑都放在了资源池中,所以我们的 limit
函数也变得非常简单。
typescript
function limit<Args extends readonly [], T>(pool: Pool<T>, request: (...args: Args) => T): (...args: Args) => Promise<T>
{
return async (...args) => {
const resource = await pool.acquire();
try {
const result = await request(...args);
return result;
} finally {
pool.release(resource);
}
}
}
调用例子
typescript
const pool = new Pool<number>([1, 2, 3, 4, 5]);
const request = limit(pool, () => {
return fetch('https://www.baidu.com');
});
for (let i = 0; i < 100; i++) {
// 即使我们并发发起了100次请求,但是我们的并发数控制在了5个
request();
}
更多场景
这种模式虽然代码量相对于PV操作来说,多了很多,但是它的优势在于,它可以应用于更多的场景。
比如,我们需要做一个数据库连接池,我们就可以使用这种模式。
typescript
// 我们简单改造一下 limit 函数,让资源可以传递进去
function limit<Args extends readonly [], T, R>(pool: Pool<T>, request: (this: T, ...args: Args) => R): (...args: Args) => Promise<R>
{
return async (...args) => {
const resource = await pool.acquire();
try {
const result = await request.call(resource, ...args);
return result;
} finally {
pool.release(resource);
}
}
}
typescript
const pool = new Pool<Connection>([new Connection(), new Connection(), new Connection(), new Connection(), new Connection()]);
const selectTable = limit(pool, function() {
// 我们甚至可以识别出this, 在this上调用方法
// 或者传递中间过程的一些成员属性
return this.query('select * from table');
});
另一个例子
我们可以使用这种模式,来实现一个Worker线程池。
typescript
const pool = new Pool<Worker>([new Worker(), new Worker(), new Worker(), new Worker(), new Worker()]);
const run = limit(pool, function() {
worker.postMessage('Hello');
});
总结
今天,我们用一个经典的并发问题,引用了PV操作和资源池模式。这两种模式,都是解决并发问题的常见模式,也是面试中的常客。希望大家能够掌握这两种模式,也希望大家能够在面试中,优雅的回答并发问题。
@cat5th/pool.js
上述代码的内容,大部分来源于我工作的点滴,所以我将其封装成了一个npm包:@cat5th/pool.js。
希望大家能够喜欢。如果有什么问题,欢迎提issue。感谢各位不吝赐教。顺手点个star吧。