[node] cluster
child_process
child_process 模块提供了衍生子进程的功能。
默认情况下,在 Node.js 的父进程与衍生的子进程之间会建立 stdin、stdout 和 stderr 的管道。 数据能以非阻塞的方式在管道中流通。 注意,有些程序会在内部使用行缓冲 I/O。 虽然这并不影响 Node.js,但这意味着发送到子进程的数据可能无法被立即使用。
- exec:启动一个子进程来执行命令,调用 bash 来解释命令,所以如果有命令有外部参数,则需要注意被注入的情况。
- spawn:更安全的启动一个子进程来执行命令,使用 option 传入各种参数来设置子进程的 stdin、stdout 等。通过内置的管道来与子进程建立 IPC 通信。
- fork:spawn 的特殊情况,专门用来产生 worker 或者 worker 池。 返回值是 ChildProcess 对象可以方便的与子进程交互。
child_process.fork()
child_process.fork() 方法是 child_process.spawn() 的一个特殊情况,专门用于衍生新的 Node.js 进程。 跟 child_process.spawn() 一样返回一个 ChildProcess 对象。 返回的 ChildProcess 会有一个额外的内置的通信通道,它允许消息在父进程和子进程之间来回传递。
衍生的 Node.js 子进程与两者之间建立的 IPC 通信信道的异常是独立于父进程的。 每个进程都有自己的内存,使用自己的 V8 实例。 由于需要额外的资源分配,因此不推荐衍生大量的 Node.js 进程。
默认情况下,child_process.fork() 会使用父进程中的 process.execPath 衍生新的 Node.js 实例。 options 对象中的 execPath 属性可以替换要使用的执行路径。
使用自定义的 execPath 启动的 Node.js 进程,会使用子进程的环境变量 NODE_CHANNEL_FD 中指定的文件描述符(fd)与父进程通信。 fd 上的输入和输出期望被分割成一行一行的 JSON 对象。
child_process.fork 与 POSIX 的 fork 有什么区别?
Node.js 的 fork 创建进程是通过 libuv 的 uv_spawn. 在 Unix 平台下 uv_spawn 最终调用了系统的 fork。POSIX 的 fork 需要 waitpid 等方法手动回收, 如果未注意回收可能导致僵尸进程出现。Node.js 通过内建 IPC 来自动处理回收。
child_process.spawn()
child_process.spawn() 方法会异步地衍生子进程,且不会阻塞 Node.js 事件循环。
subprocess.send()
当父进程和子进程之间建立了一个 IPC 通道时(例如,使用 child_process.fork()),subprocess.send() 方法可用于发送消息到子进程。 当子进程是一个 Node.js 实例时,消息可以通过 process.on('message') 事件接收。
process.execPath
process.execPath 属性,返回启动Node.js进程的可执行文件所在的绝对路径。
cluster
cluster 模块允许简单容易的创建共享服务器端口的子进程。
工作进程由child_process.fork()方法创建,因此它们可以使用IPC和父进程通信,从而使各进程交替处理连接服务。
cluster模块支持两种连接分发模式(将新连接安排给某一工作进程处理)。
第一种方法(也是除Windows外所有平台的默认方法),是循环法。由主进程负责监听端口,接收新连接后再将连接循环分发给工作进程。在分发中使用了一些内置技巧防止工作进程任务过载。
第二种方法是,主进程创建监听socket后发送给感兴趣的工作进程,由工作进程负责直接接收连接。
理论上第二种方法应该是效率最佳的,但在实际情况下,由于操作系统调度机制的难以捉摸,会使分发变得不稳定。我们遇到过这种情况:8个进程中的2个,分担了70%的负载。
因为server.listen()将大部分工作交给主进程完成,因此导致普通Node.js进程与cluster作业进程差异的情况有三种:
- server.listen({fd: 7})由于文件描述符“7”是传递给父进程的,这个文件被监听后,将文件句柄(handle)传递给工作进程,而不是文件描述符“7”本身。
- server.listen(handle) 明确监听句柄,会导致工作进程直接使用该句柄,而不是和父进程通信。
- server.listen(0) 正常情况下,这种调用会导致server在随机端口上监听。但在cluster模式中,所有工作进程每次调用listen(0)时会收到相同的“随机”端口。实质上,这种端口只在第一次分配时随机,之后就变得可预料。如果要使用独立端口的话,应该根据工作进程的ID来生成端口号。
注意:Node.js不支持路由逻辑。因此在设计应用时,不应该过分依赖内存数据对象(如sessions和login等)。
由于各工作进程是独立的进程,它们可以根据需要随时关闭或重新生成,而不影响其他进程的正常运行。只要有存活的工作进程,服务器就可以继续处理连接。如果没有存活的工作进程,现有连接会丢失,新的连接也会被拒绝。Node.js不会自动管理工作进程的数量,而应该由具体的应用根据实际需要来管理进程池。
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
console.log(`主进程 ${process.pid} 正在运行`);
// 衍生工作进程。
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`工作进程 ${worker.process.pid} 已退出`);
});
} else {
// 工作进程可以共享任何 TCP 连接。
// 在本例子中,共享的是一个 HTTP 服务器。
http.createServer((req, res) => {
res.writeHead(200);
res.end('你好世界\n');
}).listen(8000);
console.log(`工作进程 ${process.pid} 已启动`);
}
//主进程 3596 正在运行
//工作进程 4324 已启动
//工作进程 4520 已启动
//工作进程 6056 已启动
//工作进程 5644 已启动
惊群现象
最初的 Node.js 多进程模型是这样实现的,master 进程创建 socket,绑定到某个地址以及端口后,自身不调用 listen 来监听连接以及 accept 连接,而是将该 socket 的 fd 传递到 fork 出来的 worker 进程,worker 接收到 fd 后再调用 listen,accept 新的连接。但实际一个新到来的连接最终只能被某一个 worker 进程 accpet 再做处理,至于是哪个 worker 能够 accept 到,开发者完全无法预知以及干预。这势必就导致了当一个新连接到来时,多个 worker 进程会产生竞争,最终由胜出的 worker 获取连接。
//master 进程
const net = require('net');
const fork = require('child_process').fork;
let handle = net._createServerHandle('0.0.0.0', 3000);
for(var i=0;i<4;i++) {
fork('./worker').send({}, handle);
}
//worker 进程
const net = require('net');
process.on('message', function(m, handle) {
start(handle);
});
let buf = 'hello nodejs';
let res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;
function start(server) {
server.listen();
server.onconnection = function(err,handle) {
console.log('got a connection on worker, pid = %d', process.pid);
let socket = new net.Socket({
handle: handle
});
socket.readable = socket.writable = true;
socket.end(res);
}
}
//运行 node master.js 启动服务器,在另一个终端多次运行 ab -n10000 -c100 http://127.0.0.1:3000/
//各个 worker 进程统计到的请求数分别为
// worker 63999 got 14561 connections
// worker 64000 got 8329 connections
// worker 64001 got 2356 connections
// worker 64002 got 4885 connections
但这样存在问题:
- 多个进程之间会竞争 accpet 一个连接,产生惊群现象,效率比较低。
- 由于无法控制一个新的连接由哪个进程来处理,必然导致各 worker 进程之间的负载非常不均衡。
简单说来,多线程/多进程等待同一个 socket 事件,当这个事件发生时,这些线程/进程被同时唤醒,就是惊群。效率很低下,许多进程被内核重新调度唤醒,同时去响应这一个事件,当然只有一个进程能处理事件成功,其他的进程在处理该事件失败后重新休眠(也有其他选择)。这种性能浪费现象就是惊群。
惊群通常发生在 server 上,当父进程绑定一个端口监听 socket,然后 fork 出多个子进程,子进程们开始循环处理(比如 accept)这个 socket。每当用户发起一个 TCP 连接时,多个子进程同时被唤醒,然后其中一个子进程 accept 新连接成功,余者皆失败,重新休眠。
nginx proxy
现代的 web 服务器一般都会在应用服务器外面再添加一层负载均衡,比如目前使用最广泛的 nginx。
利用 nginx 强大的反向代理功能,可以启动多个独立的 node 进程,分别绑定不同的端口,最后由nginx 接收请求然后进行分配。
http {
upstream cluster {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
}
server {
listen 80;
server_name www.domain.com;
location / {
proxy_pass http://cluster;
}
}
}
这种方式就将负载均衡的任务完全交给了 nginx 处理,并且 nginx 本身也相当擅长。再加一个守护进程负责各个 node 进程的稳定性,这种方案也勉强行得通。但也有比较大的局限性,比如想增加或者减少一个进程时还得再去改下 nginx 的配置。该方案与 nginx 耦合度太高,实际项目中并不经常使用。
负载均衡(load balance)
基于 round-robin(时间片轮转法) 算法的另一种模型。主要思路是 master 进程创建 socket,绑定地址以及端口后再进行监听。该 socket 的 fd 不传递到各个 worker 进程。当 master 进程获取到新的连接时,再决定将 accept 到的客户端连接分发给指定的 worker 处理。这里使用了指定, 所以如何传递以及传递给哪个 worker 完全是可控的。round-robin 只是其中的某种算法而已,当然可以换成其他的。
//master 进程
const net = require('net');
const fork = require('child_process').fork;
let workers = [];
for (var i = 0; i < 4; i++) {
workers.push(fork('./worker'));
}
let handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function (err,handle) {
let worker = workers.pop();
worker.send({},handle);
workers.unshift(worker);
}
//worker 进程
const net = require('net');
process.on('message', function (m, handle) {
start(handle);
});
let buf = 'hello Node.js';
let res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;
function start(handle) {
console.log('got a connection on worker, pid = %d', process.pid);
let socket = new net.Socket({
handle: handle
});
socket.readable = socket.writable = true;
socket.end(res);
}
进程守护
master 进程除了负责接收新的连接,分发给各 worker 进程处理之外,还得像天使一样默默地守护着这些 worker 进程,保障整个应用的稳定性。一旦某个 worker 进程异常退出就 fork 一个新的子进程顶替上去。
这一切 cluster 模块都已经好处理了,当某个 worker 进程发生异常退出或者与 master 进程失去联系(disconnected)时,master 进程都会收到相应的事件通知。
cluster.on('exit', function () {
clsuter.fork();
});
cluster.on('disconnect', function () {
clsuter.fork();
});
这样一来整个应用的稳定性重任就落在 master 进程上了,所以一定不要给 master 太多其它的任务,百分百保证它的健壮性,一旦 master 进程挂掉你的应用也就玩完了。
优雅退出
当进程异常退出时,有可能该进程上还有很多未处理完的请求,简单粗暴的使进程直接退出必然导致所有的请求都会丢失,给用户带来非常糟的体验,这就非常需要一个进程优雅退出的方案。
给 process 对象添加 uncaughtException 事件绑定能够避免发生异常时进程直接退出。在回调函数里调用当前运行 server 对象的 close 方法,停止接收新的连接。同时告知 master 进程该 worker 进程即将退出,可以 fork 新的 worker 了。
接着在几秒中之后差不多所有请求都已经处理完毕后,该进程主动退出,其中 timeout 可以根据实际业务场景进行设置。
setTimeout(function () {
process.exit(1);
}, timeout)
在关闭服务器之前,后续新接收的 request 全部关闭 keep-alive 特性,通知客户端不需要与该服务器保持 socket 连接了。
server.on('request', function (req, res) {
req.shouldKeepAlive = false;
res.shouldKeepAlive = false;
if (!res._header) {
res.setHeader('Connection', 'close');
}
});
进程失联
在多进程服务器中,为了保障整个 web 应用的稳定性,master 进程需要监控 worker 进程的 exit 以及 disconnect 事件,收到相应事件通知后重启 worker 进程。
在优雅退出中,当捕获到未处理异常时,进程不立即退出,而是会立刻通知 master 进程重新 fork 新的进程,而不是等该进程主动退出后再 fork。具体的做法就是调用 worker进程的 disconnect 方法,从而关闭父子进程用于通信的 channel ,此时父子进程之间失去了联系,此时master 进程会触发 disconnect 事件,fork 一个新的 worker进程。
触发disconnect事件
//master.js
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const net = require('net');
const fork = require('child_process').fork;
let workers = [];
for (let i = 0; i < 4; i++) {
let worker = fork(__dirname + '/worker.js');
worker.on('disconnect', function () {
console.log('[%s] worker %s is disconnected', process.pid, worker.pid);
});
workers.push(worker);
}
let handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function (err,handle) {
let worker = workers.pop();
let channel = worker._channel;
let req = new WriteWrap();
channel.writeUtf8String(req, 'dispatch handle', handle);
workers.unshift(worker);
}
//worker.js
const net = require('net');
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;
let buf = 'hello Node.js';
let res = ['HTTP/1.1 200 OK','content-length:' + buf.length].join('\r\n') + '\r\n\r\n' + buf;
channel.ref(); //防止进程退出
channel.onread = function (len, buf, handle) {
console.log('[%s] worker %s got a connection', process.pid, process.pid);
let socket = new net.Socket({
handle: handle
});
socket.readable = socket.writable = true;
socket.end(res);
console.log('[%s] worker %s is going to disconnect', process.pid, process.pid);
channel.close();
}
//运行node master.js启动服务器后,在另一个终端执行多次curl http://127.0.0.1:3000
//worker 63240 got a connection
//worker 63240 is going to disconnect
//worker 63240 is disconnected
最简单的负载均衡 server
//master.js
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const net = require('net');
const fork = require('child_process').fork;
var workers = [];
for(var i = 0; i < 4; i++) {
workers.push(fork(__dirname + '/worker.js'));
}
var handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function (err,handle) {
var worker = workers.pop();
var channel = worker._channel;
var req = new WriteWrap();
//用于通信的 channel 除了可以发送简单的字符串数据外
//还可以发送文件描述符
//最后一个参数便是要传递的 fd
channel.writeUtf8String(req, 'dispatch handle', handle);
workers.unshift(worker);
}
//worker.js
const net = require('net');
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;
var buf = 'hello Node.js';
var res = ['HTTP/1.1 200 OK', 'content-length:' + buf.length].join('\r\n') + '\r\n\r\n' + buf;
channel.ref();
channel.onread = function (len, buf, handle) {
var socket = new net.Socket({
handle: handle
});
socket.readable = socket.writable = true;
socket.end(res);
}
IPC
进程间通信(Inter-process communication, IPC),只要将这个进程的数据传递到另外一个进程就是 IPC 。
master 进程能够接收连接进行分发,同时守护 worker 进程,这一切都离不开进程间的通信。
libuv 底层
发送 fd
当进程间需要发生文件描述符 fd 时,libuv 底层采用消息队列来实现 IPC。master 进程接收到客户端连接分发给 worker 进程处理时就用到了进程间 fd 的传递。
不发送 fd
这种情况父子进程之间只是发送简单的字符串,并且它们之间的通信是双向的。master 与 worker 间的消息传递便是这种方式。虽然 pipe 能够满足父子进程间的消息传递,但由于 pipe 是半双工的,也就是说必须得创建 2 个 pipe 才可以实现双向的通信,这无疑使得程序逻辑更复杂。
libuv 底层采用 socketpair 来实现全双工的进程通信,父进程 fork 子进程之前会调用 socketpair 创建 2 个 fd.
最简单的也最原始的利用 socketpair 实现父子进程间双向通信:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
#include <sys/socket.h>
#include <stdlib.h>
#define BUF_SIZE 100
int main () {
int s[2];
int w,r;
char * buf = (char*)calloc(1 , BUF_SIZE);
pid_t pid;
if (socketpair(AF_UNIX,SOCK_STREAM,0,s) == -1 ) {
printf("create unnamed socket pair failed:%s\n", strerror(errno));
exit(-1);
}
if ((pid = fork()) > 0) {
printf("Parent process's pid is %d\n",getpid());
close(s[1]);
char *messageToChild = "a message to child process!";
if ((w = write(s[0] , messageToChild , strlen(messageToChild) ) ) == -1) {
printf("Write socket error:%s\n",strerror(errno));
exit(-1);
}
sleep(1);
if ( (r = read(s[0], buf , BUF_SIZE )) == -1) {
printf("Pid %d read from socket error:%s\n",getpid() , strerror(errno) );
exit(-1);
}
printf("Pid %d read string : %s \n",getpid(),buf);
} else if (pid == 0) {
printf("Fork child process successed\n");
printf("Child process's pid is :%d\n",getpid());
close(s[0]);
char *messageToParent = "a message to parent process!";
if ((w = write(s[1] , messageToParent , strlen(messageToParent))) == -1 ) {
printf("Write socket error:%s\n",strerror(errno));
exit(-1);
}
sleep(1);
if ((r = read(s[1], buf , BUF_SIZE )) == -1) {
printf("Pid %d read from socket error:%s\n", getpid() , strerror(errno) );
exit(-1);
}
printf("Pid %d read string : %s \n",getpid(),buf);
} else {
printf("Fork failed:%s\n",strerror(errno));
exit(-1);
}
exit(0);
}
// 保存为 socketpair.c 后运行 gcc socketpair.c -o socket && ./socket 输出
// Parent process's pid is 52853
// Fork child process successed
// Child process's pid is :52854
// Pid 52854 read string : a message to child process!
// Pid 52853 read string : a message to parent process!
Node.js 中的 IPC
Node.js 中父进程调用 fork 产生子进程时,会事先构造一个 pipe 用于进程通信:
new process.binding('pipe_wrap').Pipe(true);
构造出的 pipe 最初还是关闭的状态,或者说底层还并没有创建一个真实的 pipe,直至调用到 libuv 底层的uv_spawn, 利用 socketpair 创建的全双工通信管道绑定到最初 Node.js 层创建的 pipe 上。
管道此时已经真实的存在了,父进程保留对一端的操作,通过环境变量将管道的另一端文件描述符 fd 传递到子进程。
options.envPairs.push('NODE_CHANNEL_FD=' + ipcFd);
子进程启动后通过环境变量拿到 fd
var fd = parseInt(process.env.NODE_CHANNEL_FD, 10);
并将 fd 绑定到一个新构造的 pipe 上
var p = new Pipe(true);
p.open(fd);
Node.js 构造出的 pipe 被存储在进程的_channel属性上:
//master.js
const WriteWrap = process.binding('stream_wrap').WriteWrap;
let cp = require('child_process');
let worker = cp.fork(__dirname + '/worker.js');
let channel = worker._channel;
channel.onread = function (len, buf, handle) {
if (buf) {
console.log(buf.toString())
channel.close()
} else {
channel.close()
console.log('channel closed');
}
}
let message = { hello: 'worker', pid: process.pid };
let req = new WriteWrap();
let string = JSON.stringify(message) + '\n';
channel.writeUtf8String(req, string, null);
//worker.js
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;
channel.ref();
channel.onread = function (len, buf, handle) {
if (buf) {
console.log(buf.toString())
}else{
process._channel.close()
console.log('channel closed');
}
}
let message = { hello: 'master', pid: process.pid };
let req = new WriteWrap();
let string = JSON.stringify(message) + '\n';
channel.writeUtf8String(req, string, null);
// 输出
// {"hello":"worker","pid":58731}
// {"hello":"master","pid":58732}
// channel closed
在内置 IPC 建立之前父子进程如何通信?
Node.js 在启动子进程的时候,主进程先建立 IPC 频道,然后将 IPC 频道的 fd (文件描述符) 通过 process.env 环境变量(NODE_CHANNEL_FD)的方式传递给子进程,然后子进程通过 fd 连上 IPC 与父进程建立连接。
function setupChannel() {
// If we were spawned with env NODE_CHANNEL_FD then load that up and
// start parsing data from that stream.
if (process.env.NODE_CHANNEL_FD) {
const fd = parseInt(process.env.NODE_CHANNEL_FD, 10);
assert(fd >= 0);
// Make sure it's not accidentally inherited by child processes.
delete process.env.NODE_CHANNEL_FD;
const cp = require('child_process');
// Load tcp_wrap to avoid situation where we might immediately receive
// a message.
// FIXME is this really necessary?
process.binding('tcp_wrap');
cp._forkChild(fd);
assert(process.send);
}
}
exports._forkChild = function(fd) {
// set process.send()
var p = new Pipe(true);
p.open(fd);
p.unref();
const control = setupChannel(process, p);
process.on('newListener', function onNewListener(name) {
if (name === 'message' || name === 'disconnect') control.ref();
});
process.on('removeListener', function onRemoveListener(name) {
if (name === 'message' || name === 'disconnect') control.unref();
});
};