Node.js 多进程架构的原理

1,230 阅读11分钟

Node 采用 事件驱动 最大的问题在于是一个 Node.js 进程实例,就是一个单进程、单线程的。在现代工业生产中,这会导致两个极其严重的问题:

  • 单线程的,一个错误就导致崩溃 (健壮性?稳定性?)
  • 无法充分利用多核 CPU 并行处理

在理想状态下,每个进程各自利用一个 CPU,以此实现多核的利用。所幸,Node提供了 child_process 模块,并且也提供了 child_process.fork() 函数供我们实现进程的复制。

下图就是著名的 Master-Worker 模式,又称主从模式。它的进程分为两种:主进程和工作进程。这是典型的分布式架构中用于并行处理业务模式,具备较好的伸缩性和稳定性。主进程不负责具体的业务处理,而是负责调度或管理工作进程,它是趋向于稳定的。工作进程负责具体的业务工作,因为业务可能多人开发,所以工作进程的稳定性值得开发者关注。

深入浅出Node.js 2019-09-28 01-25-00.png

通过 fork() 复制的进程都是一个独立的进程,这个进程中有着独立而全新的 V8 示例。它需要至少30毫秒的启动时间和至少10MB的内存。

注意!尽管 Node 提供了 fork() 供我们复制进程是每个CPU内核都使用上,但是依然切记 fork() 进程是昂贵的。这里启用多个进程只是为了将 CPU 资源利用起来。

创建子进程

child_process模块 给予 Node 可以随意创建子进程的能力。它提供 4 个方法用于创建子进程。

var cp = require('child_process');
// 启动一个子进程来执行命令。
cp.spawn('node', ['worker.js']); 

// 启动一个子进程来执行命令。与 spawn() 不同的是,它有一个回调函数获知子进程状态。
cp.exec('node worker.js', function(err, stdout, stderr) {
  // some code
});

// 启动一个子进程来执行可执行文件。
// 这里的可执行文件指可以直接执行的文件,如果是 JavaScript 文件通过 exceFile() 运行,它的首行内容必须添加如下代码: #!/usr/bin/env node
cp.exec('worker.js', function(err, stdout, stderr) {
  // some code
});

// 启动一个子进程来执行 JavaScript 文件。
cp.fork('./worker.js');

尽管 4 种创建子进程的方法有些差别,但事实上后面 3 种方法都是 spawn() 的延伸应用。

注意!exec() 与 execFile() 可设置 timeout 超时时间,一旦创建的进程运行超过设定时间将会被杀死。

进程间通信原理

在 Master-Worker 模式中,要实现主进程管理和调度工作进程的功能,需要主进程和工作进程之间通信。对于 child_process 模块,创建好了子进程,然后父子进程通信是十分容易的。

在前端浏览器中,对于大运算量的处理可以采用 Web Worker 创建多线程并在后台运行,这样不影响主线程 UI 渲染。那么在 Node 中也是同样原理。

通过 fork() 或者其他 API,创建子进程之后,为了实现父子进程之间的通信,父进程与子进程之间将会创建 IPC 通道。通过这个 IPC 通道,父子进程之间才能通过 messagesend() 传递消息。

IPC的全称是Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够相互访问资源并进行协调工作。实现进程间通信的技术有很多,如命名管道、匿名管道、socket、信号量、共享内存、消息队列、Domain Socket等。Node 中实现 IPC 通道的是管道(pipe)技术。但此管道非彼管道,在 Node 中管道是一个抽象层面的称呼,具体细节有 libuv 提供,Windows 下有命名管道实现,*nix 系统则采用 Unix Domain Socket实现。表现在应用层上的进程间通信只有简单的 messagesend() 方法,接口十分简洁和消息化。

下图为 IPC 创建和实现的示意图。(libuv 跨平台是真牛逼啊)

深入浅出Node.js 2019-09-28 02-25-59.png

父进程在实际创建子进程之前,会创建 IPC 通道 并监听它,然后才真正创建出子进程,并通过环境变量(NODE_CHANNEL_FD)告诉子进程这个 IPC 通道文件描述符。子进程在启动过程中,根据 文件描述符 去连接这个已存在的 IPC 通道,从而完成父子进程之间的连接。

下图为创建 IPC 管道的步骤示意图。

深入浅出Node.js 2019-09-28 02-48-16.png

建立连接之后父子进程就可以自由通信了。由于 IPC 通道 是通过命名管道或Domain Socket创建的,它们与网络socket的行为比较类似,属于双向通信。不同的是它们在系统内核中就完成了进程间的通信,而不用经过实际的网络层,非常高效。在 Node 中,IPC 通道 被抽象为 Stream 对象,在调用 send() 时发送数据(类似于 write()),接收到的消息会通过 message 事件(类似于 data)触发给应用层。

注意!只有启动的子进程是 Node 进程时,子进程才会根据环境变量去连接 IPC 通道,对于其他类型的子进程则无法实现进程间通信,除非其他进程也按约定去连接这个已经创建好的 IPC 通道。

进程间通信方式

建立好进程之间的 IPC 后,那么现在的问题是如何将多个进程间监听同一个端口呢

我们知道多进程监听一个端口,只有一个进程会成功。

一、代理模式

要解决这个问题,通常的做法是让每个进程监听不同的端口,其中主进程监听主端口(如80),主进程对外接收所有的网络请求,再将这些请求分别代理到不同的端口的进程上。如下图所示:

深入浅出Node.js 2019-09-28 18-36-40.png

通过代理,可以避免端口不能被重复监听的问题,甚至可以在代理进程上做适当的负载均衡,使得每个子进程可以较为均衡地执行任务。由于进程每接收到一个连接,将会用掉一个 文件描述符,因此代理方案中客户端连接到代理进程,代理进程连接到工作进程的过程需要用掉两个 文件描述符操作系统的文件描述符是有限的,代理方案浪费掉一倍数量的文件描述符的做法影响了系统的扩展能力。

二、句柄传递

1. 句柄发送

为了解决上述这样的问题,Node 在版本v0.5.9引入了进程间发送句柄的功能。**send() 方法除了能通过 IPC 发送数据外,还能发送句柄。**第二个可选参数就是句柄,如下所示:

child.send(message, [snedHandle])

那什么是句柄?句柄是一种可以用来标识资源的引用,他的内部包含了指向对象的文件描述符。比如句柄可以用来标识一个服务器的 socket 对象、一个客户端的 socket 对象、一个 UDP 套接字、一个管道等。

目前,进程对象 send() 方法可以发送的句柄类型包括以下几种:

  • net.Socket: TCP 套接字
  • net.Server: TCP 服务器,任意建立在 TCP 服务上的应用层服务器都可以享受到它带来的好处。
  • net.Natice: C++ 层面的 UDP 套接字
  • dgram.Socket: UDP 套接字
  • dgram.Native: C++ 层面的 UDP 套接字

发送句柄意味着什么?在前一个问题中,我么可以去掉代理这种方案,使主进程接收到 socket 请求后,将这个 socket 直接发送给工作进程1,而不是重新与工作进程之间建立新的 socket 连接来转发数据。文件描述符浪费的问题可以通过这样的方式轻松解决。来看看我们的代码:

// parent.js
var cp = require('child_process');
var child1 = cp.fork('child.js');
var child2 = cp.fork('child.js');

// 创建 tcp server object and send the handle
var tcp = require('net').createServer();
tcp.on('connection', function (socket) {
  socket.end('handle by parent\n');
});

tcp.listen(1337, function() {
  child1.send('server', tcp);
  child2.send('server', tcp);
});

// 然后在子进程中,将进程ID打印出来
// child.js
process.on('message', function (msg, tcp) {
  if (msg === 'server') {
    tcp.on('connection', function (socket) {
      socket.end('handle by child, pid is ' + process.pid + '\n')
    })
  }
})

用 curl 测试一下我们的服务:

$ curl "http://127.0.0.1:1337/"
handle by child, pid is 43676
$ curl "http://127.0.0.1:1337/"
handle by parent
$ curl "http://127.0.0.1:1337/"
handle by child, pid is 43676

结果是每次出现的结果都可能不同,结果可能被父进程处理,也可能被不同的子进程处理。并且这是在 TCP 层面上完成的事情,我们尝试将其转化到 HTTP层面来试试。对于主进程而言,我们甚至想要它更轻量一点,那么是否将 TCP 服务器句柄发送给子进程之后,就可以关掉 TCP 服务器的监听,让子进程来处理请求呢?

// parent.js
var cp = require('child_process');
var child1 = cp.fork('child.js');
var child2 = cp.fork('child.js');

// 创建 tcp server object and send the handle
var tcp = require('net').createServer();
tcp.on('connection', function (socket) {
  socket.end('handle by parent\n');
});

tcp.listen(1337, function() {
  child1.send('server', tcp);
  child2.send('server', tcp);
  // 关掉
  tcp.close();
});

// child.js
var http = require('http');
// 创建 http server
var server = http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('handle by child, pid is ' + process.pid + '\n');
});

process.on('message', function (m, tcp) {
  if (m === 'server') {
    tcp.on('connection', function (socket) {
      // 触发 http server connection 事件
      server.emit('connection', socket);
    })
  }
});

再次测试,如下:

$ curl "http://127.0.0.1:1337/"
handle by child, pid is 24852
$ curl "http://127.0.0.1:1337/"
handle by child, pid is 24851

这样一来,所有的请求都是由子进程处理了。整个过程中,服务的过程发生了一次改变,如下图:

深入浅出Node.js 2019-09-28 23-06-33.png

主进程发送完 TCP 句柄 并关闭监听之后,成为了下图的结构:

深入浅出Node.js 2019-09-28 23-10-12.png

我们神奇地发现,多个子进程可以同时监听相同的端口,也没有异常发生。这是为什么?传送门

2. 句柄还原

上文介绍的虽然是句柄发送,但仔细看看,句柄发送跟我们直接将 TCP 服务器对象 发送给子进程有没有差别?

send() 方法在将消息发送到 IPC 管道前,将消息组装成两个对象,一个是 message ,另一个是 handle

message 会包装成内部消息,其参数如下所示:

{
  cmd: 'NODE_HANDLE',
  type: 'net.Server',
  msg: message,
}

发送到 IPC 管道中 message对象句柄 都会通过 JSON.stringify() 进行序列化,所以最终发送到 IPC 通道中的信息都是字符串,即 send() 好像只能发送字符串哦。

连接了 IPC 通道的子进程可以读取到父进程发来的消息,将字符串通过 JSON.parse() 解析还原为对象后,才触发 message 事件将消息体传递给应用层使用。在这个过程中,消息对象还要被进行过滤处理,message.cmd 的值如果以 NODE_ 为前缀,它将响应一个内部事件 internalMessage 。如果 message.cmd 的值为 NODE_HANDLE ,它将取出 message.type 值 和得到的 句柄中的文件描述符 一起还原出一个对应的对象。这个过程示意图如下:

深入浅出Node.js 2019-09-29 00-09-19.png

由于底层细节不被应用层感知,所以在子进程中,开发者会有一个种服务器就是从父进程中直接传递过来的错觉。值得注意的是,Node 进程之间只有消息传递,不会真正地传递对象,这种错觉是抽象封装的结果。

句柄传递总结

其过程是这样的:

  • master:
    1. 传递消息和句柄。【send(message, [handle])】
    2. 将消息包装成内部消息对象,再使用 JSON.stringify 序列化为字符串。【send()内部做的事情】
    3. JSON.stringify方法序列化句柄。
    4. 将序列化的消息和句柄发送到 IPC channel。
  • worker:
    1. JSON.parse 反序列化消息字符串 => 消息对象(message)。
    2. 如果 message.cmd 为 NODE_ 前缀,触发内部消息事件(internalMessage)监听器。
    3. 如果 message.cmd 为 NODE_HANDLE ,取出 message.type 。
    4. JSON.parse 反序列化句柄,取出句柄中的文件描述符。
    5. message.type 与 文件描述符 共同还原出一个对应的对象。

问题:端口共同监听

这里的多进程为何可监听同一接口?而不出现异常。其答案也很简单,我们独立启动的进程中,TCP 服务器端 socket 套接字的文件描述符并不相同,导致监听到相同的端口会抛出异常。

Node 底层对每个端口监听都设置了 SO_REUSEAEER 选项,这个选项的涵义是不同进程可以就相同的网卡和端口进行监听,这个服务器端套接字可以被不同的进程复用,如下所示:

setsockopt(tcp->io_watcher.fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on))

由于独立启动的进程互相直接并不知道文件描述符,所以监听相同端口时就会失败。但对于 send() 发送的句柄还原出来的服务而言,它们的文件描述符是相同的,所以找到的是同一 socket 套接字,那么监听相同端口不会引起异常。

多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用。换言之就是网络请求向服务器端发送时,只有一个幸运的进程能够抢到连接,也就是说只有它能为这个请求进程服务。这些进程是抢占式的。


自 Node v0.8版本以后,直接引入了 cluster 模块 ,方便工程师开发,而不用直接通过 child_process 底层来实现。

参考

  1. 深入浅出 NodeJS by 朴灵
  2. Node.js源码阅读:多进程架构的演进之路与eggjs多进程架构实践

Footnotes

  1. 实际上,发送的是句柄,其里面包含了指向对象的文件描述符,而不是真实的对象。下文会讲解