【Node.js学习笔记】async/await 全局控制并发数量

前言

公司业务需要用Node.js开发一个消费Redis任务的服务,但因为不熟悉node.js中的异步处理,绕了不少的弯路,但同时也学习了一些相关知识。
最开始的设计思路,因为这算是用 Node.js写的第一个业务代码,所以思路很不成熟。

伪代码如下:

async consume() {
    try {
        let task = await this.getTask()
        if (task) {
        	...
            await this.handleTask(task)
            return true
        }
    } catch (error) {
        console.trace(error)
        return false
    }
    return false
}

async handleTask(task) {
	//...
	for(let i = 0;i<len;i++){
		await this.doSomething(taskData[i])
	}
	//...
}

async doSomething() {
	//涉及一些网络相关操作
	//...
}

现在回想,Node.js是属于单线程的,在这一开始的代码中,涉及网络相关操作都比较耗时,然而,不但从Redis拿到任务后用了await进行初步消费,也用了await等待原本异步的网络请求操作。所以,就造成了整套服务跑下来就只有单线程,单任务,按顺序地逐个等待网络请求完毕后进行消费。
在基本测试10W+个任务,大约只有1K个请求,就用了几分钟的时间进行消费。这单机消费能力是不可忍受的。然后,进行了如下修改。

伪代码如下:

async consume() {
    try {
        let task = await this.getTask()
        if (task) {
        	...
            await this.handleTask(task)
            return true
        }
    } catch (error) {
        console.trace(error)
        return false
    }
    return false
}

async handleTask(task) {
	//...
	for(let i = 0;i<len;i++){
		this.doSomething(taskData[i]).catch(()=>{
			//handle some error
		})
	}
	//...
}

async doSomething() {
	//涉及一些网络相关操作
	//...
}

题外话

在修改代码的时候,Redis任务生产者那边,增大了单次任务处理的信息,从原本的5k上调到了3w。之前的写法,直接任务有多大的信息,就一次性消费了,消费网络请求是通过调用第三方的API接口,不管数据量大小,直接调用。这是非常不靠谱的。
所以,在修改为上面方法的时候,顺便重构了下代码,在handleTask doSomething之前,先按照参数规定的子批次大小,对任务进行二次拆分,然后再调用doSomething来进行真正的任务消费。

上面的代码,很明显只是把网络请求前的await去掉了,然后将一些需要等待结果或错误处理的操作通过callback或全局变量来实现。在一开始测试的时候,发现速度飞快。几十万的任务量进来,看到20秒左右就显示消费完成了。但是,随后,就迎来了一大堆http网络请求超时的异常(接口那边虽然接受到请求,并碰巧地全部消费成功了,但是由于网络IO等问题,本地HTTP调用还是全部显示超时)。这是非常不利于对任务处理状态进行跟踪的。当然这也比较明显是因为把代码中比较耗时的网络操作都直接进行了异步处理。在跑的时候,把本机的CPU几乎占满了。这么操作也是非常不靠谱的,这必须要对这进行一些限制,否则不确定性太高了,随后,想起之前Windows开发的时候,有用过线程池之类的进行线程并发限制,但是,大概搜索了下,Node.js对多线程这概念好像不是很主流的一种处理方式,而是通过async.mapLimit、async.queue、事件等方式进行并发控制。但是,因为开发架构基本定好了,在网上找了几种参考方式发现都具有一定的局部性或是过于庞大、复杂。例如async.mapLimit需要将数据组成一个数组,迭代消费,除了需要重新组数据麻烦之外,还有就是比较难进行全局的一个并发数控制。最后,参考了网上一些资料。用了如下方式进行全局并发控制。

伪代码如下:

const MAX_CONCURRENCY = 20 //最大并发数
let lock = [] //全局锁
let currentConcurrency = 0 //当前并发数

async consume() {
    try {
        let task = await this.getTask()
        if (task) {
        	...
            await this.handleTask(task)
            return true
        }
    } catch (error) {
        console.trace(error)
        return false
    }
    return false
}

async handleTask(task) {
	//...
	for(let i = 0;i<len;i++){
		//大于最大并发数,则用await 进行阻塞,将promise 的resolve推进队列,等待唤醒执行
		if (this.currentConcurrency >= MAX_CONCURRENCY) {
           let _resolve
           await new Promise((resolve,reject)=> {
               _resolve = resolve
               this.lock.push(_resolve)
           })
        }
        
        this.currentConcurrency++ //需要执行进行控制并发函数之前,更新并发数量
		this.doSomething(taskData[i]).catch((error)=>{
			//handle some error
			this.currentConcurrency-- //并发任务异常结束,并发数减少
			this.lock.length && this.lock.shift()() //判断lock中是否有等待中的任务,有则拿出并执行
		})
	}
	//...
}

async doSomething() {
	//涉及一些网络相关操作
	//...
	this.currentConcurrency-- //并发任务正常结束,并发数减少
	this.lock.length && this.lock.shift()()  //判断lock中是否有等待中的任务,有则拿出并执行
}

这里通过全局变量记录当前控制执行的并发数量,并在执行需要进行控制并发域之前,判断是否超过了当前允许的最大并发数,大于最大并发数,则用await 进行阻塞,将promise 的resolve推进队列,等待唤醒执行,而在一个并发任务完成后,更新并发数并将上面用await阻塞并压入队列的resolve取出并执行触发,进行唤醒作用,继续完成全局待执行的任务。
上面的并发控制阻塞部分的代码、并发任务完成后更新处理并继续唤醒下一个任务的代码,这两个部分的可以分别封装成函数,组成一个类似线程池的东西。虽然,这可能不是最优的解决方案,但对于目前这种情景,应该算是比较直观、容易理解以及容易使用的一种方案。欢迎各位可以帮忙提供更优的解决方案。

你可能感兴趣的:(技术分享)