NodeJs基于事件驱动的服务模型,采用单线程避免了不必要的内存开销和上下文切换的开销,但是同时也带来了一些问题,比如单线程不能充分利用多核CPU资源,并且进程出现未捕获的异常会导致进程直接退出。NodeJs提供了子进程和集群模块,帮助我们使用NodeJs多进程来充分利用CPU资源和提高应用的健壮性。
相关文章
- 学习NodeJs多进程(一)
- 学习NodeJs多进程(二)
目录
- 创建子进程
- 进程间通信
- 端口共同监听
- 多进程需要考虑的问题
创建子进程
NodeJs使用child_process
模块来创建子进程。基础的两个方法为child_process.spawn()
、child_process.spawnSync()
,前者异步地创建子进程,且不阻塞 Node.js 事件循环;后者则以同步的方式提供等效功能,但会阻止事件循环直到衍生的进程退出或终止。由于child_process.spawnSync()
不常用,此处不做介绍。
child_process
模块基于child_process.spawn()
方法实现了其他几个创建子进程方法,简要介绍如下:
-
child_process.spawn(command[, args][, options])
:根据命令创建子进程,返回子进程对象,可以在子进程对象上注册事件 -
child_process.exec(command[, options][, callback])
:创建一个shell环境进程并在该shell中运行命令,UNIX上是 '/bin/sh',windows上是'cmd.exe',可通过options.shell
指定程序 -
child_process.execFile(file[, args][, options][, callback])
:类似于child_process.exec()
,不创建shell直接根据命令创建子进程 -
child_process.fork()
:创建一个新的 Node.js 进程,并通过建立 IPC 通信通道来调用指定的模块,该通道允许在父进程与子进程之间发送消息。
基础使用方式如下:
const { spawn, exec, execFile } = require('child_process')
const path = require('path')
const child= spawn('node', ['--version'])
child.stdout.on('data', (data) => {
console.log(`spawn stdout: ${data}`)
})
exec('node --version', (error, stdout, stderr) => {
if (error) {
throw error
}
console.log(`exec stdout: ${stdout}`)
})
execFile('node', ['--version'], (error, stdout, stderr) => {
if (error) {
throw error
}
console.log(`execFile stdout: ${stdout}`)
})
执行结果:
spawn stdout: v10.15.3
execFile stdout: v10.15.3
exec stdout: v10.15.3
由于child_process.exec()
和child_process.execFile()
是由child_process.spawn()
实现的,它们执行返回的子进程对象和child_process.spawn()
一样可以获取子进程的stdout、stderr,只不过以回调方法的方式写法简单一些。
child_process.fork()
只能创建一个node的子进程,只要指定模块即可。相比于其他方式创建子进程,该方式可以和子进程相互通信,通信方式也很简单,监听message
事件接收消息,使用send()
方法发送消息,使用方式如下:
// parent.js
const { fork } = require('child_process')
const path = require('path')
const child = fork(path.resolve(__dirname, './child.js'))
child.on('message', function (msg) {
console.log('Message from child: ', msg)
})
child.send('hello world')
// child.js
process.on('message', function (msg) {
console.log('Message from parent:', msg)
process.send(msg)
})
执行parent.js
,结果如下:
Message from parent: hello world
Message from child: hello world
由以上四种方式创建子进程,都能获取到子进程对象ChildProcess
的实例,它提供了close
、disconnect
、error
、exit
、message
等事件与子进程交互。
更多关于子进程的api,请阅读官方文档:http://nodejs.cn/api/child_process.html
进程间通信
由上节child_process.fork()
的示例可以看到,进程间通过监听message
事件接收消息,使用send()
方法发送消息,它们是基于IPC实现的。
IPC的全称是Inter-Process Communication,即进程间通信。Node中实现IPC通道的是管道(pipe)技术,具体细节实现依赖系统底层。借用《深入浅出Node.js》中的图来表示创建IPC管道的过程,如下:
当父进程调用child_process.fork()
创建子进程的时候,先创建IPC管道并监听它,创建成功后再创建子进程,并把IPC管道的文件描述符通过环境变量传递给子进程,子进程启动后根据IPC管道的文件描述符去连接IPC通道,连接成功后,父子进程就能通过IPC管道通信了。
端口共同监听
常规情况下,启动两个node程序去监听同一个端口时,后一个程序会提示端口已占用,那在多进程服务中如何只监听一个端口把请求分发给多个进程处理呢?其实上文用于消息传递的send()
方法的第二个参数支持传递句柄,来看一个例子:
// parent.js
const { fork } = require('child_process')
const path = require('path')
const child1 = fork(path.resolve(__dirname, './child.js'))
const child2 = fork(path.resolve(__dirname, './child.js'))
const server = require('net').createServer()
server.on('connection', (socket) => {
socket.end('handle by parent')
})
server.listen(3000, () => {
child1.send('server', server)
child2.send('server', server)
})
// child.js
process.on('message', function (msg, server) {
if (msg === 'server') {
server.on('connection', (socket) => {
socket.end(`handle by child ${process.pid}`)
})
}
})
运行parent.js
后,多次访问http://127.0.0.1:3000
,效果如下:
可以看到多个进程监听了同一个端口3000,并且多次访问之后,真正处理请求的进程是不确定的。看到这里,想必会有以下疑问。
主进程将server对象传到子进程了吗?
其实这里传递的server对象的句柄,子进程接受到server对象的句柄,获得父进程server对象的信息,再重新创建server对象。对于调用者而言,就像把server对象直接传递到了子进程,实际上send()
只有消息传递。
为什么多进程监听同一端口不报错?
在TCP端socket套接字监听端口有一个文件描述符,单独启动多个进程时文件描述符不同,导致监听相同端口会报错。NodeJs底层对每个端口监听都设置了标识,在父进程和子进程传递server对象的过程中,将标识传给了对方,因此通过标识它们监听端口用的是同一个文件描述符。在网络请求向服务器发送时,这些进程通过抢占为请求服务。
send()方法除了server对象还支持发送哪些对象?
要发送类似的对象,需要有完整的发送与还原对象的过程。根据官方文档描述,支持的对象如下:
-
net.Socket
TCP套接字 -
net.Server
TCP服务器 -
dgram.Socket
UDP套接字
多进程需要考虑的问题
- 多进程开发
根据上文介绍的子进程创建和进程间通信,如果让开发者手动来处理父子进程是比较麻烦的事情。幸好NodeJs官方提供了cluster
模块,让多进程的使用变得很容易。 - 负载均衡
多个进程间需要有一个策略来保证资源的合理分配。Node默认提供的机制是采用操作系统的抢占式策略,但也需要根据实际系统的资源使用情况来考虑。 - 进程管理
为了程序的健壮性以及充分利用CPU资源,我们引入多进程,那么多进程的管理也是一个问题,比如某个子进程异常退出需要自动创建一个新的子进程、让所有的子进程去抢占端口请求会造成性能浪费等。目前开源好用的进程管理工具有pandora
、pm2
可以帮助我们解决一些问题。 - 状态共享
通常在多个应用间需要有一些共享数据,比如IM系统中记录当前在线的用户。常见的做法是通过第三方数据存储来实现,比如redis
。
关于以上问题将在后面的文章中继续学习探索。
总结
本文简要介绍了子进程创建和进程间通信的基础内容,在后面的文章中将深入学习多进程的管理。
本文参考资源如下: