异步任务队列的一种解决方案

342 阅读7分钟

为什么要使用异步编程

在(javascript)单线程的世界里,如果有多个任务,就必须排队,前面一个完成,再继续后面的任务。就像一个ATM排队取钱似的,前面一个不取完,就不让后面的取。为了这个问题,javascript提供了2种方式: 同步和异步。

回调地狱 (Callbacks Hell)

举个例子:通过api拿到数据,数据里面有图片,图片加载成功渲染,那么代码如下:

request(url, (data) => {
    if(data){
        loadImg(data.src, () => {
            render();
        })
    }
})

如果有在业务逻辑比较复杂,就成了下面这个样子:

doSth1((...args, callback) => {
    doSth2((...args, callback) => {
        doSth3((...args, callback) => {
            doSth4((...args, callback) => {
                doSth5((...args, callback) => {
​
                })
            })
        })
    })
})

海康初始化的代码

// 初始化
WebControl = new WebControl({
    ...,
    cbConnectSuccess: cbConnectSuccess
})
​
function cbConnectSuccess(){
    // 设置接受控件返回信息
    oWebControl.JS_SetWindowControlCallback({
        cbIntegrationCallBack: cbIntegrationCallBack
    })
}
​
function cbIntegrationCallBack(data){
    // 接收控件信息
    console.log(data)
}

使用方法回调来接收一个异步任务的结果会造成很多不便

  • 异常处理不方便

    当某个方法执行失败后,不利于进行重试,不宜与指定重试的时机

  • 流程控制不方便

    不宜与对某些还没执行的异步任务进行清除

实际场景

假设现在有一个方法A需要执行,但是他需要等WebControl初始化完毕后才能执行,有以下几种情况

  1. 使用setTimeout延迟指定时间,等待控件加载完成后,在执行某些操作。

弊端:会浪费多余的时间,如果因为某些因素控件加载过长,就会导致后续逻辑无法进行。

  1. WebControl初始化完毕后将一个全局变量设置为True方法A通过这个变量来判断是否可执行

弊端:如果这个方法是不可舍弃的,那会丢掉这一次函数执行,例如项目中有个场景,在自动监控打开的时候,刚好推过来一条新的进店消息,是有概率丢失这一条消息的

最好的解决方法:使用异步队列来管理这些任务

解决初始化完成时机

Promise严格来说不是一种新技术,它只是一种机制,一种代码结构和流程,用于管理异步回调。

  • promise状态由内部控制,外部不可变
  • 状态只能从pendingresovled, rejected,一旦进行完成不可逆
  • 每次then/catch操作返回一个promise实例,可以进行链式操作

promise状态

对比上下两段初始化代码:

// 旧
WebControl = new WebControl({
    ...,
    cbConnectSuccess: cbConnectSuccess
})
​
function cbConnectSuccess(){
    oWebControl.JS_SetWindowControlCallback({
        cbIntegrationCallBack: cbIntegrationCallBack
    })
}
​
function cbIntegrationCallBack(data){
    console.log(data)
}
// 新
function init(){
    if(window.initWebControl){
        await window.initWebControl
    }
    
    window.initWebControl = new Promise((resolve,reject)=>{
        // 将用于改变promise状态的resolve存储起来,方便后续去通知完成
        window.initWebControlResolve = resolve
        
        WebControl = new WebControl({
            ...,
            cbConnectSuccess: cbConnectSuccess
        })
    })
}
​
function cbConnectSuccess(){
    oWebControl.JS_SetWindowControlCallback({
        cbIntegrationCallBack: cbIntegrationCallBack
    })
}
​
function cbIntegrationCallBack(data){
    window.initWebControlResolve() //改变promise
}

队列管理

过期清除

缓存一定数量的异步队列,可以将过期的、重复的异步任务删除

-为什么要使用?

  1. 例如海康的api都是基于回调的方式,那么很有可能某些api调用并没有响应,这个promise就一直在运行中,导致后续的任务没有机会运行
  2. 方便在插入异步任务时,对以往重复的,不需要的进行舍弃
class LruCache {
    constructor(max) {
        this.max = max//最大缓存长度
        this.cache = new Map()//用一个map来存储缓存
    }
    get(key) {
        if (this.cache.has(key)) {//如果该值存在 则将该值从队列拿出来放到最后一位
            const val = this.cache.get(key)//1.取出来
            this.cache.delete(key)//2.从现在的位置删除
            this.cache.set(key, val)//3.放在队尾
            return this.cache.get(key)
        }else return undefined
​
    }
    set(key, value) {
        //如果当前值存在 就把当前值删除
        if (this.cache.has(key)) {
            this.cache.delete(key)
        }
        //然后将当前值放在队尾
        this.cache.set(key, value)
        //如果在进行set操作后 超过了预设长度 则把队首的删除
        //这也就是为什么会把重复get的值放在队尾
        //原则上来说 被删除掉的都是在一定时间范围内使用频率不高的缓存值
        if (this.cache.size > this.max) {
            this.cache.delete(this.cache.keys().next().value)
        }
    }
}
​

接下来我们再基于上述例子,实现一个定时过期的功能。

我的思路是 在数据set的时候同时进行一个setTimeout操作, 并且在删除数据的时候进行一次clearTimeout,直接上代码!

class LruCache {
    constructor({ max, time }) {
        this.max = max
        this.time = time
        this.cache = new Map()
    }
    has(key) {
        return this.cache.has(key)
    }
    get(key) {
        if (this.cache.has(key)) {
            //valu是一个数组,该数组的第0个元素是set的时候的数据,
            //第一个元素是settimeout返回的事件句柄,清除定时器时能用到
            const val = this.cache.get(key)
            clearTimeout(val[1])
            this.cache.delete(key)
            const timer = setTimeout(() => {
                this.cache.delete(key)
            }, this.time);
            this.cache.set(key, [val[0], timer])
            return this.cache.get(key)[0]
        } else return undefined
    }
    set(key, value) {
        if (this.cache.has(key)) {
            const val = this.cache.get(key)
            clearTimeout(val[1])
            this.cache.delete(key)
        }
        const timer = setTimeout(() => {
            this.cache.delete(key)
        }, this.time)
        //在set操作的时候一定要将settimeout返回的事件句柄保存起来
        //用于在适当的时机进行清除定时器操作
        this.cache.set(key, [value, timer])
        if (this.cache.size > this.max) {
            const dk = this.cache.keys().next().value
            clearTimeout(this.cache.get(dk)[1])
            this.cache.delete(dk)
        }
    }
}

这个类就实现了一个接口缓存的功能,且支持过期接口数据清理的功能

改造结果

基于以上思路,写了一份符合业务的类

需求分析:

  • new一个这个队列,然后可以指定这个队列的启动和停止,会依次执行队列中的所有异步任务,
  • 后续每次新增一个队列的时候,检测是否有在运行的异步任务,没有就运行
  • 支持超时异步队列移除
  • 支持异步任务错误处理

任务队列和执行

class AsyncQueue {
    static _$uuid_count = 1 //用于生成任务的唯一标识符
    _queues = null //存储待执行任务的队列。
    _runningAsyncTask = null //当前正在执行的任务
    _enable = false  //启用队列
    errHandle = null //发生错误会调用
​
    constructor({errHandle}) {
        this._queue = []
        this.errHandle = errHandle
    }
​
    /**
     * 往队列末位插入一个任务
     * @param callback 异步方法
     * @param params 传递给异步方法的参数
     * @return {number} 任务的唯一标识符
     */
    push(callback, params) {
        const uuid = AsyncQueue._$uuid_count++
        this._queues.push({
            uuid,
            callback,
            params
        });
        return uuid;
    }
​
    shift() {
        this._queues.shift()
    }
​
​
    play() {
        // 判断是否可以继续
        if (!this._runningAsyncTask && this._enable && this._queues.length > 0) {
            return
        }
        const actionData = this._queues[0]
        // 记录当前任务
        this._runningAsyncTask = actionData
        // 执行当前任务
        await actionData.callback(...actionData.params)
​
        this.next()
    }
​
​
    next() {
        this._runningAsyncTask = null;
        this._queues.shift()
    }
}

实现异常捕获

play() {
    // 判断是否可以继续
    if (!this._runningAsyncTask && this._enable && this._queues.length > 0) {
        return
    }
    const actionData = this._queues[0]
    // 记录当前任务
    this._runningAsyncTask = actionData
    // 执行当前任务
    try{
        await actionData.callback(...actionData.params)
    }catch(e){
        // 执行相关操作,通知外部允许发生错误,例如可以通过传递一个异常处理函数,在这里调用
        this.errHandle(actionData)
    }finally{
        this.next()
    }
}

实现超时任务处理

// 放工具函数
function timeoutPromise(promise, ms = 1000) {
    // 超时fn
    const timeout = new Promise((_, reject) =>
        setTimeout(() => reject(new Error('执行超时')), ms)
    )
    return Promise.race([promise, timeout])
}
​
play() {
    ...
    try{
        // 具有超时记录的方法 
        await timeoutPromise(actionData.callback(...actionData.params))
    }catch(e){
        // 执行相关操作,通知外部允许发生错误,例如可以通过传递一个异常处理函数,在这里调用
        this.errHandle(actionData)
    }finally{
        this.next()
    }
}

插队

当某个任务失败时,往往需要重新执行,而不是插入到队尾排队等待

jumpingInLine(callback, params = [], uuid) {
    let index = this._queues.findIndex(item => item.uuid === uuid)
    if (index === -1) {
        this._queues.push({
            uuid,
            callback,
            params
        })
    } else {
        this._queues.splice(index, 0, {
            uuid,
            callback,
            params
        })
    }
    return uuid
}

重试

通过将失败的任务,通过插队的方式,放到下一次调用

let re_count = 5  //重试次数
let re_execute_count = 0 //当前重试次数
let re_execute_uuid = null //当前重试的任务idfunction handleReExecute(actionData) {
    if(re_execute_uuid!==actionData.uuid){
        re_execute_uuid = actionData.uuid
        re_execute_count = 0
    }
    re_execute_uuid = actionData.uuid
    re_execute_count++
    if (re_execute_count > re_count) {
        return
    }
    queue.jumpingInLine(actionData.callback, actionData.params, actionData.uuid)
    console.log(`失败的任务重复执行,次数${re_execute_count},`, actionData)
}
​
​

保证每次队列操作时自动执行任务

目前实现的只有手动,如果当前任务已经执行完毕,这时候再push一个任务,不会自动执行

解决思路:劫持数组

// 重写数组push和shift,重新监事队列是否有空余
class QueueArray extends Array {
    constructor(queueClass) {
        super();
        this.queueClass = queueClass
    }
​
    enableQueue() {
        if (this.queueClass) {
            this.queueClass.play()
        }
    }
​
    push(item) {
        let result = super.push(item)
        this.enableQueue()
        return result
    }
​
    shift() {
        let result = super.shift()
        if (this.length > 0) {
            this.enableQueue()
        }
        return result
    }
​
    splice() {
        let result = super.splice(...arguments)
        if (this.length > 0) {
            this.enableQueue()
        }
        return result
    }
}
​

改造普通数组

class AsyncQueue {
    ...
    constructor(){
       // this._queues = []
       this._queues  = new QueueArray(this)
    }
    
}

测试数据

let nowPlayList = []
​
async function multerPlay(playList) {
    nowPlayList.value = playList
    return new Promise((resolve, reject) => {
        // 90%概率成功
        setTimeout(() => {
            let isSuccess = Math.random() > 0.1
            if (isSuccess) {
                resolve('批量播放成功')
            } else {
                reject('批量播放失败')
            }
        }, 200)
    })
}
​
async function multerCloseVideo(videoList) {
    return Promise.all([...videoList.map(item => closeVideo(item))])
}
​
async function closeVideo(videoInfo) {
    return new Promise((resolve, reject) => {
        // 90%概率成功
        setTimeout(() => {
            let isSuccess = Math.random() > 0.6
            if (isSuccess) {
                let index = nowPlayList.value.findIndex(item => item.id === videoInfo.id)
                if (index !== -1) {
                    nowPlayList.value.splice(index, 1) //删除视频
                }
                resolve('关闭视频成功')
            } else {
                reject('关闭视频失败')
            }
        }, 200)
    })
}
​
​
​
// 异常处理
function handleError(actionData) {
    // 可以通过对比函数地址来判断是哪个方法执行失败
    let {params, callback, uuid} = actionData
    if (callback === multerPlay) {
        handleReExecute(actionData)
    }
    if (callback === closeVideo) {
        handleReExecute(actionData)
    }
    if (callback === multerCloseVideo) {
        console.log('批量关闭失败')
    }
​
}

结果

GIF 2024-7-29 21-45-21