晴明的博客园 GitHub      CodePen      CodeWars     

[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

但这样存在问题:

  1. 多个进程之间会竞争 accpet 一个连接,产生惊群现象,效率比较低。
  2. 由于无法控制一个新的连接由哪个进程来处理,必然导致各 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 与父进程建立连接。

process.js#L230

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);
  }
}

child_process.js#L103

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();
  });
};
posted @ 2018-01-13 00:00  晴明桑  阅读(676)  评论(0编辑  收藏  举报