Node有很多优秀的流程控制库,比如async、bluebird等。但是为了学习,我们不使用这些优秀的第三方库,而是我们自己来思考如何实现并行任务的控制。
在Node中,如果我们要并行执行多个任务,可以使用Promise.all来实现,如下面伪代码:
const result = await Promise.all([
readFileAync, // 异步读取文件
countDocument, // 统计数据库文档数
loadRemoteConfig, // 加载远程配置
]);
上面的写法中,虽然任务都是同时触发的,但是无法限制同一时刻触发的任务数。如果是大量读取数据库的操作,不加以限制可能会对数据库服务造成影响。
因此,我们需要想办法去限制并发数。那该如何去限制呢?
我们可以用最大并行数和当前进行中的任务数两个变量,来控制同一时刻并行运行的最大任务数。具体实现的步骤如下:
- 设置任务最大并行数,并将当前进行中的任务数初始化为零;
- 判断当前进行中的任务数是否小于最大并行数,如果满足条件则立即执行任务,并将当前进行中的任务数加一。否则,就将新的任务入队等待执行;
- 任务执行完成后,需要将当前进行中的任务数减一,以便队列中的任务可以正常执行;
- 一旦当前进行中的任务数小于最大并行数,就从队列中取出任务执行,直到队列为空。
在理解完上面的执行逻辑后,我们正式进入编码环节。
首先,我们需要定义上文中所指的两个控制变量,以及一个用来存放待执行任务的队列。因此,我们可以定义这样一个TQ类:
class TQ {
queue; // 待执行任务队列
count; // 当前进行中的任务计数
concurrency; // 并行数
constructor(concurrency) {
this.queue = [];
this.count = 0;
this.concurrency = concurrency || 3;
}
}
接着我们还需要定义入队/出队方法:
// 入队
enqueue(handler) {
if (typeof handler !== 'function') {
throw new Error('"handler" must be a function object')
}
return new Promise((resolve, reject) => {
this.queue.push({
handler, resolve, reject,
});
});
}
// 出队
dequeue() {
if (this.count > this.concurrency || this.queue.length <= 0) {
return 0;
}
const {
handler, resolve, reject,
} = this.queue.shift();
this.run(handler)
.then(resolve)
.catch(reject);
return 1;
}
值得注意的是,入队方法enqueue返回的是一个Promise对象,只有在任务执行完成(成功或者失败)时才返回。如果只返回一个普通对象,那么就无法获取任务的执行结果了。
在执行任务前后,需要同步维护当前进行中的任务计数的值。因此,我们可以定义一个任务执行器方法:
// 任务执行器
async run(handler) {
this.count += 1;
const reply = await handler();
this.count -= 1;
this.dequeue();
return reply;
}
每次在添加新任务时,要根据当前进行中的任务数判断是立即执行任务,还是放入队列中等待执行,所以还需要一个任务调度器方法:
// 任务调度器
async dispatch(handler) {
if (this.count < this.concurrency) {
return this.run(handler);
}
return this.enqueue(handler);
}
最后,通过Promise.all方法将任务列表逐一通过dispatch方法调度执行,就可以并行的执行任务且一次至多执行N个。
完整的示例代码可以参考:传送门