该文章首发于我的博客,欢迎来踩 ~ 另外,本文的 代码 demo 链接,可以尽情 fork 提 PR?。

文章开头,先给大家抛出一个问题。

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

下面我们来验证一下。

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

process.stdin.resume();

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

top output

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

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):核心方法,剩余三个方法底层都依赖它
  • exec(command,options):衍生一个 shell 执行一个系统命令,与spawn不同的是它会有一个回调函数参数可以获知子进程的错误、标准输出等
  • execFile(file, args[, callback]):衍生一个子进程执行一个可执行文件
  • fork(modulePath,args)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,并在邮件名称中注明来自思否。不要犹豫,就现在?!!!


小歪
161 声望5 粉丝

初入前端,在努力学习充实自己,目标成为顶级js技术栈工程师!