【工欲善其事】深入理解 Node.js 带并发上限的异步任务批量执行逻辑

写在前面

最近接触到很多需要用 Node.js 进行异步编程的任务,刚好遇到一个比较有代表性的,整理出来分享给大家,顺带再谈谈过程中 AI 辅助编程存在的问题,以及我为什么依旧对古法编程仍抱有一丝希望。欢迎评论留言。

一、背景介绍

Node.js 异步编程的所有相关话题中,业内公认难度较大的,当属 限定最高并发总数的异步任务的批量执行 了。最近刚好就遇到这样的场景:为了节省云存储的流量,需要在本地把所有的 png 图片压缩成体积更小的 webp 格式,提炼成一个异步任务可以表示为:

js 复制代码
const asyncTask = async (arg1, arg2) => { ... }

现在的问题是,这样的图片有上百张,将来说不定有上千张,直接用 for 循环去执行这些异步任务就是一个典型的 不带并发总量限制的异步任务的批量执行 。而我想要的是更安全的方案:并发总量 完全自主可控

二、峰回路转

不曾想在 B 站 偶然看到一篇由渡一团队的谢杰大佬分享的技术帖,里面探讨的面试题正好就是我要找的答案(意想不到的惊喜 Serendipity 了属于是):

实现一个 Scheduler 类,实现最多同时运行 n 个异步任务。实例方法 add(fn) 接收一个返回 Promise 的回调函数,方法本身则返回一个在该任务完成时 resolvePromise

js 复制代码
// 要求:最多并发 2 个任务,输出顺序应为 2 3 1 4
const timeout = (t, tag) => new Promise(
  r => setTimeout(() => (console.log(tag), r(tag)), t));

class Scheduler {
  /* TODO */
}

const scheduler = new Scheduler(2);
const addTask = (t, tag) => scheduler.add(() => timeout(t, tag));

addTask(1000, '1');
addTask(500,  '2');
addTask(300,  '3');
addTask(400,  '4');
// 期望打印:2 3 1 4

三、拿来主义

那就直接照搬过来改改:

js 复制代码
// Scheduler.js
class Scheduler {
  constructor(ConcurrencyCount = 3) {
    this.ConcurrencyCount = ConcurrencyCount; // 最大并发数
    this.tasks = []; // 等待队列
    this.runningCount = 0; // 当前正在执行的数量
    this.completedCount = 0; // 已完成任务数
  }

  add(task) {
    const { promise, resolve, reject } = Promise.withResolvers();
    this.tasks.push({ task, resolve, reject });
    this._run();
    return promise;
  }

  _run() {
    while (this.runningCount < this.ConcurrencyCount && this.tasks.length > 0) {
      const { task, resolve, reject } = this.tasks.shift();
      this.runningCount++;

      task().then(resolve)
        .catch(reject)
        .finally(() => {
          this.runningCount--;
          this.completedCount++;
          this._run(); // 尝试调度下一个任务
        });
    }
  }
}

然后在入口文件 index.js 中导入这个 Scheduler 类:

js 复制代码
import { Scheduler } from "./Scheduler.js";
import { tasks } from './tasks.js';

const concurrency = 7
const scheduler = new Scheduler(concurrency)
for (const [lbl, num] of tasks) {
  scheduler.add(async () => await markSeatOnMap(lbl, num));
}

由于每执行完一个任务都会打印一句话,于是得到了如下结果:

【图1】第一版异步执行结果

为了验证运行时确实只有七个异步任务在执行,我又在 _run() 方法的 finally 代码块加了状态日志:

js 复制代码
// Scheduler.js
class Scheduler {
  // -- snip --
  _run() {
    while (this.runningCount < this.ConcurrencyCount && this.tasks.length > 0) {
      // -- snip --
      task().then(resolve)
        .catch(reject)
        .finally(() => {
          console.log(`\t\trun: ${this.runningCount}, done: ${this.completedCount}, queued: ${this.tasks.length}`)
          this.runningCount--;
          this.completedCount++;
          this._run(); // 尝试调度下一个任务
        });
    }
  }
}

于是执行状态也监控到了,确实并发数最高为 7

【图2】带状态日志的第二版执行结果

四、DIY 扩展:反推每个执行队列中异步任务的实际执行顺序

从上一步得到的这张截图中,我们还可以推断出每个异步任务被分配到了哪一条并发执行的【流水线】(即执行队列)上:

  1. 最先完成的是 (a1, 4),对应 号线,因此第 8 个任务会被分发到 号线执行;

  2. 接着完成的是 (a1, 2),对应 号线,因此第 9 个任务会被分发到 号线执行;

  3. 然后完成的是 (a1, 1),对应 号线,因此第 10 个任务会被分发到 号线执行;

  4. 再然后完成了 (a2, 1),对应 号线,因此第 11 个任务会被分发到 号线执行;

  5. 再然后完成了 (a2, 3),对应 号线,因此第 12 个任务会被分发到 号线执行;

    ......

以此类推,直到最后一个任务被分发到最后一轮空出的那条执行线路上,整个任务分发过程才会结束。

由于每次执行的异步任务用时各不相同,每轮的执行顺序也完全不同。

于是好奇心又上来了:能否将每条线路上每个异步任务实际的执行顺序也批量统计出来?

这个问题我一开始我的思路不对,觉得挺困难的,于是求助了国内主流的几款 AI 大模型,结果还是不出意料地让人失望了:每款大模型都按照我给的错误思路,老老实实地从日志文件反推异步执行顺序,完全没能跳出我的思维误区,从实时运行的角度给出更简单的解决方案,最终全军覆没了。

主动调整思路后的古法编程版,我将关注重点放在了任务触发前后这两个状态位上:在封装的任务对象 task 上挂几个状态值,类似去银行柜台排队,除了自己的总编号外,还要记录被分配到的窗口编号。具体代码如下:

js 复制代码
// 理论执行顺序(排队顺序)
let queuing_order = 0;
// 临时栈(用于确定空缺位)
const stack = [];
// 临时栈的空缺位
let free_idx = -1;
// 开始阶段并行数有富余时,用于初始化临时栈
let seed = 0;
// 模拟线程池
const lines = new Map([
  ['0', []],
  ['1', []],
  ['2', []],
  ['3', []],
  ['4', []],
  ['5', []],
  ['6', []],
])

export class Scheduler {
  // -- snip --
  _run() {
    while (this.runningCount < this.concurrencyCount && this.tasks.length > 0) {
      const { task, resolve, reject } = this.tasks.shift();
      this.runningCount++;

      task.order = ++queuing_order // 真实执行顺序
      if(free_idx < 0){
        stack.push(task)
        task.thread = seed++;
      } else {
        stack.splice(free_idx, 1, task)
        task.thread = free_idx
      }

      task()
        .then(resolve)
        .catch(reject)
        .finally(() => {
          console.log(`\t\trun: ${this.runningCount}, done: ${this.completedCount}, queued: ${this.tasks.length}`)
          this.runningCount--;
          this.completedCount++;

          free_idx = stack.findIndex(t => t.order === task.order)
          stack.splice(free_idx, 1, '@')
          lines.get(`${task.thread}`).push(task.order)

          if(this.tasks.length === 0 && this.runningCount === 0) {
            console.log()
            console.log('line1:', lines.get('0').map(n => `${n}`.padStart(3, ' ')).join(','));
            console.log('line2:', lines.get('1').map(n => `${n}`.padStart(3, ' ')).join(','));
            console.log('line3:', lines.get('2').map(n => `${n}`.padStart(3, ' ')).join(','));
            console.log('line4:', lines.get('3').map(n => `${n}`.padStart(3, ' ')).join(','));
            console.log('line5:', lines.get('4').map(n => `${n}`.padStart(3, ' ')).join(','));
            console.log('line6:', lines.get('5').map(n => `${n}`.padStart(3, ' ')).join(','));
            console.log('line7:', lines.get('6').map(n => `${n}`.padStart(3, ' ')).join(','));
          }
          
          this._run(); // 尝试调度下一个任务
        });
    }
  }
}

这是最终运行的结果:

【图3】调整思路后得到的实际执行顺序

该截图和最开始的手动分析完全一致,当然还有很多细节有待完善,这里就不展开了。

五、其他优化空间

在最新版第四版的《Node.js Design Patterns 》一书中,也对这个控制模式进行了介绍,只不过书中的案例更加复杂,需要递归地爬取指定深度的网页链接。这本书我感觉应该算是讲 Node.js 异步编程最深刻的一本经典教材了,从最开始的回调函数讲到 Promise、再升级到后来的 async / await 语法糖,完整演示同一个问题在不同设计模式下的实现方法,值得反复回味。书中给出的控制总并发的 class 类是这样写的:

js 复制代码
import { EventEmitter } from 'node:events'

export class TaskQueue extends EventEmitter {
  constructor(concurrency) {
    super()
    this.concurrency = concurrency
    this.running = 0
    this.queue = []
  }

  pushTask(task) {
    this.queue.push(task)
    process.nextTick(this.next.bind(this))
    return this
  }

  next() {
    if (this.running === 0 && this.queue.length === 0) {
      return this.emit('empty')
    }

    while (this.running < this.concurrency && this.queue.length > 0) {
      const task = this.queue.shift()
      task()
        .catch(err => {
          this.emit('taskError', err)
        })
        .finally(() => {
          this.running--
          this.next()
        })
      this.running++
    }
  }

  stats() {
    return {
      running: this.running,
      scheduled: this.queue.length,
    }
  }
}

确实考虑得很全面,把统计状态直接放到 stats() 方法了。这就是经典教材的魅力。

相关推荐
杨杨杨大侠3 分钟前
大模型的权重到底怎么用?拆开一个 token 的生成过程
aigc·openai·ai编程
吴佳浩 Alben39 分钟前
Agent 怎么做自动化评测?构建端到端的 Agent Evaluation 体系
人工智能·语言模型·架构·自动化·ai编程
小虎AI生活1 小时前
从四大模型一周连发看企业 AI 落地,为什么 95% 的试点不赚钱
ai编程
孟健2 小时前
Gemini 3.8 Flash 没涨价,干活却贵了 40%?
ai编程
mayaairi2 小时前
Vue2 生命周期完全指南:从创建到销毁的8个钩子函数
前端·javascript·vue.js
研☆香3 小时前
一个简单的气泡弹窗制作
javascript
大模型丫丫3 小时前
用 TypeScript、NestJS 和 LangGraph 搭建一个可控的 AI Agent
javascript·人工智能·typescript
IMPYLH3 小时前
HTML 的 <pre> 元素
前端·javascript·html
shmily麻瓜小菜鸡4 小时前
JavaScript / TypeScript 易踩坑知识点 —— 异步编程类
开发语言·javascript·typescript
tsumikistep4 小时前
【前端】Vue + Axios 跨域问题实战:7070 代理转发 8080 + 401 报错分析(黑马外卖)
前端·javascript·vue.js