头图

Node.js扩展:你需要了解的多线程

原文链接:https://dev.to/leapcell/scaling-nodejs-multi-threading-you-need-to-know-2nhi
作者:Leapcell
译者:倔强青铜三

前言

大家好,我是倔强青铜三。是一名热情的软件工程师,我热衷于分享和传播IT技术,致力于通过我的知识和技能推动技术交流与创新,欢迎关注我,微信公众号:倔强青铜三。欢迎点赞、收藏、关注,一键三连!!!

Node.js由于其单线程特性,主线程用于执行非阻塞I/O操作。然而,在执行CPU密集型任务时,仅依赖单个线程可能会导致性能瓶颈。幸运的是,Node.js提供了几种启用和管理线程的方法,使应用程序能够充分利用多核CPU。

为什么要启用子线程?

在Node.js中启用子线程的主要原因是为了处理并发任务并提高应用程序性能。Node.js本质上基于事件循环、单线程模型,这意味着所有I/O操作(如文件读写和网络请求)都是非阻塞的。然而,CPU密集型任务(如大规模计算)可能会阻塞事件循环,影响应用程序的整体性能。

启用子线程有助于解决以下问题:

  • 非阻塞操作:Node.js的设计理念围绕非阻塞I/O。然而,如果直接在主线程中执行外部命令,执行过程可能会阻塞主线程,影响应用程序的响应性。通过在子线程中执行这些命令,主线程保持其非阻塞特性,确保其他并发操作不受影响。
  • 高效利用系统资源:通过使用子进程或工作线程,Node.js应用程序可以更好地利用多核CPU的计算能力。这对于执行CPU密集型外部命令特别有用,因为它们可以在单独的CPU核心上运行,而不影响Node.js的主事件循环。
  • 隔离和安全:在子线程中运行外部命令为应用程序增加了一层额外的安全性。如果外部命令失败或崩溃,这种隔离有助于保护主Node.js进程不受影响,从而提高应用程序的稳定性。
  • 灵活的数据处理和通信:通过子线程,可以在将外部命令的输出传递回主线程之前进行灵活处理。Node.js提供了多种实现进程间通信(IPC)的方法,使数据交换无缝进行。

启用子线程的方法

接下来,我们将探讨在Node.js中启用子线程的不同方法。

子进程

Node.js的child_process模块允许通过创建子进程来运行系统命令或其他程序,这些子进程可以与主线程通信。这对于执行CPU密集型任务或运行其他应用程序非常有用。

spawn()

child_process模块中的spawn()方法用于创建一个新的子进程,执行指定的命令。它返回一个具有stdoutstderr流的对象,允许与子进程进行交互。此方法适用于长时间运行且产生大量输出的进程,因为它以流的形式处理数据,而不是一次性缓冲所有数据。

spawn()函数的基本语法是:

const { spawn } = require('child_process');
const child = spawn(command, [args], [options]);
  • command:要执行的命令的字符串表示。
  • args:命令行参数的字符串数组。
  • options:一个可选对象,用于配置子进程的创建方式。常见选项包括:

    • cwd:子进程的工作目录。
    • env:包含环境变量的对象。
    • stdio:配置子进程的标准输入/输出,通常用于管道操作或文件重定向。
    • shell:如果为true,则在shell中运行命令。默认shell是Unix上的/bin/sh和Windows上的cmd.exe
    • detached:如果为true,子进程独立于父进程运行,并且可以在父进程退出后继续运行。

以下是使用spawn()的简单示例:

const { spawn } = require('child_process');
const path = require('path');

// 使用'touch'命令创建一个名为'moment.txt'的文件
const touch = spawn('touch', ['moment.txt'], {
  cwd: path.join(process.cwd(), './m'),
});

touch.on('close', (code) => {
  if (code === 0) {
    console.log('文件创建成功');
  } else {
    console.error(`创建文件出错,退出代码:${code}`);
  }
});

此代码的目的是在当前工作目录的m子目录中创建一个名为moment.txt的空文件。如果成功,将打印成功消息;否则,将显示错误消息。

exec()

child_process模块中的exec()方法用于创建一个新的子进程,执行给定的命令,并缓冲任何输出。与spawn()不同,exec()更适合输出较小的场景,因为它将子进程的stdoutstderr存储在内存中。

exec()的基本语法是:

const { exec } = require('child_process');

exec(command, [options], callback);
  • command:要执行的命令的字符串。
  • options:可选参数,用于自定义执行环境。
  • callback:接收(error, stdout, stderr)作为参数的回调函数。

options对象可以包括:

  • cwd:设置子进程的工作目录。
  • env:指定环境变量对象。
  • encoding:字符编码。
  • shell:指定用于执行的shell(Unix上的/bin/sh,Windows上的cmd.exe)。
  • timeout:以毫秒为单位的超时时间;如果执行时间超过此时间,子进程将被终止。
  • maxBufferstdoutstderr的最大缓冲区大小(默认:1024 * 1024或1MB)。
  • killSignal:用于终止进程的信号(默认:'SIGTERM')。

回调函数接收:

  • error:如果命令执行失败或返回非零退出代码,则为Error对象;否则为null
  • stdout:命令的标准输出。
  • stderr:命令的标准错误输出。

以下是使用exec()的示例:

const { exec } = require('child_process');
const path = require('path');

// 定义要执行的命令,包括文件路径
const command = `touch ${path.join('./m', 'moment.txt')}`;

exec(command, { cwd: process.cwd() }, (error, stdout, stderr) => {
  if (error) {
    console.error(`执行命令出错:${error}`);
    return;
  }
  if (stderr) {
    console.error(`标准错误输出:${stderr}`);
    return;
  }
  console.log('文件创建成功');
});

运行此代码将创建文件并显示相应的输出。

fork()

child_process模块中的fork()方法是一种专门的方法,用于创建一个新的Node.js进程,通过进程间通信(IPC)通道与父进程通信。fork()在单独运行Node.js模块时特别有用,并且对于在多核CPU上并行执行非常有益。

fork()的基本语法是:

const { fork } = require('child_process');

const child = fork(modulePath, [args], [options]);
  • modulePath:要在子进程中运行的模块的路径的字符串。
  • args:传递给模块的字符串数组。
  • options:一个可选对象,用于配置子进程。

options对象可以包括:

  • cwd:子进程的工作目录。
  • env:包含环境变量的对象。
  • execPath:用于创建子进程的Node.js可执行文件的路径。
  • execArgv:传递给Node.js可执行文件但不传递给模块本身的参数列表。
  • silent:如果为true,则将子进程的stdinstdoutstderr重定向到父进程;否则,它们继承自父进程。
  • stdio:配置标准输入/输出流。
  • ipc:为父进程和子进程之间的通信创建IPC通道。

使用fork()创建的子进程自动建立IPC通道,允许父进程和子进程之间进行消息传递。父进程可以使用child.send(message)发送消息,子进程可以使用process.on('message', callback)监听这些消息。同样,子进程可以使用process.send(message)向父进程发送消息。

以下是演示如何使用fork()创建子进程并通过IPC通信的示例:

index.js(父进程)

const { fork } = require('child_process');

const child = fork('./child.js');

child.on('message', (message) => {
  console.log('从子进程收到的消息:', message);
});

child.send({ hello: 'world' });

setInterval(() => {
  child.send({ hello: 'world' });
}, 1000);

child.js(子进程)

process.on('message', (message) => {
  console.log('从父进程收到的消息:', message);
});

process.send({ foo: 'bar' });

setInterval(() => {
  process.send({ hello: 'world' });
}, 1000);

在此示例中,父进程(index.js)创建了一个运行child.js的子进程。父进程向子进程发送消息,子进程接收并记录消息,然后通过process.send()发送响应。父进程还记录从子进程收到的消息。定时器确保周期性的消息交换。

使用fork(),每个子进程作为单独的Node.js实例运行,具有自己的V8引擎和事件循环。这意味着创建过多的子进程可能会导致高资源消耗。

工作线程

Node.js中的worker_threads模块提供了一种在单个进程中并行运行多个JavaScript任务的机制。这允许应用程序充分利用多核CPU资源,特别是对于CPU密集型任务,而无需启动多个进程。使用worker_threads可以显著提高性能并实现复杂计算。

工作线程的关键概念:

  • 工作线程:执行JavaScript代码的独立线程。每个工作线程在自己的V8实例中运行,具有自己的事件循环和局部变量,这意味着它可以独立于主线程或其他工作线程运行。
  • 主线程:启动工作线程的线程。在典型的Node.js应用程序中,初始的JavaScript执行环境(事件循环)在主线程上运行。
  • 通信:主线程和工作线程通过传递消息进行通信。它们可以发送JavaScript值,包括ArrayBuffer和其他可转移对象,允许高效的数据传输。

以下是演示如何在主线程和工作线程之间创建工作线程并进行通信的基本示例:

const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  // 主线程
  const worker = new Worker(__filename);
  worker.on('message', (message) => {
    console.log('从工作线程收到的消息:', message);
  });
  worker.postMessage('你好,工作线程!');
} else {
  // 工作线程
  parentPort.on('message', (message) => {
    console.log('从主线程收到的消息:', message);
    parentPort.postMessage('你好,主线程!');
  });
}

在此示例中,index.js文件既作为主线程的入口点,也作为工作线程的脚本。通过检查isMainThread,脚本确定它是运行在主线程还是作为工作线程。主线程创建一个执行相同脚本的工作线程,然后向工作线程发送消息。工作线程通过postMessage()响应。

worker_threadsfork()的区别

概念:

  • worker_threads:使用工作线程在同一个进程中并行执行JavaScript代码。
  • fork():启动一个单独的Node.js进程,每个进程都有自己的V8实例和事件循环。

通信:

  • worker_threads:使用MessagePort传递JavaScript值,包括ArrayBufferMessageChannel
  • fork():通过process.send()message事件使用进程间通信(IPC)。

内存使用:

  • worker_threads:共享内存,减少冗余数据复制,允许更好的性能。
  • fork():每个forked进程都有独立的内存空间和自己的V8实例,导致更高的内存使用。

最佳用例:

  • worker_threads:适用于CPU密集型计算和并行处理。
  • fork():适用于运行独立的Node.js应用程序或隔离服务。

总体而言,是否使用worker_threadsfork()取决于您的应用程序需求。如果需要严格的进程隔离,fork()可能是更好的选择。然而,如果需要高效的并行计算和数据处理,worker_threads提供了更好的性能和资源利用率。

集群(Clustering)

Node.js中的cluster模块允许创建共享同一服务器端口的子进程。这使得Node.js应用程序能够跨多个CPU核心运行,提高性能和吞吐量。由于Node.js是单线程的,其非阻塞I/O操作非常适合处理许多并发连接。然而,对于CPU密集型任务或在多个核心之间分配工作负载时,使用cluster模块特别有用。

cluster模块的基本工作原理是允许主进程(通常称为“主进程”)创建多个工作进程,这些工作进程实际上是主进程的副本。主进程管理这些工作进程,并在它们之间分配传入的网络连接。

内部,cluster模块使用child_process.fork()创建工作进程,这意味着每个工作进程都运行相同的应用程序代码。关键区别在于它们可以通过IPC(进程间通信)与主进程通信。

以下是使用cluster模块的简单示例:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`主进程 ${process.pid} 正在运行`);

  // 启动工作进程
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`工作进程 ${worker.process.pid} 已退出`);
  });
} else {
  // 工作进程可以共享任何TCP连接
  // 在此示例中,它们创建一个HTTP服务器
  http
    .createServer((req, res) => {
      res.writeHead(200);
      res.end('hello world\n');
    })
    .listen(8000);

  console.log(`工作进程 ${process.pid} 已启动`);
}

运行此脚本并请求服务器时,您将在日志中看到不同的进程ID,表明多个工作进程正在处理请求。

在此示例中,主进程根据CPU核心数量创建工作进程。每个工作进程独立运行,处理传入的HTTP请求。如果工作进程退出,主进程将通过exit事件收到通知。

虽然cluster模块提高了性能和可靠性,但也增加了复杂性,例如管理工作进程生命周期和处理进程间通信。在某些情况下,使用进程管理器(例如pm2)可能是更合适的选择。

然而,cluster模块并非所有应用程序都必需。对于非CPU密集型应用程序,单个Node.js实例可能足以处理所有工作负载。

总结

子进程允许Node.js应用程序执行操作系统命令或运行独立的Node.js模块,提高并发处理能力。通过使用exec()spawn()fork()等API,开发人员可以灵活地创建和管理子进程,实现复杂的异步和非阻塞操作。这使应用程序能够充分利用系统资源和多核CPU的优势,而不干扰主事件循环。

通过选择合适的线程方法——无论是子进程、工作线程还是集群——您可以优化Node.js应用程序的性能和可扩展性。

最后感谢阅读!欢迎关注我,微信公众号:倔强青铜三。欢迎点赞收藏关注,一键三连!!!

倔强青铜三
41 声望0 粉丝