面试官:假如让你同时发起几千个请求你该怎么办 | 请你说说怎么实现错误收集,重试机制,可暂停可恢复 的并发线程池

2,826 阅读8分钟

前言

之前当面试官的时候在二面三面的场景设计题就会问他这种问题。当然能够完整回答这个问题的暂时没遇到过,稍微能给一些接近的答案选项的也是很少很少。其实咱也挺诧异的,我感觉控制并发还是挺常见的,但是不知道为什么前端的朋友对于并发的概念挺薄弱。

业务使用示例

我举一个我业务中的例子说明一下这玩意为什么比较重要。先讲一下背景吧。我们当时需要做一个平台,类似于文件管理和模型管理。然后我们需要判断上传文件类型.其中有xml,普通文件(video,img),model,图纸(dwg格式),然后进行不同的操作。在这期间需要跟bimface 和 阿里oss 以及我们研究院的后端进行交互。由于业务隐私问题,这里我用的是几个版本前上传xml的解析图(部分)

链路图.png

大逻辑的处理我是用职责链模式来做的(juejin.cn/post/727894…) ,小逻辑的处理特别是promise的逻辑我则是用到了并发池。也就是本文我们所要讲到的东西

你在图中可以看到请求次数 = num(task) 这个东西。注意这里的任务 有大几千的,也就是说,你有几千个api 请求需要在 一次流程中全部走完。并且这些请求但凡有一次请求失败那么整条链路都会失败,因此这也就是说我们这个并发线程池需要有一个错误收集和错误重试机。毕竟如果你几千条任务因为某一条任务的失败而让这几千条任务重新走一遍成本也太高了。

并且还有一个点,如果你同时发起任务,那么你的浏览器在这种条件下大概率会触发 ERR_INSUFFICIENT_RESOURCES 这个报错导致你后面的任务全部gg。

好了说完了背景,我们来讲一下这个东西应该怎么实现吧

线程池设计

用户传参数据结构设计

  • 第一点:首先先说一下误区,在我面过的面试者中,许多人在 说到实现并发的方式中都可以想起 promise。但是他们的回答千篇一律的是

    // 假设promise[0] 是一个数组
    let sleep= (time)=>{
        return new Promise((resolve)=>{
            setTimeout(()=>{
                resolve(time)
            },time)
        })
    }
    let PromiseArr = [sleep(1000),sleep(10),sleep(0),sleep(2000)]
    promise.all(PromiseArr.split(0,2))
    

    你应该已经可以发现这段demo出现的问题,sleep这个函数传入参数他就直接运行了,你这里的promise就是一个摆设。那么这个点其实就引入了我们设计数据结构的第一个原则。promise参数和promise数组需要分开传入,更加进一步的,我们可以借鉴一个 p-limit 的 设计,也就是将 你的 promise 包装在 一个 函数里面执行

  • 第二个点:是为了实现并发中对里面的事件能够实现自定义,我们又无法避免的需要实现一个发布者订阅者模式。这部分比较简单就是给一个eventbus数组和emit方法就可以了

  • 第三个点:然后为了实现我们的业务逻辑我们需要加上 最大重试次数,最大并发数

最终让用户传入的数据如下

type PoolRequestType = {
    // eventbus 
    eventBus?: {
        finish:Array<Function>
        error:Array<Function>
    };
    
    // 最大重试次数
    MaxRetryCount: number;
    RetryFn:(arg:any)=>void;
    // 最大并发数
    MaxConcurrentCount: number;
    ConcurrentFn:(arg:any)=>void;
    // 异步数组
    PromiseArr: Array<(e?: any) => Promise<any>>;
    // 用户不用传这个,写在这里是方便用户获取这个数组
    FailTask:Array<(e?: any) => Promise<any>>
}
​

控制并发的方法

首先我们要知道我们常常说的并发任务,其实在前端的领域其实都可以封装成promise任务进行控制。例如axios,useRequest 返回的数据其实都是一个promise,基于这一点我们可以思考一下我们怎么对一个promise数组进行并发量的控制呢。我们下面通过分析 线程池增加 , 删除,更新,错误处理 ,以及错误重试机制,来了解一下怎么控制并发。先上一下这段的源码

/**
     * @des 执行
     * @param PromiseArr promise数组
     * @param ParamArr 参数数组
     * @param Retry 重试次数
*/
async execute(PromiseArr: Array<(e: any) => Promise<any>>,ParamArr: Array<any>,Retry:number) { 
    if (PromiseArr.length != ParamArr.length) {
        throw new Error("function和param列表传参数量不相等,请进行校验")
    }
    if(!Retry && PromiseArr.length){
        throw new Error("重试次数达到上限.停止重试")
    }
​
    let that = this
    //完成的数量
    let finish = 0
    // 并发池
    let pool: Array<any> = []
    // 失败列表
    let FailPromiselList:Array<any> = [] 
    let FailParamlList:Array<any> = [] 
    for (let i = 0; i < PromiseArr.length; i++) {
        let task = PromiseArr[i](ParamArr[i])
        task.then((data) => {
            let index = pool.findIndex(t => t === task)
            //请求结束后将该Promise任务从并发池中移除
            pool.splice(index)
        }).catch(() => {
            FailParamlList.push(ParamArr[i])
            FailPromiselList.push(PromiseArr[i])
        }).finally(() => {
            finish++; // console.log("进度:" + i / PromiseArr.length)
            //所有请求都请求完成
            if (finish === PromiseArr.length) {
                if(FailParamlList.length){
                    console.log("重试剩余次数:",Retry)
                    that.execute(FailPromiselList,FailParamlList,Retry-1)
                }
                that.emit("finish","并发池完成")
            }
        })
        pool.push(task)
​
        if (pool.length === this.config.MaxConcurrentCount) {
            // 用Promise.race 实现并发
            try{
                await Promise.race(pool)
            }catch{
                console.log("报错")
            }
        }
    }
​
}

我们来讲一下这篇代码里面的知识点吧

  • 增加:我们可以看到我们源码中维护了一个 pool 的 array,然后再一个promise任务执行后,就把这个任务

    pool.push(task) 
    
  • 删除:删除的逻辑写在 promise执行完成之后,再promise.then方法执行完之后,调用splice方法对线程池对应的promise删除

    task.then((data) => {
        let index = pool.findIndex(t => t === task)
        //请求结束后将该Promise任务从并发池中移除
        pool.splice(index)
    })
    
  • 更新:首先我们知道使用Promise.race可以保证,pool中最快的promise改变状态时,await就会放行,继续向pool写入新的promise,那么在这里我们很明显,当我们的 pool.length 等于我们的最大并发数后他才会进行阻塞。直到执行完promise任务之后经由 .then删除后线程池执行完的方法后。他才会继续执行

    if (pool.length === this.config.MaxConcurrentCount) {
        // 用Promise.race 实现并发
        try{
            await Promise.race(pool)
        }catch{
            console.log("报错")
        }
    }
    
  • 错误处理:当我们的catch捕获到数据,我们会放到错误的一个数组里面,当所有的数组执行完后,他会向使用者发送一个事件出去表示一轮promise 已经执行完并且递归执行错误数组里面的方法,直到重试次数变成0

    task.then((data) => {
        // ...
     }).catch(() => {
        FailParamlList.push(ParamArr[i])
        FailPromiselList.push(PromiseArr[i])
    }).finally(() => {
        finish++;
        //所有请求都请求完成
        if (finish === PromiseArr.length) {
            // 重试
            if(FailParamlList.length){
                console.log("重试剩余次数:",Retry)
                that.execute(FailPromiselList,FailParamlList,Retry-1)
            }
            that.emit("finish","并发池完成")
        }
    })
    

这里还有一个小细节需要注意一下,我这里循环是用for而不是forEach来做的

原因见下图,主要是forEach并不能够进行阻塞

微信截图_20230922145146.png

实现暂停和可恢复的队列

这里需要注意一个问题。网上有一些所谓暂停和可恢复的队列所说的方法最多的就是 设置 一个状态 。比如 pause 变量,然后在每一个 任务执行前去看一下 pause变量 有没有 设置成 true。假如是 true,他们会直接return 。 到这里他们关于暂停的思路是没有问题的。但是这样子真的能够实现可恢复的队列吗,return之后状态不就全部丢失了吗。你仔细想一想其实会发现 在暂停后 进行 恢复 的 队列,他不应该是return ,而应该是让整个链条属于 pause 的状态,也就是说我们要把这里 阻塞住。

这里我们考虑用 while + promise 进行实现。首先我们需要在class 中 新建一个属性 pause

接下来新建两个个方法

/**
     * @des 实现任务队列的暂停恢复
*/
async pauseIfNeeded() {
    while (this.paused) { // 当暂停状态为 true 时,等待恢复
        console.log("暂停中")
        await new Promise(resolve => setTimeout(resolve, 1000));
    }
}
​
pauseStatus(pause:boolean){
    this.paused = pause
}

最后在每一次任务执行前我们 都会去用await 去执行 这个 pauseIfNeeded方法,看看 pause 的值是啥。

  • 状态是pause == true的情况: 如果使用者不手动执行 pauseStatus那么就会永远陷入 while,每一秒钟都会去看看 pause 的状态有没有改变
  • 状态是pause == false的情况:那么任务就直接放行

最终使用示例

import { Pool } from "./Pool.js"
let sleepFn = (e, rejectBoolean = false) => {
    return new Promise((resolve,reject) => {
        console.log("开始执行settimeout方法:",e)
        setTimeout((e) => {
            if (rejectBoolean) {reject(e) };
            resolve(e);
        }, e,e);
    });
};

let res
let temp = {
    eventBus: {
        finish: [(e) => {
            console.log("触发finish方法:", e);
        }]
    },
    // 最大重试次数
    MaxRetryCount: 1,
    // 最大并发数
    MaxConcurrentCount: 2,
    // 异步数组
    PromiseArr: [async() => { 
        return sleepFn(0) 
    }, () => { return sleepFn(0) },],
    ConcurrentFn:()=>{
        console.log("触发了并发")
    },
    RetryFn:(data)=>{
        console.log("触发了重试",data)
    }
    // 参数列表数组
};
res = new Pool(temp);

​

你可以通过 res.pauseStatus 控制是否暂停

完整源码

github.com/electroluxc…

小结

大概这样子,你就能实现一个并发可重试的线程池了.最后补充一下,eventbus的事件你可以按照我上面的格式自定义一下就好了。理论来说可以支持各种场景的。最后欢迎各路大神讨论

源码链接:github.com/electroluxc…