我是靠谱客的博主 害怕战斗机,最近开发中收集的这篇文章主要介绍Node.js 应用开发详解05 多进程解决方案:cluster 模式以及 PM2 工具的原理介绍,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

前几讲我们都使用了一种非常简单暴力的方式(node app.js)启动 Node.js 服务器,而在线上我们要考虑使用多核 CPU,充分利用服务器资源,这里就用到多进程解决方案,所以本讲介绍 PM2 的原理以及如何应用一个 cluster 模式启动 Node.js 服务。

单线程问题

在《01 | 事件循环:高性能到底是如何做到的?》中我们分析了 Node.js 主线程是单线程的,如果我们使用 node app.js 方式运行,就启动了一个进程,只能在一个 CPU 中进行运算,无法应用服务器的多核 CPU,因此我们需要寻求一些解决方案。你能想到的解决方案肯定是多进程分发策略,即主进程接收所有请求,然后通过一定的负载均衡策略分发到不同的 Node.js 子进程中。如图 1 的方案所示:

Drawing 1.png

这一方案有 2 个不同的实现:

  • 主进程监听一个端口,子进程不监听端口,通过主进程分发请求到子进程;

  • 主进程和子进程分别监听不同端口,通过主进程分发请求到子进程。

在 Node.js 中的 cluster 模式使用的是第一个实现。

cluster 模式

cluster 模式其实就是我们上面图 1 所介绍的模式,一个主进程多个子进程,从而形成一个集群的概念。我们先来看看 cluster 模式的应用例子。

应用

我们先实现一个简单的 app.js,代码如下:

const http = require('http');
/**
 * 
 * 创建 http 服务,简单返回
 */
const server = http.createServer((req, res) => {
    res.write(`hello world, start with cluster ${process.pid}`);
    res.end();
});
/**
 * 
 * 启动服务
 */
server.listen(3000, () => {
    console.log('server start http://127.0.0.1:3000');
});
console.log(`Worker ${process.pid} started`);

这是最简单的一个 Node.js 服务,接下来我们应用 cluster 模式来包装这个服务,代码如下:

const cluster = require('cluster');
const instances = 2; // 启动进程数量
if (cluster.isMaster) {
    for(let i = 0;i<instances;i++) { // 使用 cluster.fork 创建子进程
        cluster.fork();
    }
} else {
    require('./app.js');
}

首先判断是否为主进程:

  • 如果是则使用 cluster.fork 创建子进程;

  • 如果不是则为子进程 require 具体的 app.js。

然后运行下面命令启动服务。

$ node cluster.js

启动成功后,再打开另外一个命令行窗口,多次运行以下命令:

curl "http://127.0.0.1:3000/"

你可以看到如下输出:

hello world, start with cluster 4543
hello world, start with cluster 4542
hello world, start with cluster 4543
hello world, start with cluster 4542

后面的进程 ID 是比较有规律的随机数,有时候输出 4543,有时候输出 4542,4543 和 4542 就是我们 fork 出来的两个子进程,接下来我们看下为什么是这样的。

原理

首先我们需要搞清楚两个问题:

  • Node.js 的 cluster 是如何做到多个进程监听一个端口的;

  • Node.js 是如何进行负载均衡请求分发的。

多进程端口问题

在 cluster 模式中存在 master 和 worker 的概念,master 就是主进程worker 则是子进程,因此这里我们需要看下 master 进程和 worker 进程的创建方式。如下代码所示:

const cluster = require('cluster');
const instances = 2; // 启动进程数量
if (cluster.isMaster) {
    for(let i = 0;i<instances;i++) { // 使用 cluster.fork 创建子进程
        cluster.fork();
    }
} else {
    require('./app.js');
}

这段代码中,第一次 require 的 cluster 对象就默认是一个 master,这里的判断逻辑在源码中,如下代码所示:

'use strict';
<span class="hljs-keyword">const</span> childOrPrimary = <span class="hljs-string">'NODE_UNIQUE_ID'</span> <span class="hljs-keyword">in</span> process.env ? <span class="hljs-string">'child'</span> : <span class="hljs-string">'primary'</span>;
<span class="hljs-built_in">module</span>.exports = <span class="hljs-built_in">require</span>(<span class="hljs-string">`internal/cluster/<span class="hljs-subst">${childOrPrimary}</span>`</span>);

通过进程环境变量设置来判断:

  • 如果没有设置则为 master 进程;

  • 如果有设置则为子进程。

因此第一次调用 cluster 模块是 master 进程,而后都是子进程。

主进程和子进程 require 文件不同:

  • 前者是 internal/cluster/primary;

  • 后者是 internal/cluster/child。

我们先来看下 master 进程的创建过程,这部分代码在这里。

可以看到 cluster.fork,一开始就会调用 setupPrimary 方法,创建主进程,由于该方法是通过 cluster.fork 调用,因此会调用多次,但是该模块有个全局变量 initialized 用来区分是否为首次,如果是首次则创建,否则则跳过,如下代码:

  if (initialized === true)
	    return process.nextTick(setupSettingsNT, settings);
  initialized = <span class="hljs-literal">true</span>;

接下来继续看 cluster.fork 方法,源码如下:

cluster.fork = function(env) {
	  cluster.setupPrimary();
	  const id = ++ids;
	  const workerProcess = createWorkerProcess(id, env);
	  const worker = new Worker({
	    id: id,
	    process: workerProcess
	  });
  worker.on(<span class="hljs-string">'message'</span>, <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">message, handle</span>) </span>{
    cluster.emit(<span class="hljs-string">'message'</span>, <span class="hljs-keyword">this</span>, message, handle);
  });

在上面代码中第 2 行就是创建主进程,第 4 行就是创建 worker 子进程,在这个 createWorkerProcess 方法中,最终是使用 child_process 来创建子进程的。在初始化代码中,我们调用了两次 cluster.fork 方法,因此会创建 2 个子进程,在创建后又会调用我们项目根目录下的 cluster.js 启动一个新实例,这时候由于 cluster.isMaster 是 false,因此会 require 到 internal/cluster/child 这个方法。

由于是 worker 进程,因此代码会 require ('./app.js') 模块,在该模块中会监听具体的端口,代码如下:

/**
 * 
 * 启动服务
 */
server.listen(3000, () => {
    console.log('server start http://127.0.0.1:3000');
});
console.log(`Worker ${process.pid} started`);

这里的 server.listen 方法很重要,这部分源代码在这里,其中的 server.listen 会调用该模块中的 listenInCluster 方法,该方法中有一个关键信息,如下代码所示:

if (cluster.isPrimary || exclusive) {
	    // Will create a new handle
	    // _listen2 sets up the listened handle, it is still named like this
	    // to avoid breaking code that wraps this method
	    server._listen2(address, port, addressType, backlog, fd, flags);
	    return;
	  }
  <span class="hljs-keyword">const</span> serverQuery = {
    <span class="hljs-attr">address</span>: address,
    <span class="hljs-attr">port</span>: port,
    <span class="hljs-attr">addressType</span>: addressType,
    <span class="hljs-attr">fd</span>: fd,
    flags,
  };

  <span class="hljs-comment">// Get the primary's server handle, and listen on it</span>
  cluster._getServer(server, serverQuery, listenOnPrimaryHandle);

上面代码中的第 6 行,判断为主进程,就是真实的监听端口启动服务,而如果非主进程则调用 cluster._getServer 方法,也就是 internal/cluster/child 中的 cluster._getServer 方法。

接下来我们看下这部分代码:

obj.once('listening', () => {
	    cluster.worker.state = 'listening';
	    const address = obj.address();
	    message.act = 'listening';
	    message.port = (address && address.port) || options.port;
	    send(message);
	  });

这一代码通过 send 方法,如果监听到 listening 发送一个消息给到主进程,主进程也有一个同样的 listening 事件,监听到该事件后将子进程通过 EventEmitter 绑定在主进程上,这样就完成了主子进程之间的关联绑定,并且只监听了一个端口。而主子进程之间的通信方式,就是我们常听到的 IPC 通信方式

负载均衡原理

既然 Node.js cluster 模块使用的是主子进程方式,那么它是如何进行负载均衡处理的呢,这里就会涉及 Node.js cluster 模块中的两个模块。

  • round_robin_handle.js(非 Windows 平台应用模式),这是一个轮询处理模式,也就是轮询调度分发给空闲的子进程,处理完成后回到 worker 空闲池子中,这里要注意的就是如果绑定过就会复用该子进程,如果没有则会重新判断,这里可以通过上面的 app.js 代码来测试,用浏览器去访问,你会发现每次调用的子进程 ID 都会不变。

  • shared_handle.js( Windows 平台应用模式),通过将文件描述符、端口等信息传递给子进程,子进程通过信息创建相应的 SocketHandle / ServerHandle,然后进行相应的端口绑定和监听、处理请求。

以上就是 cluster 的原理,总结一下就是 cluster 模块应用 child_process 来创建子进程,子进程通过复写掉 cluster._getServer 方法,从而在 server.listen 来保证只有主进程监听端口,主子进程通过 IPC 进行通信,其次主进程根据平台或者协议不同,应用两种不同模块(round_robin_handle.js 和 shared_handle.js)进行请求分发给子进程处理。接下来我们看一下 cluster 的成熟的应用工具 PM2 的应用和原理。

PM2 原理

PM2 是守护进程管理器,可以帮助你管理和保持应用程序在线。PM2 入门非常简单,它是一个简单直观的 CLI 工具,可以通过 NPM 安装,接下来我们看下一些简单的用法。

应用

你可以使用如下命令进行 NPM 或者 Yarn 的安装:

$ npm install pm2@latest -g
# or
$ yarn global add pm2

安装成功后,可以使用如下命令查看是否安装成功以及当前的版本:

$ pm2 --version

接下来我们使用 PM2 启动一个简单的 Node.js 项目,进入本讲代码的项目根目录,然后运行下面命令:

$ pm2 start app.js

运行后,再执行如下命令:

$ pm2 list

可以看到如图 2 所示的结果,代表运行成功了。

Drawing 2.png

图 2 pm2 list 运行结果

PM2 启动时可以带一些配置化参数,具体参数列表你可以参考官方文档。在开发中我总结出了一套最佳的实践,如以下配置所示:

module.exports = {
    apps : [{
      name: "nodejs-column", // 启动进程名
      script: "./app.js", // 启动文件
      instances: 2, // 启动进程数
      exec_mode: 'cluster', // 多进程多实例
      env_development: {
        NODE_ENV: "development",
        watch: true, // 开发环境使用 true,其他必须设置为 false
      },
      env_testing: {
        NODE_ENV: "testing",
        watch: false, // 开发环境使用 true,其他必须设置为 false
      },
      env_production: {
        NODE_ENV: "production",
        watch: false, // 开发环境使用 true,其他必须设置为 false
      },
      log_date_format: 'YYYY-MM-DD HH:mm Z',
      error_file: '~/data/err.log', // 错误日志文件,必须设置在项目外的目录,这里为了测试
      out_file: '~/data/info.log', //  流水日志,包括 console.log 日志,必须设置在项目外的目录,这里为了测试
      max_restarts: 10,
    }]
  }

在上面的配置中要特别注意 error_fileout_file,这里的日志目录在项目初始化时要创建好,如果不提前创建好会导致线上运行失败,特别是无权限创建目录时。其次如果存在环境差异的配置时,可以放置在不同的环境下,最终可以使用下面三种方式来启动项目,分别对应不同环境。

$ pm2 start pm2.config.js --env development
$ pm2 start pm2.config.js --env testing
$ pm2 start pm2.config.js --env production

原理

接下来我们来看下是如何实现的,由于整个项目是比较复杂庞大的,这里我们主要关注进程创建管理的原理

首先我们来看下进程创建的方式,整体的流程如图 3 所示。

Drawing 3.png

图 3 PM2 源码多进程创建方式

这一方式涉及五个模块文件。

  • CLI(lib/binaries/CLI.js)处理命令行输入,如我们运行的命令:

pm2 start pm2.config.js --env development
  • API(lib/API.js)对外暴露的各种命令行调用方法,比如上面的 start 命令对应的 API->start 方法。

  • Client (lib/Client.js)可以理解为命令行接收端,负责创建守护进程 Daemon,并与 Daemon(lib/Daemon.js)保持 RPC 连接。

  • God (lib/God.js)主要负责进程的创建和管理,主要是通过 Daemon 调用,Client 所有调用都是通过 RPC 调用 Daemon,然后 Daemon 调用 God 中的方法。

  • 最终在 God 中调用 ClusterMode(lib/God/ClusterMode.js)模块,在 ClusterMode 中调用 Node.js 的 cluster.fork 创建子进程。

图 3 中首先通过命令行解析调用 API,API 中的方法基本上是与 CLI 中的命令行一一对应的,API 中的 start 方法会根据传入参数判断是否是调用的方法,一般情况下使用的都是一个 JSON 配置文件,因此调用 API 中的私有方法 _startJson。

接下来就开始在 Client 模块中流转了,在 _startJson 中会调用 executeRemote 方法,该方法会先判断 PM2 的守护进程 Daemon 是否启动,如果没有启动会先调用 Daemon 模块中的方法启动守护进程 RPC 服务,启动成功后再通知 Client 并建立 RPC 通信连接。

成功建立连接后,Client 会发送启动 Node.js 子进程的命令 prepare,该命令传递 Daemon,Daemon 中有一份对应的命令的执行方法,该命令最终会调用 God 中的 prepare 方法。

在 God 中最终会调用 God 文件夹下的 ClusterMode 模块,应用 Node.js 的 cluster.fork 创建子进程,这样就完成了整个启动过程。

综上所述,PM2 通过命令行,使用 RPC 建立 Client 与 Daemon 进程之间的通信,通过 RPC 通信方式,调用 God,从而应用 Node.js 的 cluster.fork 创建子进程的。以上是启动的流程,对于其他命令指令,比如 stop、restart 等,也是一样的通信流转过程,你参照上面的流程分析就可以了,如果遇到任何问题,都可以在留言区与我交流。

以上的分析你需要参考PM2 的 GitHub 源码。

总结

本讲主要介绍了 Node.js 中的 cluster 模块,并深入介绍了其核心原理,其次介绍了目前比较常用的多进程管理工具 PM2 的应用和原理。学完本讲后,需要掌握 Node.js cluster 原理,并且掌握 PM2 的实现原理。

接下来我们将开始讲解一些关于 Node.js 性能相关的知识,为后续的高性能服务做一定的准备,其次也在为后续性能优化打下一定的技术基础。

下一讲会讲解,目前我们在使用的 Node.js cluster 模式存在的性能问题。



精选评论

**6400:

老师,我想问下进程,线程,实例和cpu核数的关系。1.实例值的就是一台PC,一台服务器吗?2.进程数和CPU核数有什么联系,一个CPU核可以有几个进程,几个线程呢?3.假如我的服务器有8个实例,每个实例2核Cpu,如何配置pm2可以发挥最大性能呢?

    讲师回复:

    1. 这个应该叫做一个服务会更好一些,实例在我们里面所说的是一个子进程;

  1. 一般 CPU 核数和子进程个数是没有关联的,但是在应用时最好单个服务起的 Node.js 进程不能等于或超过 CPU 核数,如果大于等于时,当Node.js进程跑满时,就会导致 CPU 超负荷,从而机器瘫痪的现象。
  2. 8 个服务,每个服务2个进程(一个进程占用一个 CPU 核数),这其实要看你机器的配置,以及每个服务的压力情况。举个例子,假设你服务器只有 4 核,并且其中有 2 个服务请求压力并发较大,你如果配置的这2个服务正好是2个进程,那么很有可能导致整个服务器资源跑满的情况。其实 PM2 背后还是 cluster 模式,而 cluster 模式下你需要根据当前机器的配置,以及你服务的压力来判断要启用多少个子进程,但是核心是不要超出当前 CPU 核数。
**喝王老吉:

想问一下老师,对于大文件的读写,比如几个G的日志,要分析,会内存溢出,应该怎么办?

    讲师回复:

    最简单的就是文件拆分,超过一定文件大小时自动拆分为多个文件,其次就是使用文件流。

**0971:

不是很理解,一个主进程+两个子进程,那就是3个进程,但pm2 list时我们只会看到两个子进程,而且不少文章都是根据cpu个数来创建子进程,那加上主进程,不就比cpu还多了吗?

    讲师回复:

    pm2是只给你看了子进程,并不是所有的进程,pm2 自身就有一个独立的进程。为了更清晰,你可以把我们源码中的 cluster.js 在运行一次,使用 node cluster.js ,然后你使用 ps -ef | grep cluster.js | grep -v grep 。你可以看下有几个进程信息,其次你可以看下每个进程的PID 以及它父PID。

**森:

源代码中的 cluster.js 为什么执行了三次?if里面执行了一次,else里面执行了两次

    讲师回复:

    是的哈。
https://github.com/nodejs/node/blob/master/lib/internal/cluster/primary.js
这里是调用 cluster.fork 的地方,这部分会调用createWorkerProcess创建子进程,这个方法中会调用 fork 方法,而 fork 在文件头部有申明const { fork } = require(‘child_process’);因此最终会将启动参数命令行传递给 child_process 的 fork 方法来启动一个新的进程,再细致点,你可以看到它会把启动相关的参数都传递给 fork 这个函数。具体可以看下这个方法的官方介绍 https://nodejs.org/api/child_process.html#child_process_child_process_fork_modulepath_args_options 。


因为问的同学比较多,我在补充下

  1. cluster.js 首次运行的时候,isMaster 肯定是 true 这时候会创建一个父进程
  2. 在我们代码中调用了两次的 cluster.fork() 方法
  3. cluster.fork() 源码中会调用 child_process 的 fork 方法来启动新的进程,在调用 cluster.fork() 时会将进程运行参数全部传递给 child_process fork() 方法,通过这个方法启动了一个新进程;
  4. child_process.fork() 的时候会重新运行 node 的启动命令,这时候就会重新运行 cluster.js 这个文件,而这时候 isMaster 是 false 所以会去 requre(‘app.js’) 了。这里还有一个点,就是在 fork 子进程的时候,如果父进程还没有创建,会创建父进程。
  5. 在 app.js 中虽然是启动了监听端口,由于监听端口的方法被重写了,因此只是向主进程发送了一个消息,告诉父进程可以向我发送消息了,因此可以一个端口多个进程来服务。
**南:

引用 ‘cluster.js 为啥执行三次’这个问题,我通过专栏的例子去测试,创建了cluster之后启动,我在cluster.js里面打印了 “cluster.isMaster”的值,在通过 node 命令启动后,打印了三次,一次 true,两次 false,是每一次进程的启动都会触发cluster.js 这个文件的执行吗?

    讲师回复:

    是的哈。
https://github.com/nodejs/node/blob/master/lib/internal/cluster/primary.js
这里是调用 cluster.fork 的地方,这部分会调用createWorkerProcess创建子进程,这个方法中会调用 fork 方法,而 fork 在文件头部有申明const { fork } = require(‘child_process’);因此最终会将启动参数命令行传递给 child_process 的 fork 方法来启动一个新的进程,再细致点,你可以看到它会把启动相关的参数都传递给 fork 这个函数。具体可以看下这个方法的官方介绍 https://nodejs.org/api/child_process.html#child_process_child_process_fork_modulepath_args_options 。


因为问的同学比较多,我在补充下

  1. cluster.js 首次运行的时候,isMaster 肯定是 true 这时候会创建一个父进程
  2. 在我们代码中调用了两次的 cluster.fork() 方法
  3. cluster.fork() 源码中会调用 child_process 的 fork 方法来启动新的进程,在调用 cluster.fork() 时会将进程运行参数全部传递给 child_process fork() 方法,通过这个方法启动了一个新进程;
  4. child_process.fork() 的时候会重新运行 node 的启动命令,这时候就会重新运行 cluster.js 这个文件,而这时候 isMaster 是 false 所以会去 requre(‘app.js’) 了。这里还有一个点,就是在 fork 子进程的时候,如果父进程还没有创建,会创建父进程。
  5. 在 app.js 中虽然是启动了监听端口,由于监听端口的方法被重写了,因此只是向主进程发送了一个消息,告诉父进程可以向我发送消息了,因此可以一个端口多个进程来服务。
**杰:

不是很明白,直接运行cluster.js的时候,isMaster的值是true还是false。如果是true,那怎么会进入require app.js去运行程序在3000端口呢?如果是false,那什么时候去创建子进程呢?

    讲师回复:

    是这样的

  1. cluster.js 首次运行的时候,isMaster 肯定是 true 这时候会创建一个父进程
  2. 在我们代码中调用了两次的 cluster.fork() 方法
  3. cluster.fork() 源码中会调用 child_process 的 fork 方法来启动新的进程,在调用 cluster.fork() 时会将进程运行参数全部传递给 child_process fork() 方法,通过这个方法启动了一个新进程;
  4. child_process.fork() 的时候会重新运行 node 的启动命令,这时候就会重新运行 cluster.js 这个文件,而这时候 isMaster 是 false 所以会去 requre(‘app.js’) 了。这里还有一个点,就是在 fork 子进程的时候,如果父进程还没有创建,会创建父进程。
  5. 在 app.js 中虽然是启动了监听端口,由于监听端口的方法被重写了,因此只是向主进程发送了一个消息,告诉父进程可以向我发送消息了,因此可以一个端口多个进程来服务。
lastbee:

cluster.js如果console的话,会出现三次结果,isMaster一次为true,另外两次为false。是调用子进程的fork引起的吗?创建子进程的过程不理解,肯定是子进程引起的。

    讲师回复:

    是的哈。
https://github.com/nodejs/node/blob/master/lib/internal/cluster/primary.js
这里是调用 cluster.fork 的地方,这部分会调用createWorkerProcess创建子进程,这个方法中会调用 fork 方法,而 fork 在文件头部有申明const { fork } = require(‘child_process’);因此最终会将启动参数命令行传递给 child_process 的 fork 方法来启动一个新的进程,再细致点,你可以看到它会把启动相关的参数都传递给 fork 这个函数。具体可以看下这个方法的官方介绍 https://nodejs.org/api/child_process.html#child_process_child_process_fork_modulepath_args_options 。

**杰:

老师,请问pm2的RPC通信连接和cluster的IPC主子通信有什么联系

    讲师回复:

    RPC 通信其实是一种 IPC 通信方式。但是对于 PM2 和 cluster 模式来说这两者其实没什么关联性。最终目的都是实现消息的传递,只是 PM2 利用 RPC 命令行指令,而 cluster 应用 IPC 传递进程间的请求消息。

lastbee:

cluster.js 为啥执行三次

    讲师回复:

    我这里没有非常理解你的问题点,这里说的 3 次是指我们源代码中的 cluster.js 这个文件吗,还是 Node.js的 cluster.js 这个模块。这2部分都没有执行3次的说法呢。前者启动后,就执行了一次。后者被require以后,并没有执行多次,而是调用了2次 cluster.fork()创建了2个子进程。不知道是专栏哪部份内容导致了误解,你可以补充下专栏的说明部分。

**6400:

老师,请问error_file和out_file是在build阶段通过shell脚本写入到项目目录吗?没太理解这个文件创建的时机

    讲师回复:

    这是两个文件首先要创建目录,专栏里面说的要先创建好相应的目录,PM2在写日志的时候会直接在目录下写,并不会先创建目录。比如你用 fs 去写文件时,你目录不存在会直接报错,所以在使用 fs.write 时你要先创建好目录,PM2 也是一样的,只是他用的是文件流。创建时机就是你在上线这个项目之前,必须要创建好这个日志目录。

最后

以上就是害怕战斗机为你收集整理的Node.js 应用开发详解05 多进程解决方案:cluster 模式以及 PM2 工具的原理介绍的全部内容,希望文章能够帮你解决Node.js 应用开发详解05 多进程解决方案:cluster 模式以及 PM2 工具的原理介绍所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(48)

评论列表共有 0 条评论

立即
投稿
返回
顶部