序言

一次面试中,我提到本人用过pm2,面试接着问:「那你晓得pm2父子过程通信形式吗」。我大略据说pm2有cluster模式,但不分明父子过程如何通信。面试完结后把NodeJS的多过程重新整理了一下。

对于前端开发同学,肯定很分明js是单线程非阻塞的,这决定了NodeJS可能反对高性能的服务的开发。 JavaScript的单线程非阻塞个性让NodeJS适宜IO密集型利用,因为JavaScript在拜访磁盘/数据库/RPC等时候不须要阻塞期待后果,而是能够异步监听后果,同时持续向下执行。

但js不适宜计算密集型利用,因为当JavaScript遇到消耗计算性能的工作时候,单线程的毛病就裸露进去了。前面的工作都要被阻塞,直到耗时工作执行结束。

为了优化NodeJS不适宜计算密集型工作的问题,NodeJS提供了多线程和多过程的反对。

多过程和多线程从两个方面对计算密集型工作进行了优化,异步和并发

  1. 异步,对于耗时工作,能够新建一个线程或者过程来执行,执行结束再告诉主线程/过程。

看上面例子,这是一个koa接口,外面有耗时工作,会阻塞其余工作执行。

const Koa = require('koa');const app = new Koa();app.use(async ctx => {    const url = ctx.request.url;    if (url === '/') {        ctx.body = 'hello';    }    if (url === '/compute') {        let sum = 0;        for (let i = 0; i < 1e20; i++) {            sum += i;            }        ctx.body = `${sum}`;    }});app.listen(3000, () => {    console.log('http://localhost:300/ start')});

能够通过多线程和多过程来解决这个问题。

NodeJS提供多线程模块worker_threads,其中Woker模块用来创立线程,parentPort用在子线程中,能够获取主线程援用,子线程通过parentPort.postMessage发送数据给主线程,主线程通过worker.on承受数据。

//api.jsconst Koa = require('koa');const app = new Koa();const {Worker} = require('worker_threads');app.use(async (ctx) => {    const url = ctx.request.url;    if (url === '/') {        ctx.body = 'hello';    }    if (url === '/compute') {        const sum = await new Promise(resolve => {            const worker = new Worker(__dirname + '/compute.js');            //接管信息            worker.on('message', data => {                resolve(data);            })        });        ctx.body = `${sum}`;    }})app.listen(3000, () => {    console.log('http://localhost:3000/ start')});//computer.jsconst {parentPort} = require('worker_threads')let sum = 0;for (let i = 0; i < 1e20; i++) {    sum += i;}//发送信息parentPort.postMessage(sum);

上面是应用多过程解决耗时工作的办法,多过程模块child_process提供了fork办法(前面会介绍更多创立子过程的办法),能够用来创立子过程,主过程通过fork返回值(worker)持有子过程的援用,并通过worker.on监听子过程发送的数据,子过程通过process.send给父过程发送数据。

//api.jsconst Koa = require('koa');const app = new Koa();const {fork} = require('child_process');app.use(async ctx => {    const url = ctx.request.url;    if (url === '/') {        ctx.body = 'hello';    }    if (url === '/compute') {        const sum = await new Promise(resolve => {            const worker = fork(__dirname + '/compute.js');            worker.on('message', data => {                resolve(data);            });        });        ctx.body = `${sum}`;    }});app.listen(300, () => {    console.log('http://localhost:300/ start');});//computer.jslet sum = 0;for (let i = 0; i < 1e20; i++) {    sum += i;}process.send(sum);
  1. 并发,为了能够更好地利用多核能力,通常会对同一个脚本创立多过程和多线程,数量和CPU核数雷同,这样能够让工作并发执行,最大水平晋升了工作执行效率。

本文重点解说多过程的应用。

从理论利用角度,如果咱们心愿应用多过程,让咱们的利用反对并发执行,晋升利用性能,那么首先要创立多过程,而后过程运行的过程中不免波及到过程之间的通信,包含父子过程通信和兄弟过程之间的通信,另外还有很重要的一点是过程的治理,因为创立了多个过程,那么来了一个工作应该交给哪个过程去执行呢?过程必然要反对后盾执行(守护过程),这个又怎么实现呢?过程解体如何重启?重启过于频繁的不稳固过程又如何限度?如何操作过程的启动、进行、重启?

这一系列的过程管理工作都有相干的工具反对。

接下来就依照下面阐明的创立过程、过程间通信、过程治理(cluster集群治理、过程管理工具:pm2和egg-cluster)。

创立多过程

child_process模块用来创立子过程,该模块提供了4个办法用于创立子过程

const {spawn, fork, exec, execFile} = require('child_process');

child_process.spawn(command[, args][, options])

child_process.fork(modulePath[, args][, options])

child_process.exec(command[, options][, callback])

child_process.execFile(file[, args][, options][, callback])

spawn会启动一个shell,并在shell上执行命令;spawn会在父子过程间建设IO流stdinstdoutstderrspawn返回一个子过程的援用,通过这个援用能够监听子过程状态,并接管子过程的输出流。

const { spawn } = require('child_process');const ls = spawn('ls', ['-lh', '/usr']);ls.stdout.on('data', (data) => {  console.log(`stdout: ${data}`);});ls.stderr.on('data', (data) => {  console.error(`stderr: ${data}`);});ls.on('close', (code) => {  console.log(`child process exited with code ${code}`);});

forkexecexecFile都是基于spawn扩大的。

execspawn不同,它接管一个回调作为参数,回调中会传入报错和IO流

const { exec } = require('child_process');exec('cat ./test.txt', (error, stdout, stderr) => {  if (error) {    console.error(`exec error: ${error}`);    return;  }  console.log(`stdout: ${stdout}`);  console.error(`stderr: ${stderr}`);});

execFileexec不同的是,它不会创立一个shell,而是间接执行可执行文件,因而效率比exec稍高一些,另外,它传入的第一个参数是可执行文件,第二个参数是执行可执行文件的参数。参考nodejs进阶视频解说:进入学习

const { execFile } = require('child_process');execFile('cat', ['./test.txt'], (error, stdout, stderr) => {    if (error) {      console.error(`exec error: ${error}`);      return;    }    console.log(stdout);});

fork反对传入一个NodeJS模块门路,而非shell命令,返回一个子过程援用,这个子过程的援用和父过程建设了一个内置的IPC通道,能够让父子过程通信。

// parent.jsvar child_process = require('child_process');var child = child_process.fork('./child.js');child.on('message', function(m){    console.log('message from child: ' + JSON.stringify(m));});child.send({from: 'parent'});// child.jsprocess.on('message', function(m){    console.log('message from parent: ' + JSON.stringify(m));});process.send({from: 'child'});

对于下面几个创立子过程的办法,有对应的同步版本。

spawnSyncexecSyncexecFileSync

过程间通信

过程间通信分为父子过程通信和兄弟过程通信,当然也可能波及近程过程通信,这个会在前面提到,本文次要关注本地过程的通信。

父子过程通信能够通过规范IO流传递json

// 父过程const { spawn } = require('child_process');child = spawn('node', ['./stdio-child.js']);child.stdout.setEncoding('utf8');// 父过程-发child.stdin.write(JSON.stringify({    type: 'handshake',    payload: '你好吖'}));// 父过程-收child.stdout.on('data', function (chunk) {  let data = chunk.toString();  let message = JSON.parse(data);  console.log(`${message.type} ${message.payload}`);});// ./stdio-child.js// 子过程-收process.stdin.on('data', (chunk) => {  let data = chunk.toString();  let message = JSON.parse(data);  switch (message.type) {    case 'handshake':      // 子过程-发      process.stdout.write(JSON.stringify({        type: 'message',        payload: message.payload + ' : hoho'      }));      break;    default:      break;  }});

应用fork创立的子过程,父子过程之间会建设内置IPC通道(不晓得该IPC通道底层是应用管道还是socket实现)。(代码见“创立多过程大节”)

因而父子过程通信是NodeJS原生反对的。

上面咱们看兄弟过程如何通信。

通常过程通信有几种办法:共享内存、音讯队列、管道、socket、信号。

其中对于共享内存和音讯队列,NodeJS并未提供原生的过程间通信反对,须要依赖第三方实现,比方通过C++shared-memory-disruptor addon插件实现共享内存的反对、通过redis、MQ实现音讯队列的反对。

上面介绍在NodeJS中通过socket、管道、信号实现的过程间通信。

socket

socket是应用层与TCP/IP协定族通信的两头形象层,是一种操作系统提供的过程间通信机制,是操作系统提供的,工作在传输层的网络操作API。

socket提供了一系列API,能够让两个过程之间实现客户端-服务端模式的通信。

通过socket实现IPC的办法能够分为两种:

  1. TCP/UDP socket,本来用于进行网络通信,理论就是两个近程过程间的通信,但两个过程既能够是近程也能够是本地,应用socket进行通信的形式就是一个过程建设server,另一个过程建设client,而后通过socket提供的能力进行通信。
  2. UNIX Domain socket,这是一套由操作系统反对的、和socket很相近的API,但用于IPC,名字尽管是UNIX,理论Linux也反对。socket 本来是为网络通讯设计的,但起初在 socket 的框架上倒退出一种 IPC 机制,就是 UNIX domain socket。尽管网络 socket 也可用于同一台主机的过程间通信(通过 loopback 地址 127.0.0.1),然而 UNIX domain socket 用于 IPC 更有效率:不须要通过网络协议栈,不须要打包拆包、计算校验和、保护序号和应答等,只是将应用层数据从一个过程拷贝到另一个过程。这是因为,IPC 机制实质上是牢靠的通信,而网络协议是为不牢靠的通信设计的。

开源的node-ipc计划就是应用了socket计划

NodeJS如何应用socket进行通信呢?答案是通过net模块实现,看上面的例子。

// serverconst net = require('net');net.createServer((stream => {  stream.end(`hello world!\n`);})).listen(3302, () => {  console.log(`running ...`);});// clientconst net = require('net');const socket = net.createConnection({port: 3302});socket.on('data', data => {  console.log(data.toString());});

UNIX Domain socket在NodeJS层面上提供的API和TCP socket相似,只是listen的是一个文件描述符,而不是端口,相应的,client连贯的也是一个文件描述符(path)。

// 创立过程const net = require('net')const unixSocketServer = net.createServer(stream => {  stream.on('data', data => {    console.log(`receive data: ${data}`)  })});unixSocketServer.listen('/tmp/test', () => {  console.log('listening...');});// 其余过程const net = require('net')const socket = net.createConnection({path: '/tmp/test'})socket.on('data', data => {  console.log(data.toString());});socket.write('my name is vb');// 输入后果listening...

管道

管道是一种操作系统提供的过程通信办法,它是一种半双工通信,同一时间只能有一个方向的数据流。

管道实质上就是内核中的一个缓存,当过程创立一个管道后,Linux会返回两个文件描述符,一个是写入端的描述符(fd[1]),一个是输入端的描述符(fd[0]),能够通过这两个描述符往管道写入或者读取数据。

NodeJS中也是通过net模块实现管道通信,与socket区别是server listen的和client connect的都是特定格局的管道名。

管道的通信效率比拟低下,个别不必它作为过程通信计划。

上面是应用net实现过程通信的示例。

var net = require('net');var PIPE_NAME = "mypipe";var PIPE_PATH = "\\.\pipe\" + PIPE_NAME;var L = console.log;var server = net.createServer(function(stream) {    L('Server: on connection')    stream.on('data', function(c) {        L('Server: on data:', c.toString());    });    stream.on('end', function() {        L('Server: on end')        server.close();    });    stream.write('Take it easy!');});server.on('close',function(){    L('Server: on close');})server.listen(PIPE_PATH,function(){    L('Server: on listening');})// == Client part == //var client = net.connect(PIPE_PATH, function() {    L('Client: on connection');})client.on('data', function(data) {    L('Client: on data:', data.toString());    client.end('Thanks!');});client.on('end', function() {    L('Client: on end');})// Server: on listening// Client: on connection// Server: on connection// Client: on data: Take it easy!// Server: on data: Thanks!// Client: on end// Server: on end// Server: on close

信号

作为残缺强壮的程序,须要反对常见的中断退出信号,使得程序可能正确的响应用户和正确的清理退出。

信号是操作系统杀掉过程时候给过程发送的音讯,如果过程中没有监听信号并做解决,则操作系统个别会默认间接粗犷地杀死过程,如果过程监听信号,则操作系统不默认解决。

这种过程通信形式比拟局限,只用在一个过程杀死另一个过程的状况。

在NodeJS中,一个过程能够杀掉另一个过程,通过制订要被杀掉的过程的id来实现:process.kill(pid, signal)/child_process.kill(pid, signal)

过程能够监听信号:

process.on('SIGINT', () => {    console.log('ctl + c has pressed');});

cluster

当初构想咱们有了一个启动server的脚步,咱们心愿能更好地利用多核能力,启动多个过程来执行server脚本,另外咱们还要思考如何给多个过程调配申请。

下面的场景是一个很常见的需要:多过程治理,即一个脚本运行时候创立多个过程,那么如何对多个过程进行治理?

实际上,不仅是在server的场景有这种需要,只有是多过程都会遇到这种需要。而server的多过程还会遇到另一个问题:同一个server脚本监听的端口必定雷同,那启动多个过程时候,端口肯定会抵触。

为了解决多过程的问题,并解决server场景的端口抵触问题,NodeJS提供了cluster模块。

这种同样一份代码在多个实例中运行的架构叫做集群,cluster就是一个NodeJS过程集群治理的工具。

cluster提供的能力:

  1. 创立子过程
  2. 解决多子过程监听同一个端口导致抵触的问题
  3. 负载平衡

cluster次要用于server场景,当然也反对非server场景。

先来看下cluster的应用

import cluster from 'cluster';import http from 'http';import { cpus } from 'os';import process from 'process';const numCPUs = cpus().length;if (cluster.isPrimary) {  console.log(`Primary ${process.pid} is running`);  // Fork workers.  for (let i = 0; i < numCPUs; i++) {    cluster.fork();  }  cluster.on('exit', (worker, code, signal) => {    console.log(`worker ${worker.process.pid} died`);  });} else {  // Workers can share any TCP connection  // In this case it is an HTTP server  http.createServer((req, res) => {    res.writeHead(200);    res.end('hello world\n');  }).listen(8000);  console.log(`Worker ${process.pid} started`);}

能够看到应用cluster.fork创立了子过程,实际上cluster.fork调用了child_process.fork来创立子过程。创立好后,cluster会主动进行负载平衡。

cluster反对设置负载平衡策略,有两种策略:轮询和操作系统默认策略。能够通过设置cluster.schedulingPolicy = cluster.SCHED_RR;指定轮询策略,设置cluster.schedulingPolicy = cluster.SCHED_NONE;指定用操作系统默认策略。也能够设置环境变量NODE_CLUSTER_SCHED_POLICYrr/none来实现。

让人比拟在意的是,cluster是如何解决端口抵触问题的呢?

咱们看到代码中应用了http.createServer,并监听了端口8000,但实际上子过程并未监听8000,net模块的server.listen办法(http继承自net)判断在cluster子过程中不监听端口,而是创立一个socket并发送到父过程,以此将本人注册到父过程,所以只有父过程监听了端口,子过程通过socket和父过程通信,当一个申请到来后,父过程会依据轮询策略选中一个子过程,而后将申请的句柄(其实就是一个socket)通过过程通信发送给子过程,子过程拿到socket后应用这个socket和客户端通信,响应申请。

那么net中又是如何判断是否是在cluster子过程中的呢?cluster.fork对过程做了标识,因而net能够辨别进去。

cluster是一个典型的master-worker架构,一个master负责管理worker,而worker才是理论工作的过程。

过程治理:pm2与egg-cluster

除了集群治理,在理论利用运行时候,还有很多过程治理的工作,比方:过程的启动、暂停、重启、记录以后有哪些过程、过程的后盾运行、守护过程监听过程解体重启、终止不稳固过程(频繁解体重启)等等。

社区也有比拟成熟的工具做过程治理,比方pm2和egg-cluster

pm2

pm2是一个社区很风行的NodeJS过程管理工具,直观地看,它提供了几个十分好用的能力:

  1. 后盾运行。
  2. 主动重启。
  3. 集群治理,反对cluster多过程模式。

其余的性能还包含0s reload、日志治理、终端监控、开发调试等等。

pm2的大略原理是,建设一个守护过程(daemon),用来治理机器上通过pm2启动的利用。当用户通过命令行执行pm2命令对利用进行操作时候,其实是在和daemon通信,daemon接管到指令后进行相应的操作。这时一种C/S架构,命令行相当于客户端(client),守护过程daemon相当于服务器(server),这种模式和docker的运行模式雷同,docker也是有一个守护过程接管命令行的指令,再执行对应的操作。

客户端和daemon通过rpc进行通信,daemon是真正的“过程管理者”。

因为有守护过程,在启动利用时候,命令行应用pm2客户端通过rpc向daemon发送信息,daemon创立过程,这样过程不是由客户端创立的,而是daemon创立的,因而客户端退出也不会收到影响,这就是pm2启动的利用能够后盾运行的起因。

daemon还会监控过程的状态,解体会主动重启(当然频繁重启的过程被认为是不稳固的过程,存在问题,不会始终重启),这样就实现了过程的主动重启。

pm2利用NodeJS的cluster模块实现了集群能力,当配置exec_modecluster时候,pm2就会主动应用cluster创立多个过程,也就有了负载平衡的能力。

egg-cluster

egg-cluster是egg我的项目开源的一个过程管理工具,它的作用和pm2相似,但两者也有很大的区别,比方pm2的过程模型是master-worker,master负责管理worker,worker负责执行具体任务。egg-cluster的过程模型是master-agent-worker,其中多进去的agent有什么作用呢?

有些工作其实不须要每个 Worker 都去做,如果都做,一来是浪费资源,更重要的是可能会导致多过程间资源拜访抵触

既然有了pm2,为什么egg要本人开发一个过程管理工具呢?能够参考作者的答复

  1. PM2 的理念跟咱们不统一,它的大部分性能咱们用不上,用得上的局部却又做的不够极致。
  2. PM2 是AGPL 协定的,对企业应用不敌对。

pm2尽管很弱小,但还不能说完满,比方pm2并不反对master-agent-worker模型,而这个是理论我的项目中很常见的一个需要。因而egg-cluster基于理论的场景实现了过程治理的一系列性能。

答案

通过下面的介绍,咱们晓得了pm2应用cluster做集群治理,cluster又是应用child_process.fork来创立子过程,所以父子过程通信应用的是内置默认的IPC通道。