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 性能优化的两种重要方法,希望对你们有帮助。

相关推荐
网络小白不怕黑1 小时前
HTTP响应头字段深度解析(一)
网络协议
车载测试工程师2 小时前
车载以太网网络测试-25【SOME/IP-报文格式-1】
网络·网络协议·tcp/ip
iOS技术狂热者6 小时前
多图超详细安装flutter&Android Studio开发环境,并配置插件
websocket·网络协议·tcp/ip·http·网络安全·https·udp
今夜有雨.6 小时前
HTTP---基础知识
服务器·网络·后端·网络协议·学习·tcp/ip·http
355984268550557 小时前
医保服务平台 Webpack逆向
前端·webpack·node.js
盛满暮色 风止何安9 小时前
VLAN的高级特性
运维·服务器·开发语言·网络·网络协议·网络安全·php
yangpipi-9 小时前
6. 王道_网络协议
网络·网络协议
不能只会打代码10 小时前
六十天前端强化训练之第三十一天之Webpack 基础配置 大师级讲解(接下来几天给大家讲讲工具链与工程化)
前端·webpack·node.js
techdashen12 小时前
性能比拼: TCP vs UDP(第三轮)
网络协议·tcp/ip·udp
无名之逆12 小时前
hyperlane:Rust HTTP 服务器开发的不二之选
服务器·开发语言·前端·后端·安全·http·rust