优雅完成高频面试题《请求并发数控制》

优雅完成高频面试题《请求并发数控制》

众所周知,大厂面试官喜欢问的问题有很多,但是有一类问题是必问的,那就是并发。并发问题是面试中的常客,而且往往是面试官的必问问题,因为并发问题是一个程序员必须要掌握的基本功,也是一个程序员的基本素养。并发问题的考察,不仅仅是考察你是否掌握了并发的基本概念,更重要的是考察你是否能够在并发的场景下,写出高效、优雅的代码。

今天,我们就来看看一个经典的并发问题:请求并发数控制

问题描述

不知道有没有同学用过 TIM,它是腾讯公司出品的一款聊天工具。但今天说的不是这款软件,而是 TIM.js, 它主要用于实时聊天的SDK。

但 TIM.js 中,有一个问题,就是它要求用户调用它的接口时,必须控制并发数:每秒最多只能调用 5 次接口。如果超过 5 次,就会报错。

这个问题,其实是一个非常经典的问题,而且在实际工作中,也经常会遇到。比如,我们在做爬虫的时候,我们也需要控制并发数,否则,我们的爬虫就会被网站封掉。

解决方案

相必大家都在网上看过很多解决方案。今天,我们引入一个很常见的概念来解决这个问题:PV操作

PV操作 和 信号量

PV操作,是操作系统中的一个概念,它是用来解决并发问题的。在操作系统中,PV操作是通过信号量来实现的。 简单来说,信号量就是一种对于某种资源的计数器,它可以用来控制对于某种资源的并发访问。而P操作和V操作,就是对于信号量的操作。分别对应着对于信号量的减少和增加。

在我们的问题中,我们可以将并发数看作是一个信号量,而对于并发数的控制,就是对于这个信号量的操作。我们可以通过P操作来减少并发数,通过V操作来增加并发数。

代码实现

我们可以通过一个 signal 变量来表示并发数,通过 pv 函数来实现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) {
        // 释放资源
    }  
}

在这个类中,我们需要实现两个方法:acquirereleaseacquire 方法用于获取资源,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吧。

相关推荐
WeiXiao_Hyy23 分钟前
成为 Top 1% 的工程师
java·开发语言·javascript·经验分享·后端
吃杠碰小鸡40 分钟前
高中数学-数列-导数证明
前端·数学·算法
kingwebo'sZone1 小时前
C#使用Aspose.Words把 word转成图片
前端·c#·word
xjt_09011 小时前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农1 小时前
Vue 2.3
前端·javascript·vue.js
夜郎king2 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳2 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵3 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星3 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_3 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js