Node.js 性能提升秘籍:多线程与 HTTP 并发管理策略

worker_threads 多线程的使用

当进行 CPU 密集型任务(如大数据量计算)的时候,node 的默认单线程机制无法做到性能的最佳释放,cpu 占用率上不去,其实 Node.js V10.5.0 版本开始就提供了 worker_threads 模块来支持多线程操作;我们用一个例子来介绍下多线程的使用方法,看看相对于单线程,性能的提升有多大。

首先设定一个场景,比如计算 12 个单词的全排列,这是一个阶乘问题。12 的阶乘 12! 约等于 4.79 亿种组合。阶乘计算可以用一个简单的函数来实现,如下:

阶乘计算

node 复制代码
// 计算阶乘的方法
function factorial(n) {
  if (n === 0) return 1;
  return n * factorial(n - 1);
}

全排列函数计算

我们要定义一个生成器函数来生成所有的 4.79 亿种排列组合:

js 复制代码
// 生成该阶乘对应的所有排列组合数据
// 可假设 wordsList =  ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l']
function* permute(wordsList) {
  const permArr = [];
  const usedChars = [];

  function* permuteInternal(wordsList) {
    let i, ch;
    for (i = 0; i < wordsList.length; i++) {
      ch = wordsList.splice(i, 1)[0];
      usedChars.push(ch);
      if (wordsList.length === 0) {
        yield [...usedChars];
      }
      yield* permuteInternal(wordsList);
      wordsList.splice(i, 0, ch);
      usedChars.pop();
    }
  }

  yield* permuteInternal(wordsList);
}

// 通过遍历器访问每一项
for (let permutation of permutations) {
	console.log(permutation)
}

单线程效率日志

然后运行时通过打时间戳,可计算出运行速度,这一步省略,直接看结果日志:

以上是单线程执行的效果,可以看到每秒的处理速度为 41718 个左右,完成全部运算需要 3小时,下面我们引入多线程。

多线程主文件

主线程代码 (主文件),创建一个文件 main.js,内容如下:

js 复制代码
const os = require('os');
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

if (isMainThread) {
    const workers = [];
    let numCPUs = os.cpus().length; // 可根据当前设备的 cpu 核心数去决定使用多少线程

    for (let i = 0; i < useNumCPUs; i++) {
        const worker = new Worker(path.join(__dirname, 'workerEth.js'), {
            workerData: {
                knownWords: knownWordsArray,
                targetAddress,
                fileName,
                progressNum,
                start: i * permutationsPerWorker + progressNum,
                end: (i + 1) * permutationsPerWorker + progressNum,
                threadIndex: i + 1,
            }
        });
    }

    worker.on('message', (message) => {}) // 接收子进程发回的消息
    worker.on('error', (error) => {
        console.error(`Worker error: ${error}`);
    });
    worker.on('exit', (code) => {
        if (code !== 0) {
                console.error(`Worker stopped with exit code ${code}`);
        } else {
                console.log('Worker stopped successfully.');
        }
    });
    workers.push(worker); // 将所有线程都放到workers 里,以方便后面批量终止
}

多线程子线程文件

子线程文件代码概要:

js 复制代码
const { parentPort, workerData, threadId } = require('worker_threads');
const { knownWords, fileName, progressNum, start, end, threadIndex } = workerData;

parentPort.postMessage({})

在这个示例中:

主线程代码使用 isMainThread 检查当前运行环境是否为主线程,如果是主线程,它将创建 Worker 实例,指向当前文件并传递一些数据 (workerData)。

主线程还设置了一些事件处理器来处理来自 worker 的消息、错误和退出事件,Worker 子线程接收到传入的 workerData,进行计算并通过 postMessage 将结果发送回主线程。

定义 workers 数组的目的是为了在需要统一终止所有线程时可通过循环批量处理:

js 复制代码
  function terminateAllWorkers() {
    for (let worker of workers) {
      worker.terminate();
    }
  }

多线程效率日志

从最终的运行日志可看到,当使用不同线程数量时,最终的运行效率也不同。

注意点:并不是有多少核心就用多少线程哦,要综合考虑,线程过多会导致发热降频,还会影响设备执行其他任务,甚至导致系统崩溃。

因为 Node 的多线程(通过 worker_threads)与其他语言的多线程在设计理念和实现方式上有很大不同,Node.js 更注重简单性和易用性,通过非阻塞 I/O 和事件驱动模型来处理并发,从而避免了其他语言的线程同步、共享资源的竞争以及死锁等复杂问题。

http 并发问题

多线程虽然在 cpu 密集计算中能大大提升性能,不过也并不是万金油,在实际项目中性能往往受限于最短板(木桶效应 ),最常见的就是网络请求,因为 http 请求要涉及 TCP 握手、挥手、数据传输 等环节,耗时不稳定,可能在几毫秒到几秒钟之间,假设你购买了一项 api 服务,它支持 1000rps(每秒请求数),如果你不针对并发进行优化,那么要么很快超过了 1000rps 限制服务被中断,要么同步执行导致业务阻塞,所以优化 http 请求也是可以提升整体程序性能的一个重要环节。

通常情况下,并发问题只是后端考虑的多,对单个前端终端来说,很少有大量批量请求的情况,不过少并不代表没有,当真的遇到这种情况时,怎么处理呢?

bottleneck 库用法

这里介绍一个 node 库 bottleneck ,它是一款适用于 Node.js 和浏览器的轻量级零依赖任务调度器和速率限制器, 在同类库中下载量最大。(不同npm包对比 点这里

使用起来也很简单,示例代码如下:

js 复制代码
// bottleneck 写法
const Bottleneck = require('bottleneck');
const MAX_CONCURRENT_REQUESTS = 500;
const dataList = Array.from({ length: 1000 }, (_, i) => i); // 示例数据列表

const limiter = new Bottleneck({
  maxConcurrent: MAX_CONCURRENT_REQUESTS, // 最大并发数
  minTime: 10 // 最小时间间隔
});

async function fetchInfo(i) {
  // 模拟一个异步请求
  return new Promise(resolve => setTimeout(resolve, Math.random() * 1000, i));
}

async function main() {
  // 使用 Promise.all 来等待所有任务完成
  await Promise.all(
    dataList.map(async i => {
      try {
        await limiter.schedule(() => fetchInfo(i));
        console.log(`任务 ${i} 完成`);
      } catch (error) {
        console.error(`任务 ${i} 失败:`, error);
      }
    })
  );

  console.log('所有任务完成');
}

main();

最重要的两个参数就是 maxConcurrentminTime,假设你购买的 api 服务支持 1000rps, 那就可以设置 maxConcurrent 为 1000, minTime 是最小请求间隔,理论上可以设置为 0 。

原生语法实现

上面 bottleneck 模块实现的功能,我们用原生语法也是可以实现的,代码如下:

js 复制代码
// 原生写法
const MAX_CONCURRENT_REQUESTS = 500;
const tasks = [];
const ongoingTasks = new Set();

async function addTask(task) {
  ongoingTasks.add(task);
  task.finally(() => ongoingTasks.delete(task));
  tasks.push(task);
}

async function fetchInfo(i) {
  // 模拟一个异步请求
  return new Promise(resolve => setTimeout(resolve, Math.random() * 1000, i));
}

async function main() {
  for (let i of dataList) {
    const task = fetchInfo(i);
    await addTask(task);

    if (ongoingTasks.size >= MAX_CONCURRENT_REQUESTS) {
      await Promise.race(ongoingTasks); // 等待任意一个任务完成
    }
  }

  await Promise.all(tasks);
}

const dataList = Array.from({ length: 1000 }, (_, i) => i);

main().then(() => console.log('所有任务完成'));

这两种写法上最大的区别是,bottleneck 库不需要手动控制最大并发数和时间间隔了,bottleneck 提供了 schedule 方法,用于调度任务,并自动处理并发控制和时间间隔,使用起来更简便。

而用原生写法,则需要手动控制并发请求数和时间间隔,这增加了代码复杂度,当达到最大并发数后要加 await 等待任意一个 api 任务完成,才能继续流程,任务队列补上下一个,时间间隔还需要额外增加逻辑处理。

总结

本文介绍了 node 性能优化的两种重要方法,希望对你们有帮助。

相关推荐
前端双越老师44 分钟前
30 行代码 langChain.js 开发你的第一个 Agent
人工智能·node.js·agent
诗句藏于尽头8 小时前
完成ssl不安全警告
网络协议·安全·ssl
浪裡遊10 小时前
React Hooks全面解析:从基础到高级的实用指南
开发语言·前端·javascript·react.js·node.js·ecmascript·php
whale fall11 小时前
npm install安装的node_modules是什么
前端·npm·node.js
会飞的鱼先生11 小时前
Node.js-http模块
网络协议·http·node.js
用户35218024547514 小时前
MCP极简入门:node+idea运行简单的MCP服务和MCP客户端
node.js·ai编程
-qOVOp-15 小时前
408第三季part2 - 计算机网络 - ip分布首部格式与分片
网络协议·tcp/ip·计算机网络
数通Dinner15 小时前
RSTP 拓扑收敛机制
网络·网络协议·tcp/ip·算法·信息与通信
G等你下课20 小时前
AJAX请求跨域问题
前端·javascript·http
觅_20 小时前
Node.js 的线程模型
node.js