系列3|走进Node.js之多进程模型

文:正龙(沪江网校Web前端工程师)

本文原创,转载请注明作者及出处

之前的文章“走进Node.js之HTTP实现分析”中,大家已经了解 Node.js 是如何处理 HTTP 请求的,在整个处理过程,它仅仅用到单进程模型。那么如何让 Web 应用扩展到多进程模型,以便充分利用CPU资源呢?答案就是 Cluster。本篇文章将带着大家一起分析Node.js的多进程模型。

首先,来一段经典的 Node.js 主从服务模型代码:

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

if (cluster.isMaster) {
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
} else {
  require(‘http‘).createServer((req, res) => {
    res.end(‘hello world‘);
  }).listen(3333);
}

通常,主从模型包含一个主进程(master)和多个从进程(worker),主进程负责接收连接请求,以及把单个的请求任务分发给从进程处理;从进程的职责就是不断响应客户端请求,直至进入等待状态。如图 3-1 所示:

围绕这段代码,本文希望讲述清楚几个关键问题:

  1. 从进程的创建过程;
  2. 在使用同一主机地址的前提下,如果指定端口已经被监听,其它进程尝试监听同一端口时本应该会报错(EADDRINUSE,即端口已被占用);那么,Node.js 如何能够在主从进程上对同一端口执行 listen 方法?

进程 fork 是如何完成的?

在 Node.js 中,cluster.fork 与 POSIX 的 fork 略有不同:虽然从进程仍旧是 fork 创建,但是并不会直接使用主进程的进程映像,而是调用系统函数 execvp 让从进程使用新的进程映像。另外,每个从进程对应一个 Worker 对象,它有如下状态:none、online、listening、dead和disconnected。

ChildProcess 对象主要提供进程的创建(spawn)、销毁(kill)以及进程句柄引用计数管理(ref 与 unref)。在对Process对象(process_wrap.cc)进行封装之外,它自身也处理了一些细节问题。例如,在方法 spawn 中,如果需要主从进程之间建立 IPC 管道,则通过环境变量 NODE_CHANNEL_FD 来告知从进程应该绑定的 IPC 相关的文件描述符(fd),这个特殊的环境变量后面会被再次涉及到。

以上提到的三个对象引用关系如下:

cluster.fork 的主要执行流程:

  1. 调用 child_process.spawn;
  2. 创建 ChildProcess 对象,并初始化其 _handle 属性为 Process 对象;Process 是 process_wrap.cc 中公布给 JavaScript 的对象,它封装了 libuv 的进程操纵功能。附上 Process 对象的 C++ 定义:

c++ interface Process { construtor(const FunctionCallbackInfo<Value>& args); void close(const FunctionCallbackInfo<Value>& args); void spawn(const FunctionCallbackInfo<Value>& args); void kill(const FunctionCallbackInfo<Value>& args); void ref(const FunctionCallbackInfo<Value>& args); void unref(const FunctionCallbackInfo<Value>& args); void hasRef(const FunctionCallbackInfo<Value>& args); }

  1. 调用 ChildProcess._handle 的方法 spawn,并会最终调用 libuv 库中 uv_spawn

主进程在执行 cluster.fork 时,会指定两个特殊的环境变量 NODE_CHANNEL_FD 和 NODE_UNIQUE_ID,所以从进程的初始化过程跟一般 Node.js 进程略有不同:

  1. bootstrap_node.js 是运行时包含的 JavaScript 入口文件,其中调用 internal\process.setupChannel;
  2. 如果环境变量包含 NODE_CHANNEL_FD,则调用 child_process._forkChild,然后移除该值;
  3. 调用 internal\child_process.setupChannel,在子进程的全局 process 对象上监听消息 internalMessage,并且添加方法 send 和 _send。其中 send 只是对 _send 的封装;通常,_send 只是把消息 JSON 序列化之后写入管道,并最终投递到接收端。
  4. 如果环境变量包含 NODE_UNIQUE_ID,则当前进程是 worker 模式,加载 cluster 模块时会执行 workerInit;另外,它也会影响到 net.Server 的 listen 方法,worker 模式下 listen 方法会调用 cluster._getServer,该方法实质上向主进程发起消息 {"act" : "queryServer"},而不是真正监听端口。

IPC实现细节

上文提到了 Node.js 主从进程仅仅通过 IPC 维持联络,那这一节就来深入分析下 IPC 的实现细节。首先,让我们看一段示例代码:

1-master.js

const {spawn} = require(‘child_process‘);
let child = spawn(process.execPath, [`${__dirname}/1-slave.js`], {
  stdio: [0, 1, 2, ‘ipc‘]
});

child.on(‘message‘, function(data) {
  console.log(‘received in master:‘);
  console.log(data);
});

child.send({
  msg: ‘msg from master‘
});

1-slave.js

process.on(‘message‘, function(data) {
  console.log(‘received in slave:‘);
  console.log(data);
});
process.send({
  ‘msg‘: ‘message from slave‘
});
node 1-master.js

运行结果如下:

细心的同学可能发现控制台输出并不是连续的,master和slave的日志交错打印,这是由于并行进程执行顺序不可预知造成的。

socketpair

前文提到从进程实际上通过系统调用 execvp 启动新的 Node.js 实例;也就是说默认情况下,Node.js 主从进程不会共享文件描述符表,那它们到底是如何互发消息的呢?

原来,可以利用 socketpair 创建一对全双工匿名 socket,用于在进程间互发消息;其函数签名如下:

int socketpair(int domain, int type, int protocol, int sv[2]);

通常情况下,我们是无法通过 socket 来传递文件描述符的;当主进程与客户端建立了连接,需要把连接描述符告知从进程处理,怎么办?其实,通过指定 socketpair 的第一个参数为 AF_UNIX,表示创建匿名 UNIX 域套接字(UNIX domain socket),这样就可以使用系统函数 sendmsgrecvmsg 来传递/接收文件描述符了。

主进程在调用 cluster.fork 时,相关流程如下:

  1. 创建 Pipe(pipe_wrap.cc)对象,并且指定参数 ipc 为 true;
  2. 调用 uv_spawn,options 参数为 uv_process_options_s 结构体,把 Pipe 对象存储在结构体的属性 stdio 中;
  3. 调用 uv__process_init_stdio,通过 socketpair 创建全双工 socket;
  4. 调用 uv__process_open_stream,设置 Pipe 对象的 iowatcher.fd 值为全双工 socket 之一。

至此,主从进程就可以进行双向通信了。流程图如下:

我们再回看一下环境变量 NODE_CHANNEL_FD,令人疑惑的是,它的值始终为3。进程级文件描述符表中,0-2分别是标准输入stdin、标准输出stdout和标准错误输出stderr,那么可用的第一个文件描述符就是3,socketpair 显然会占用从进程的第一个可用文件描述符。这样,当从进程往 fd=3 的流中写入数据时,主进程就可以收到消息;反之,亦类似。

从 IPC 读取消息主要是流操作,以后有机会详解,下面列出主要流程:

  1. StreamBase::EditData 回调 onread;
  2. StreamWrap::OnReadImpl 调用 StreamWrap::EditData;
  3. StreamWrap 的构造函数会调用 set_read_cb 设置 OnReadImpl;
  4. StreamWrap::set_read_cb 设置属性 StreamWrap::read_cb_;
  5. StreamWrap::OnRead 中引用属性 read_cb_;
  6. StreamWrap::ReadStart 调用 uv_read_start 时传递 Streamwrap::OnRead 作为第3个参数:
int uv_read_start(uv_stream_t* stream, uv_alloc_cb alloc_cb, uv_read_cb read_cb)

涉及到的类图关系如下:

服务器主从模型

以上大概分析了从进程的创建过程及其特殊性;如果要实现主从服务模型的话,还需要解决一个基本问题:从进程怎么获取到与客户端间的连接描述符?我们打算从 process.send(只有在从进程的全局 process 对象上才有 send 方法,主进程可以通过 worker.process 或 worker 访问该方法)的函数签名着手:

void send(message, sendHandle, callback)

其参数 message 和 callback 含义也许显而易见,分别指待发送的消息对象和操作结束之后的回调函数。那它的第二个参数 sendHandle 用途是什么?

前文提到系统函数 socketpair 可以创建一对双向 socket,能够用来发送 JSON 消息,这一块主要涉及到流操作;另外,当 sendHandle 有值时,它们还可以用于传递文件描述符,其过程要相对复杂一些,但是最终会调用系统函数 sendmsg 以及 recvmsg。

传递与客户端的连接描述符

在主从服务模型下,主进程负责跟客户端建立连接,然后把连接描述符通过 sendmsg 传递给从进程。我们来看看这一过程:

从进程

  1. 调用 http.Server.listen 方法(继承至 net.Server);
  2. 调用 cluster._getServer,向主进程发起消息:

json { "cmd": "NODE_HANDLE", "msg": { "act": "queryServer" } }

主进程

  1. 接收处理这个消息时,会新建一个 RoundRobinHandle 对象,为变量 handle。每个 handle 与一个连接端点对应,并且对应多个从进程实例;同时,它会开启与连接端点相应的 TCP 服务 socket。

```js

class RoundRobinHandle {

construtor(key, address, port, addressType, fd) {

// 监听同一端点的从进程集合

this.all = [];

  // 可用的从进程集合
  this.free = [];

  // 当前等待处理的客户端连接描述符集合
  this.handles = [];

  // 指定端点的TCP服务socket
  this.server = null;
}
add(worker, send) {
  // 把从进程实例加入this.all
}
remove(worker) {
  // 移除指定从进程
}
distribute(err, handle) {
  // 把连接描述符handle存入this.handles,并指派一个可用的从进程实例开始处理连接请求
}
handoff(worker) {
  // 从this.handles中取出一个待处理的连接描述符,并向从进程发起消息
  // {
  //  "type": "NODE_HANDLE",
  //  "msg": {
  //    "act": "newconn",
  //  }
  // }
}

}

```

  1. 调用 handle.add 方法,把 worker 对象添加到 handle.all 集合中;
  2. 当 handle.server 开始监听客户端请求之后,重置其 onconnection 回调函数为 RoundRobinHandle.distribute,这样的话主进程就不用实际处理客户端连接,只要分发连接给从进程处理即可。它会把连接描述符存入 handle.handles 集合,当有可用 worker 时,则向其发送消息 { "act": "newconn" }。如果被指派的 worker 没有回复确认消息 { "ack": message.seq, accepted: true },则会尝试把该连接分配给其他 worker。

流程图如下:

从进程上调用listen

客户端连接处理

从进程如何与主进程监听同一端口?

原因主要有两点:

** I. 从进程中 Node.js 运行时的初始化略有不同**

  1. 因为从进程存在环境变量 NODE_UNIQUE_ID,所以在 bootstrap_node.js 中,加载 cluster 模块时执行 workerInit 方法。这个地方与主进程执行的 masterInit 方法不同点在于:其一,从进程上没有 cluster.fork 方法,所以不能在从进程继续创建子孙进程;其二,Worker 对象上的方法 disconnect 和 destroy 实现也有所差异:我们以调用 worker.destroy 为例,在主进程上时,不能直接把从进程杀掉,而是通知从进程退出,然后再把它从集合里删除;当在从进程上时,从进程通知完主进程然后退出就可以了;其三,从进程上 cluster 模块新增了方法 _getServer,用于向主进程发起消息 {"act": "queryServer"},通知主进程创建 RoundRobinHandle 对象,并实际监听指定端口地址;然后自身用一个模拟的 TCP 描述符继续执行;
  2. 调用 cluster._setupWorker 方法,主要是初始化 cluster.worker 属性,并监听消息 internalMessage,处理两种消息类型:newconn 和 disconnect;
  3. 向主进程发起消息 { "act": "online" };
  4. 因为从进程额环境变量中有 NODE_CHANNEL_FD,调用 internal\process.setupChannel时,会连接到系统函数 socketpair 创建的双向 socket ,并监听 internalMessage ,处理消息类型:NODE_HANDLE_ACK和NODE_HANDLE。

** II. listen 方法在主从进程中执行的代码略有不同。**

在 net.Server(net.js)的方法 listen 中,如果是主进程,则执行标准的端口绑定流程;如果是从进程,则会调用 cluster._getServer,参见上面对该方法的描述。

最后,附上基于libuv实现的一个 C 版 Master-Slave 服务模型,GitHub地址

启动服务器之后,访问 http://localhost:3333 的运行结果如下:

相信通过本篇文章的介绍,大家已经对Node.js的Cluster有了一个全面的了解。下一次作者会跟大家一起深入分析Node.js进程管理在生产环境下的可用性问题,敬请期待。

相关文章

系列1|走进Node.js之启动过程剖析

系列2|走进Node.js 之 HTTP实现分析

推荐: 翻译项目Master的自述:

1. 干货|人人都是翻译项目的Master

2. iKcamp出品微信小程序教学共5章16小节汇总(含视频)

3. 开始免费连载啦~每周2更共11堂iKcamp课|基于Koa2搭建Node.js实战项目教学(含视频)| 课程大纲介绍

原文地址:https://www.cnblogs.com/ikcamp/p/8376596.html

时间: 2024-10-07 18:38:10

系列3|走进Node.js之多进程模型的相关文章

走进Node.js

自2009年Node.js诞生以来,其发展速度如此之快. 严格的说,Node.js是一个用于开发各种Web服务器的开发工具. Node.js为什么发展的这么快,迅速成长起来的呢,首先,我们看一下现在的服务器端语言中存在的问题,在Java.PHP.ASP.NET等服务器端语言中,为每一个客户端创建一个新的线程,每个线程需要耗费大约2MB的内存,就是说,理论上,8GB内存的服务器可以同时连接的最大用户数为4000个左右.要让Web应用程序支持更多的用户,就要增加服务器,这样一来,硬件成本就增加了,而

node.js cluster多进程、负载均衡和平滑重启

1 cluster多进程 cluster经过好几代的发展,现在已经比较好使了.利用cluster,可以自动完成子进程worker分配request的事情,就不再需要自己写代码在master进程中robin式给每个worker分配任务了. const cluster = require('cluster'); const http = require('http'); const numCPUs = require('os').cpus().length; if (cluster.isMaster

【nodejs原理&amp;源码赏析(4)】深度剖析cluster模块源码与node.js多进程(上)

目录 一. 概述 二. 线程与进程 三. cluster模块源码解析 3.1 起步 3.2 入口 3.3 主进程模块master.js 3.4 子进程模块child.js 四. 小结 示例代码托管在:http://www.github.com/dashnowords/blogs 博客园地址:<大史住在大前端>原创博文目录 华为云社区地址:[你要的前端打怪升级指南] 一. 概述 cluster模块是node.js中用于实现和管理多进程的模块.常规的node.js应用程序是单线程单进程的,这也意味

浅谈Node.js单线程模型

Node.js采用 事件驱动 和 异步I/O 的方式,实现了一个单线程.高并发的运行时环境,而单线程就意味着同一时间只能做一件事,那么Node.js如何利用单线程来实现高并发和异步I/O?本文将围绕这个问题来探讨Node.js的单线程模型: 1.高并发 一般来说,高并发的解决方案就是多线程模型,服务器为每个客户端请求分配一个线程,使用同步I/O,系统通过线程切换来弥补同步I/O调用的时间开销,比如Apache就是这种策略,由于I/O一般都是耗时操作,因此这种策略很难实现高性能,但非常简单,可以实

【转】浅谈Node.js单线程模型

Node.js采用 事件驱动 和 异步I/O 的方式,实现了一个单线程.高并发的运行时环境,而单线程就意味着同一时间只能做一件事,那么Node.js如何利用单线程来实现高并发和异步I/O?本文将围绕这个问题来探讨Node.js的单线程模型: 1.高并发 一般来说,高并发的解决方案就是多线程模型,服务器为每个客户端请求分配一个线程,使用同步I/O,系统通过线程切换来弥补同步I/O调用的时间开销,比如Apache就是这种策略,由于I/O一般都是耗时操作,因此这种策略很难实现高性能,但非常简单,可以实

让前端猪猪飞起来的Node.js

前端猪猪飞飞 传统前端猪猪大多数做的事情就是页面的布局.渲染和动画效果等工作,玩弄的总是一个网页壳,因为没有后台支持,这总让前端猪猪觉得有点不爽.而Node.js的出现,却让前端猪猪们翱翔于蓝天,因为可以使用js来调用数据库,文件等后台操作,瞬间感觉猪猪也能飞,也能吊炸天. Node.js是第三方js库 大多数不认识Node.js的人会第一直觉感觉这不就是一个第三方js库吗?我只想说"呵呵"(其实我刚学那时候也这样认为). 直接上Node.js官方解释: Node.js是一个基于Chr

理解Node.js事件驱动编程

Node.js现在非常活跃,相关生态社区已经超过Lua(基本上比较知名的功能都有nodejs模块实现). 但是我们为何要使用Node.Js?相比传统的webserver服务模式,nodejs有什么优点优势? Node.Js是基于javascript语言,建构在google V8 engine以及Linux上的一个非阻塞事件驱动IO框架.nodejs是单进程单线程,但是基于V8的强大驱动力,以及事件驱动模型,nodejs的 性能非常高,而且想达到多核或者多进程也不是很难(现在已经有大量的第三方mo

Node.js的进程管理

众所周知Node基于V8,而在V8中JavaScript是单线程运行的,这里的单线程不是指Node启动的时候就只有一个线程,而是说运行JavaScript代码是在单线程上,Node还有其他线程,比如进行异步IO操作的IO线程.这种单线程模型带来的好处就是系统调度过程中不会频繁进行上下文切换,提升了单核CPU的利用率. 但是这种做法有个缺陷,就是我们无法利用服务器CPU多核的性能,一个Node进程只能利用一个CPU.而且单线程模式下一旦代码崩溃就是整个程序崩溃.通常解决方案就是使用Node的clu

和阿木聊Node.js

npm:node.js官方库 cnpm:taobao维护的库: WebStorm:Node.js的开发工具,但是收费: seajs:还有一款交requirjs,前者是遵循amd规范(一次性参数中加载要require的内容),后者是遵循cmd规范,需要什么再require什么:规范是默认的,但是都支持对方的默认规范:模块化开发,在一个大的HTML中,需要的时候,再进行加载(以模块为单位进行加载,这就变得更加灵活了,早起前端开发都是以页面为单位进行加载,现在是面向页面中的"块"进行加载),