Node单机集群实现

该文章首发于个人博客,欢迎来踩 ~ 另外,本文的 代码 demo 连接,能够尽情 fork 提 PR😂。html

文章开头,先给你们抛出一个问题。前端

用过 Node 的人都知道,Node 采用的是相似 Nginx 单进程、异步IO 的运行模型,这也是 Node 性能强劲的根源。咱们可能也常常听人说 js 的执行是单进程、单线程的,那么,若是换个说法,若说 Node 是单进程、单线程 的,是对的吗?node

下面咱们来验证一下。git

咱们来执行一个最简单的 Node 程序。它只作一件事,就是不停接受标准输入流并丢弃,这样保证进程一直存在github

process.stdin.resume();
复制代码

启动后,咱们使用 ps -ef | grep node 命令找到该进程的 pid,并使用 top 命令查看该进程的线程数会打印出以下信息shell

top output

这里就不在赘述 top 命令的用法了,感兴趣的同窗能够自行 google 😁。这里框出来的部分就是进程中的线程数,能够看到,并非 1,而是 7。由此咱们就有了上一个问题的结论。api

Node 是单进程,但不是单线程的数组

那咱们常说的 js 是单线程的又是怎么回事呢?带着问题,咱们来看一下 Node 的架构图:网络

Node

  • Node Standard library 就是咱们经常使用的 Node 核心模块,如 fs、path、http 等等多线程

  • Node Bindings 是沟通JS 和 C++的桥梁,封装V8和Libuv的细节,向上层提供基础API服务

  • 最底层也是支撑 Node 的最核心的部分

    • V8 是Google开发的JavaScript引擎,提供JavaScript运行环境,能够说它就是 Node.js 的发动机

    • Libuv 是专门为Node.js开发的一个封装库,提供跨平台的异步I/O能力

    • C-ares:提供了异步处理 DNS 相关的能力

    • http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、数据压缩等其余的能力

要解释为何上图会有 7 个线程,关键在于 libuv 这个库。

libuv 是一个跨平台的异步 IO 库,实现了网络请求、文件 IO、子进程、线程池等功能。

能够发现,libuv 中是有线程池的,能够推断出那 7 个线程极可能就是 libuv 所建立的。具体缘由因为篇幅有限,再加上这也不是本文的重点,就不赘述了。

感兴趣的同窗能够这样启动 Node,set UV_THREADPOOL_SIZE=100 && node your-node.js,并执行须要依赖 thread pool 的方法,如 fs.readFile,会发现线程数变多了。

综上所述,咱们能够获得结论,Node 默认是单进程多线程的,而 js 执行是单线程的

索引

本文我将按照以下顺序介绍如何利用 cluster 模块建立一个单机集群,以及 cluster 实现的基本原理。可以让你们对 Node 的进程、进程间通讯机制有一个全面的了解

  1. Node 中的进程

  2. cluster 模块使用

  3. cluster 模块基本原理

因为笔者仍是个渣渣,还有不少地方不理解,也可能存在描述不许确的地方,还请见谅。本文的 代码 demo 连接,里面还有一些问题待研究,都已用 TODO: 标注出来,若有大神了解,还请提 PR,在此提早感谢!!!

Node 中的进程

要实现一个单机集群,首先就是要有建立子进程的能力。Node 默认是单进程运行的,但也能够建立子进程从而利用多核 CPU 的能力。

Node 中建立子进程依赖的模块是 child_process,方法主要有以下四个:

  • spawn(command[,args][,options]):核心方法,剩余三个方法底层都依赖它

  • exec(command[,options][,callback]):衍生一个 shell 执行一个系统命令,与spawn不一样的是它会有一个回调函数参数能够获知子进程的错误、标准输出等

  • execFile(file[, args][, options][, callback]):衍生一个子进程执行一个可执行文件

  • fork(modulePath[,args][,options])forkspawn 的变体,专门用于衍生一个 node 进程,最大的特色是父子进程自带通讯机制(IPC管道)

如上四个方法中,spwan 方法是核心,理解了它的用法,剩余三个就很好学习了。

它存在几个重要的 options,以下:

  • shell:默认 spawn 是不会在一个新的 shell 中执行的,若要开启,可将该配置设置为 true,或字符串指定 shell 的名称。从而支持执行命令彻底是 shell 中的语法。详见官方文档

  • stdio:选项用于配置子进程与父进程之间创建的管道,详见官方文档

  • detached

    • 默认状况下,父进程退出,子进程也会一并退出。当设置了该选项为 true 时,子进程会独立于父进程,即父进程退出子进程不会退出

    • 默认状况下,父进程等待全部子进程退出后自动退出。若但愿父进程能够独立于子进程退出,则能够调用 childProcess.unref() 方法,断开与子进程的关联

以上 stdio、unref 两个选项是实现单机集群的关键选项,在下文也会用到。

进程间如何通讯?

要想实现多进程架构,进程间通讯能力是必不可少的。Node 中进程间通讯的方式有不少种,经常使用的以下:

  • IPC:Node 内置的进程间通讯方式,经过创建子进程时的 stdio 选项打开

    • 限制
      1. 须要拿到进程的 handle,好比 process 对象,所以彻底独立的两个进程没法使用这种方式
  • stdio:此 stdio 非彼 stdio,只是一个代称,表示经过进程的 stdin、stdout、stderr 来通讯

    • 限制
      1. 同上限制 1

      2. 只能传递 StringBuffer

  • socket:进程间通讯经常使用的一种手段。Node 中 net 模块提供了经过 socket 通讯的功能

    • 优点:能够方便地跨进程通讯,无需拿到进程的 handle

    • 限制:须要建立 socket 文件

本文将重点介绍 IPC 这种方式,这也是 Node 中最经常使用的方式,其余通讯方式在 代码 demo 中均可以找到。

  • 打开方式spawnstdio 选项传入数组,并带上 'ipc',如 ['ipc'],还能够是 [0, 1, 2, 'ipc'],表示将子进程的 stdin、stdout、stderr 都继承主进程的,并开启 IPC 管道,详见官方文档

    // 代码示例
    const cp = child_process.spawn('node', [你的文件路径], {
        stdio: [0, 1, 2, 'ipc']
    });
    // 或
    const cp = child_process.fork(你的文件路径);
    复制代码

    fork 方法建立的子进程是默认就带 IPC 管道的。

  • 使用方法

    • 主进程:在主进程中能够拿到子进程的句柄,如上例就是 cp,经过 send 方法便可向其发送消息了。子进程经过 on('message') 事件监听便可。

    • 子进程:子进程中经过 process 对象便可拿到主进程的句柄,使用方式与主进程同样。

      /* 主进程 */
      const cp = spawn('node', [resolve(__dirname, './child.js')], {
          // 继承父进程的 stdin、stdout、stderr,同时创建 IPC 通道
          stdio: [0, 1, 2, 'ipc']
      });
      
      // 将输入发送给子进程
      process.stdin.on('data', (d) => {
          // 判断 IPC 管道是否链接
          if (cp.connected) {
              cp.send(d.toString());
          }
      });
      
      cp.on('message', (data) => {
          log('父进程收到数据');
          log(data.toString());
      });
      
      cp.on('disconnect', () => {
          log('好的,再见儿子');
      });
      
      /* 子进程 */
      process.on('message', (data) => {
          process.send('子进程收到数据');
          // 若子进程没有继承父进程的 stdin、stdout、stderr,则该行没有任何输出
          process.stdout.write(data);
      });
      复制代码

      本代码示例在 process/ipc/ipc

使用 cluster 模块建立集群

终于到重点了。默认 Node 程序是跑在单个进程中,js 又是执行在单个线程中的,所以没法利用多核 CPU 的并行能力。但 Node 也提供了 cluster 模块用于方便地建立多个进程的单机集群。

Node 单机集群的核心思想是 “主从模式(Master-Worker)”,即 主进程负责分发工做给工做进程,工做进程负责完成交付的任务

以 Web Server 为例,就是主进程负责监听端口,并将每次到来的请求分发给工做进程去进行业务逻辑的处理。

先贴官方文档

cluster 的经常使用 API 有以下几个:

  • isMaster/isWorker:用于判断当前进程是主进程仍是工做进程

  • setupMaster([settings]):cluster 内部经过 fork 建立子进程,该方法用于设置 fork 方法的默认配置,惟一没法设置的是 fork 参数中的 env 属性

  • fork(filepath?):建立工做进程

  • worker:当处在工做进程中,经过该字段获取当前 Worker 实例的相关信息,包括 processid 等,更多字段参见文档

  • cluster.schedulingPolicy:设置调度策略。这是一个全局设置,当第一个工做进程被衍生或者调用 cluster.setupMaster() 时,都将第一时间生效。cluster 中有以下两种调度策略

    • cluster.SCHED_RR:即 round-robin,循环策略,即每一个工做进程按顺序接收请求

    • cluster.SCHED_NONE:抢占策略。即由系统自行决定该由哪一个工做进程来处理请求

下面来实现一个简单的单机集群。

/* 主进程 */
cluster.schedulingPolicy = cluster.SCHED_NONE;
cluster.setupMaster({
    exec: resolve(__dirname, './worker.js'),
});

for (let i = 0; i < os.cpus().length; i++) {
    cluster.fork();
}

/* 工做进程 */
http.createServer((req, res) => {
    console.log(worker.process.pid + ' 响应请求');
    res.end('hello');
}).listen(5000, () => {
    console.log('process %s started', worker.process.pid);
});
复制代码

本示例代码在 cluster/basic

这样就实现了一个简单的单机集群,能够经过 ab -n 10 -c 5 http://127.0.0.1:5000/ 命令去测试一下效果。

不出意外的话,server 的输出应该以下图:

server log

能够看到分发给每一个工做进程的请求基本是平均的,你们能够尝试更换一下调度策略,再看看 👀~

可是目前咱们的集群尚未任何错误处理能力,若其中一个工做进程出错挂掉了怎么办?这样工做进程就愈来愈少了。

要解决这个问题,只需在上例主进程代码中加上简单几行便可。

cluster.on('exit', (worker, code, signal) => {
    console.log(`工做进程 ${worker.process.pid} 已退出`);
    const newWorker = cluster.fork();
    console.log(`已重启工做进程,pid:${newWorker.process.pid}`);
});
复制代码

本示例代码在 cluster/refork

如上,经过 cluster.on('exit') 事件监听子进程退出,自动重启一个新的工做进程。这样就能够从容应对工做进程出错的状况。

如今咱们的集群已经比较稳定了,但启动还不太优雅。由于它只能在 shell 中启动,至关于 shell 的一个子进程,当你退出 shell 后 shell 会将它所建立的子进程回收,咱们的服务就被干掉了。

咱们须要一个让服务后台运行的方法。

还记得上面提到的 ChildProcess.unref 方法么?这个方法是实现该功能的关键。

默认状况下,父进程等待全部子进程退出后自动退出。若但愿父进程能够独立于子进程(即子进程都退出后父进程依旧运行或者父进程无需等待子进程都退出便可退出),则能够调用该方法,断开与子进程的关联,便可调用这个方法。

该方法有几个注意事项:

  1. 若父子进程间存在通讯管道,则该选项无效,如 stdio: 'pipe'。必须将 stdio 设置为 'ignore' 或将子进程标准输入、输出重定向到其余地方(与父进程无关)才行

  2. 若启用了它,则主进程默认会在执行完成后直接退出,但子进程不会退出,并被提高为 init 进程的子进程(Mac 下是 launchd),即 ppid 为 1

  3. 用 fork 实现不了 unref

下面来动手实现吧~

咱们只须要新建一个启动脚本,它所作的就是接受命令启动服务终止服务

实现原理就是经过上面描述的 unref 方法断开与脚本进程的联系,让它提高为一个后台进程,并把服务的进程 id 保存为一个 pid 文件,用于在传入 stop 子命令时 kill 调服务进程。

使用 detached 属性也能够达到相同效果,让主进程退出后子进程依然存在,但相比 unref,使用 detached 还须要手动将主进程 kill 掉,不然默认主进程会等待全部子进程退出。

const pidFile = __dirname + '/pid';
// 若进程子命令是 stop,则 kill
if (process.argv[2] === 'stop') {
    const pid = fs.readFileSync(pidFile, 'utf8');

    if (!process.kill(pid, 0)) {
        console.log(`进程 ${pid} 不存在!`);
        return;
    }
    
    process.kill(Number(pid));
    fs.unlinkSync(pidFile);
}
else {
    const cp = spawn('node', [resolve(__dirname, './main.js')], {
        stdio: 'ignore'
    });
    // 记录主进程 pid
    fs.writeFileSync(pidFile, cp.pid);
    // 删除当前进程的引用计数,取消该进程与它子进程的关联
    cp.unref();
}
复制代码

本示例代码在 cluster/background/index.js

这样,咱们就能够经过 node cluster/background/index.js 来启动服务,并经过 cluster/background/index.js stop 终止服务啦~若想更方便地调用该命令,还能够将该脚本改为一个 shell 脚本,在文件顶部添加一个解析器注释便可,如 #!/usr/bin/env node

至此,咱们已经完成了一个简单、相对稳定的单机集群,并能经过命令方便地启动、关闭。

不过总的来讲,咱们的集群还远远不能用于生产环境,node 的 cluster 模块实现的单机集群仍是太粗糙,我的建议用 pm2 这样功能全面、稳定,而且无需修改任何业务代码的工具更好~

cluster 模块基本原理

因为笔者能力有限,目前尚未彻底看懂 cluster 模块所有代码,这里只把明白的介绍一下,以后应该会再仔细研究一下,写一篇 cluster 原理的文章😓。

  1. 如何实现 isMaster/isWorker?

    • 经过环境变量判断当前进程是主进程仍是子进程,fork 子进程时 node 内部会给子进程添加一个特殊的环境变量
  2. 工做进程如何建立?

    • 工做进程由 child_process.fork 方法建立,所以它们能够直接使用 IPC 和父进程通讯
  3. 请求如何处理?

    • 只由主进程监听端口,将请求经过 IPC 管道分发给子进程,由子进程去处理

    • 子进程只启动服务,不会真正监听端口。由于内部 listen 方法被 fake 成一个直接返回 0 的空方法,所以不会去真正监听端口

  4. 接问题 3,主进程的服务是在什么时候建立的呢?

    • 主进程的 server 启动实现是在子进程调用 listen 方法中开始的。子进程中如有调用 listen,则触发主进程去建立 server 或获取已建立的 server 句柄,建立时会把子进程启动 server 的参数传给主进程(好比端口、host 等)
  5. 接问题 3,主进程如何分发请求给工做进程?

    • 如上所属,进程间可经过 IPC 管道通讯,即便用 process.send 方法向子进程发送消息。该方法还有个重要功能就是可以发送句柄,如 net.Servernet.Socket 等等,所以可以将主进程的 net.Server 实例直接发送给工做进程处理。

能看到这里证实你是个热爱技术的优秀程序猿,请不要犹豫,当即加入咱们!

字节跳动长期招前端,实习生 hc 不限,社招 hc 多多,快把你的简历发送到这个邮箱 => yuanye.markey@bytedance.com,并在邮件名称中注明来自掘金。不要犹豫,就如今🚀!!!

相关文章
相关标签/搜索