Node运行在单线程下,但这并不意味着无法利用多核/多机下多进程的优势
事实上,Node最初从设计上就考虑了分布式网络场景:
Node is a single-threaded, single-process system which enforces shared-nothing design with OS process boundaries. It has rather good libraries for networking. I believe this to be a basis for designing very large distributed programs. The “nodes” need to be organized: given a communication protocol, told how to connect to each other. In the next couple months we are working on libraries for Node that allow these networks.
P.S.关于Node之所以叫Node,见Why is Node.js named Node.js?
通信方式与进程产生方式有关,而Node有4种创建进程的方式:spawn()
,exec()
,execFile()
和fork()
const { spawn } = require('child_process');
const child = spawn('pwd');
// 带参数的形式
// const child = spawn('find', ['.', '-type', 'f']);
spawn()
返回ChildProcess
实例,ChildProcess
同样基于事件机制(EventEmitter API),提供了一些事件:
exit
:子进程退出时触发,可以得知进程退出状态(code
和signal
)disconnect
:父进程调用child.disconnect()
时触发error
:子进程创建失败,或被kill
时触发close
:子进程的stdio
流(标准输入输出流)关闭时触发message
:子进程通过process.send()
发送消息时触发,父子进程之间可以通过这种内置的消息机制通信可以通过child.stdin
,child.stdout
和child.stderr
访问子进程的stdio
流,这些流被关闭的时,子进程会触发close
事件
P.S.close
与exit
的区别主要体现在多进程共享同一stdio
流的场景,某个进程退出了并不意味着stdio
流被关闭了
在子进程中,stdout/stderr
具有Readable特性,而stdin
具有Writable特性,与主进程的情况正好相反:
child.stdout.on('data', (data) => {
console.log(`child stdout:\n${data}`);
});child.stderr.on('data', (data) => {
console.error(`child stderr:\n${data}`);
});
利用进程stdio
流的管道特性,就可以完成更复杂的事情,例如:
const { spawn } = require('child_process');const find = spawn('find', ['.', '-type', 'f']);
const wc = spawn('wc', ['-l']);find.stdout.pipe(wc.stdin);wc.stdout.on('data', (data) => {
console.log(`Number of files ${data}`);
});
作用等价于find . -type f | wc -l
,递归统计当前目录文件数量
另外,通过spawn()
方法的stdio
选项可以建立IPC机制:
const { spawn } = require('child_process');const child = spawn('node', ['./ipc-child.js'], { stdio: [null, null, null, 'ipc'] });
child.on('message', (m) => {
console.log(m);
});
child.send('Here Here');// ./ipc-child.js
process.on('message', (m) => {
process.send(`< ${m}`);
process.send('> 不要回答x3');
});
关于spawn()
的IPC选项的详细信息,请查看options.stdio
spawn()
方法默认不会创建shell去执行传入的命令(所以性能上稍微好一点),而exec()
方法会创建一个shell。另外,exec()
不是基于stream的,而是把传入命令的执行结果暂存到buffer中,再整个传递给回调函数
exec()
方法的特点是完全支持shell语法,可以直接传入任意shell脚本,例如:
const { exec } = require('child_process');exec('find . -type f | wc -l', (err, stdout, stderr) => {
if (err) {
console.error(`exec error: ${err}`);
return;
} console.log(`Number of files ${stdout}`);
});
但exec()
方法也因此存在命令注入的安全风险,在含有用户输入等动态内容的场景要特别注意。所以,exec()
方法的适用场景是:希望直接使用shell语法,并且预期输出数据量不大(不存在内存压力)
那么,有没有既支持shell语法,还具有stream IO优势的方式?
有。两全其美的方式如下:
const { spawn } = require('child_process');
const child = spawn('find . -type f | wc -l', {
shell: true
});
child.stdout.pipe(process.stdout);
开启spawn()
的shell
选项,并通过pipe()
方法把子进程的标准输出简单地接到当前进程的标准输入上,以便看到命令执行结果。实际上还有更容易的方式:
const { spawn } = require('child_process');
process.stdout.on('data', (data) => {
console.log(data);
});
const child = spawn('find . -type f | wc -l', {
shell: true,
stdio: 'inherit'
});
stdio: 'inherit'
允许子进程继承当前进程的标准输入输出(共享stdin
,stdout
和stderr
),所以上例能够通过监听当前进程process.stdout
的data
事件拿到子进程的输出结果
另外,除了stdio
和shell
选项,spawn()
还支持一些其它选项,如:
const child = spawn('find . -type f | wc -l', {
stdio: 'inherit',
shell: true,
// 修改环境变量,默认process.env
env: { HOME: '/tmp/xxx' },
// 改变当前工作目录
cwd: '/tmp',
// 作为独立进程存在
detached: true
});
注意,env
选项除了以环境变量形式向子进程传递数据外,还可以用来实现沙箱式的环境变量隔离,默认把process.env
作为子进程的环境变量集,子进程与当前进程一样能够访问所有环境变量,如果像上例中指定自定义对象作为子进程的环境变量集,子进程就无法访问其它环境变量
所以,想要增/删环境变量的话,需要这样做:
var spawn_env = JSON.parse(JSON.stringify(process.env));// remove those env vars
delete spawn_env.ATOM_SHELL_INTERNAL_RUN_AS_NODE;
delete spawn_env.ELECTRON_RUN_AS_NODE;var sp = spawn(command, ['.'], {cwd: cwd, env: spawn_env});
detached
选项更有意思:
const { spawn } = require('child_process');const child = spawn('node', ['stuff.js'], {
detached: true,
stdio: 'ignore'
});child.unref();
以这种方式创建的独立进程行为取决于操作系统,Windows上detached子进程将拥有自己的console窗口,而Linux上该进程会创建新的process group(这个特性可以用来管理子进程族,实现类似于tree-kill的特性)
unref()
方法用来断绝关系,这样“父”进程可以独立退出(不会导致子进程跟着退出),但要注意这时子进程的stdio
也应该独立于“父”进程,否则“父”进程退出后子进程仍会受到影响
const { execFile } = require('child_process');
const child = execFile('node', ['--version'], (error, stdout, stderr) => {
if (error) {
throw error;
}
console.log(stdout);
});
与exec()
方法类似,但不通过shell来执行(所以性能稍好一点),所以要求传入可执行文件。Windows下某些文件无法直接执行,比如.bat
和.cmd
,这些文件就不能用execFile()
来执行,只能借助exec()
或开启了shell
选项的spawn()
P.S.与exec()
一样也不是基于stream的,同样存在输出数据量风险
spawn
,exec
和execFile
都有对应的同步阻塞版本,一直等到子进程退出
const {
spawnSync,
execSync,
execFileSync,
} = require('child_process');
同步方法用来简化脚本任务,比如启动流程,其它时候应该避免使用这些方法
fork()
是spawn()
的变体,用来创建Node进程,最大的特点是父子进程自带通信机制(IPC管道):
The child_process.fork() method is a special case of child_process.spawn() used specifically to spawn new Node.js processes. Like child_process.spawn(), a ChildProcess object is returned. The returned ChildProcess will have an additional communication channel built-in that allows messages to be passed back and forth between the parent and child. See subprocess.send() for details.
例如:
var n = child_process.fork('./child.js');
n.on('message', function(m) {
console.log('PARENT got message:', m);
});
n.send({ hello: 'world' });// ./child.js
process.on('message', function(m) {
console.log('CHILD got message:', m);
});
process.send({ foo: 'bar' });
因为fork()
自带通信机制的优势,尤其适合用来拆分耗时逻辑,例如:
const http = require('http');
const longComputation = () => {
let sum = 0;
for (let i = 0; i < 1e9; i++) {
sum += i;
};
return sum;
};
const server = http.createServer();
server.on('request', (req, res) => {
if (req.url === '/compute') {
const sum = longComputation();
return res.end(`Sum is ${sum}`);
} else {
res.end('Ok')
}
});server.listen(3000);
这样做的致命问题是一旦有人访问/compute
,后续请求都无法及时处理,因为事件循环还被longComputation
阻塞着,直到耗时计算结束才能恢复服务能力
为了避免耗时操作阻塞主进程的事件循环,可以把longComputation()
拆分到子进程中:
// compute.js
const longComputation = () => {
let sum = 0;
for (let i = 0; i < 1e9; i++) {
sum += i;
};
return sum;
};// 开关,收到消息才开始做
process.on('message', (msg) => {
const sum = longComputation();
process.send(sum);
});
主进程开启子进程执行longComputation
:
const http = require('http');
const { fork } = require('child_process');const server = http.createServer();server.on('request', (req, res) => {
if (req.url === '/compute') {
const compute = fork('compute.js');
compute.send('start');
compute.on('message', sum => {
res.end(`Sum is ${sum}`);
});
} else {
res.end('Ok')
}
});server.listen(3000);
主进程的事件循环不会再被耗时计算阻塞,但进程数量还需要进一步限制,否则资源被进程消耗殆尽时服务能力仍会受到影响
P.S.实际上,cluster
模块就是对多进程服务能力的封装,思路与这个简单示例类似
stdin/stdout and a JSON payload
最直接的通信方式,拿到子进程的handle后,可以访问其stdio
流,然后约定一种message
格式开始愉快地通信:
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;
}
});
P.S.VS Code进程间通信就采用了这种方式,具体见access electron API from vscode extension
明显的限制是需要拿到“子”进程的handle,两个完全独立的进程之间无法通过这种方式来通信(比如跨应用,甚至跨机器的场景)
P.S.关于stream及pipe的详细信息,请查看Node中的流
如spawn()
及fork()
的例子,进程之间可以借助内置的IPC机制通信
父进程:
process.on('message')
收child.send()
发子进程:
process.on('message')
收process.send()
发限制同上,同样要有一方能够拿到另一方的handle才行
借助网络来完成进程间通信,不仅能跨进程,还能跨机器
node-ipc就采用这种方案,例如:
// server
const ipc=require('../../../node-ipc');ipc.config.id = 'world';
ipc.config.retry= 1500;
ipc.config.maxConnections=1;ipc.serveNet(
function(){
ipc.server.on(
'message',
function(data,socket){
ipc.log('got a message : ', data);
ipc.server.emit(
socket,
'message',
data+' world!'
);
}
); ipc.server.on(
'socket.disconnected',
function(data,socket){
console.log('DISCONNECTED\n\n',arguments);
}
);
}
);
ipc.server.on(
'error',
function(err){
ipc.log('Got an ERROR!',err);
}
);
ipc.server.start();// client
const ipc=require('node-ipc');ipc.config.id = 'hello';
ipc.config.retry= 1500;ipc.connectToNet(
'world',
function(){
ipc.of.world.on(
'connect',
function(){
ipc.log('## connected to world ##', ipc.config.delay);
ipc.of.world.emit(
'message',
'hello'
);
}
);
ipc.of.world.on(
'disconnect',
function(){
ipc.log('disconnected from world');
}
);
ipc.of.world.on(
'message',
function(data){
ipc.log('got a message from world : ', data);
}
);
}
);
P.S.更多示例见RIAEvangelist/node-ipc
当然,单机场景下通过网络来完成进程间通信有些浪费性能,但网络通信的优势在于跨环境的兼容性与更进一步的RPC场景
父子进程都通过外部消息机制来通信,跨进程的能力取决于MQ支持
即进程间不直接通信,而是通过中间层(MQ),加一个控制层就能获得更多灵活性和优势:
P.S.不好实现?包一层能解决吗,不行就包两层……
比较受欢迎的有smrchy/rsmq,例如:
// init
RedisSMQ = require("rsmq");
rsmq = new RedisSMQ( {host: "127.0.0.1", port: 6379, ns: "rsmq"} );
// create queue
rsmq.createQueue({qname:"myqueue"}, function (err, resp) {
if (resp===1) {
console.log("queue created")
}
});
// send message
rsmq.sendMessage({qname:"myqueue", message:"Hello World"}, function (err, resp) {
if (resp) {
console.log("Message sent. ID:", resp);
}
});
// receive message
rsmq.receiveMessage({qname:"myqueue"}, function (err, resp) {
if (resp.id) {
console.log("Message received.", resp)
}
else {
console.log("No messages for me...")
}
});
会起一个Redis server,基本原理如下:
Using a shared Redis server multiple Node.js processes can send / receive messages.
消息的收/发/缓存/持久化依靠Redis提供的能力,在此基础上实现完整的队列机制
基本思路与message queue类似:
Use Redis as a message bus/broker.
Redis自带Pub/Sub机制(即发布-订阅模式),适用于简单的通信场景,比如一对一或一对多并且不关注消息可靠性的场景
另外,Redis有list结构,可以用作消息队列,以此提高消息可靠性。一般做法是生产者LPUSH消息,消费者BRPOP消息。适用于要求消息可靠性的简单通信场景,但缺点是消息不具状态,且没有ACK机制,无法满足复杂的通信需求
P.S.Redis的Pub/Sub示例见What’s the most efficient node.js inter-process communication library/method?
Node进程间通信有4种方式: