共计 10882 个字符,预计需要花费 28 分钟才能阅读完成。
序言
一次面试中,我提到本人用过 pm2,面试接着问:「那你晓得 pm2 父子过程通信形式吗」。我大略据说 pm2 有 cluster 模式,但不分明父子过程如何通信。面试完结后把 NodeJS 的多过程重新整理了一下。
对于前端开发同学,肯定很分明 js 是单线程非阻塞的,这决定了 NodeJS 可能反对高性能的服务的开发。JavaScript 的单线程非阻塞个性让 NodeJS 适宜 IO 密集型利用,因为 JavaScript 在拜访磁盘 / 数据库 /RPC 等时候不须要阻塞期待后果,而是能够异步监听后果,同时持续向下执行。
但 js 不适宜计算密集型利用,因为当 JavaScript 遇到消耗计算性能的工作时候,单线程的毛病就裸露进去了。前面的工作都要被阻塞,直到耗时工作执行结束。
为了优化 NodeJS 不适宜计算密集型工作的问题,NodeJS 提供了多线程和多过程的反对。
多过程和多线程从两个方面对计算密集型工作进行了优化,异步和并发:
- 异步,对于耗时工作,能够新建一个线程或者过程来执行,执行结束再告诉主线程 / 过程。
看上面例子,这是一个 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.js
const 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.js
const {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.js
const 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.js
let sum = 0;
for (let i = 0; i < 1e20; i++) {sum += i;}
process.send(sum);
- 并发,为了能够更好地利用多核能力,通常会对同一个脚本创立多过程和多线程,数量和 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 流 stdin
、stdout
、stderr
;spawn
返回一个子过程的援用,通过这个援用能够监听子过程状态,并接管子过程的输出流。
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}`);
});
fork
、exec
和 execFile
都是基于 spawn
扩大的。
exec
与 spawn
不同,它接管一个回调作为参数,回调中会传入报错和 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}`);
});
execFile
和 exec
不同的是,它不会创立一个 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.js
var 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.js
process.on('message', function(m){console.log('message from parent:' + JSON.stringify(m));
});
process.send({from: 'child'});
对于下面几个创立子过程的办法,有对应的同步版本。
spawnSync
、execSync
、execFileSync
。
过程间通信
过程间通信分为父子过程通信和兄弟过程通信,当然也可能波及近程过程通信,这个会在前面提到,本文次要关注本地过程的通信。
父子过程通信能够通过规范 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 的办法能够分为两种:
- TCP/UDP socket,本来用于进行网络通信,理论就是两个近程过程间的通信,但两个过程既能够是近程也能够是本地,应用 socket 进行通信的形式就是一个过程建设 server,另一个过程建设 client,而后通过 socket 提供的能力进行通信。
- 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 模块实现,看上面的例子。
// server
const net = require('net');
net.createServer((stream => {stream.end(`hello world!\n`);
})).listen(3302, () => {console.log(`running ...`);
});
// client
const 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 提供的能力:
- 创立子过程
- 解决多子过程监听同一个端口导致抵触的问题
- 负载平衡
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_POLICY
为rr/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 过程管理工具,直观地看,它提供了几个十分好用的能力:
- 后盾运行。
- 主动重启。
- 集群治理,反对 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_mode
为cluster
时候,pm2 就会主动应用 cluster 创立多个过程,也就有了负载平衡的能力。
egg-cluster
egg-cluster 是 egg 我的项目开源的一个过程管理工具,它的作用和 pm2 相似,但两者也有很大的区别,比方 pm2 的过程模型是 master-worker,master 负责管理 worker,worker 负责执行具体任务。egg-cluster 的过程模型是 master-agent-worker,其中多进去的 agent 有什么作用呢?
有些工作其实不须要每个 Worker 都去做,如果都做,一来是浪费资源,更重要的是可能会导致多过程间资源拜访抵触
既然有了 pm2,为什么 egg 要本人开发一个过程管理工具呢?能够参考作者的答复
- PM2 的理念跟咱们不统一,它的大部分性能咱们用不上,用得上的局部却又做的不够极致。
- PM2 是 AGPL 协定的,对企业应用不敌对。
pm2 尽管很弱小,但还不能说完满,比方 pm2 并不反对 master-agent-worker 模型,而这个是理论我的项目中很常见的一个需要。因而 egg-cluster 基于理论的场景实现了过程治理的一系列性能。
答案
通过下面的介绍,咱们晓得了 pm2 应用 cluster 做集群治理,cluster 又是应用 child_process.fork 来创立子过程,所以父子过程通信应用的是内置默认的 IPC 通道。