0. 背景
单线程运行模型
Node.js架构在Chrome V8引擎之上,它的模型与浏览器类似,js代码运行在单个进程的单个线程上。
- 优点:程序状态是单一的,在没有多线程的情况下没有锁和线程同步问题,操作系统在调度时也因为较少上下文的切换,可以很好地提高CPU的使用率。
- 缺点:如今CPU基本均是多核的,有的服务器往往还有多个CPU。一个Node进程只能利用一个核,这将导致Node应用无法充分利用多核CPU服务器。另外,由于Node执行在单线程上,不适合处理CPU密集型的任务。
1. 服务模型变迁历史
- 同步 :同步服务模式是一次只为一个请求服务,所有请求都得按次序等待服务。
- 多进程 :每个连接都需要一个进程来服务,相同的状态将会在内存中存在很多份,造成浪费。
- 多线程 :让一个线程服务一个请求,线程相对进程的开销要小许多,并且线程之间可以共享数据,内存浪费的问题可以得到解决,并且利用线程池可以减少创建和销毁线程的开销。
apache httpd
,C10k问题
- 事件驱动 :就是异步IO,使用事件机制,用单个线程来服务所有请求,避免了不必要的内存开销和上下文切换开销。
2. Node.js 中的进程操作
2.1 创建子进程
-
child_process.spawn()
:适用于返回大量数据,例如图像处理,二进制数据处理。 -
child_process.exec()
:产生一个shell
并在该shell
中运行命令,stdout
并stderr
在完成时将和传递给回调函数。 -
child_process.execFile()
:类似于exec()
,不同之处在于它默认情况下直接生成命令而无需生成新的shell
。 -
child_process.fork()
: 产生一个新的Node.js
进程,并使用建立的IPC
通信通道调用指定的模块,该通道允许在父级和子级之间发送消息。生成的Node.js
子进程独立于父进程,拥有自己的内存,并带有自己的V8实例。由于需要额外的资源分配,因此不建议生成大量Node.js
子进程。
2.2 进程间通信——IPC
IPC
的全称是Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作。
Node.js 中使用 fork()
创建子进程时同时创建了一个IPC管道,( 在linix系统中采用 Unix Domain Socket
实现 ) ,IPC管道与网络socket比较类似,可以双向通信,可以相互发送数据。不同的是它们在系统内核中就完成了进程间的通信,而不用经过实际的网络层,非常高效。
在Node中,IPC通道被抽象为 Stream
对象。在调用 send()
发送消息时,会先将消息序列化,然后发送到 IPC 中。接收到的消息时,先反序列化为对象,然后通过message 事件触发给应用层。
进程间通信示例:
- master.js
const { fork } = require('child_process');
console.log('master process ', process.pid, ' start');
const child = fork('message-child.js');
child.send({ hello: 'Hello, I am master ' + process.pid });
child.on('message', m => {
console.log('Master receive message from: ', child.pid, ', message: ', m)
})
- child.js
console.log('child process ', process.pid, ' start, parent process ', process.ppid);
process.on('message', m => {
console.log('Child receive message: ', m);
})
process.send({ hello: 'Hello, I am child: ' + process.pid });
- 输出如下:
master process 18547 start
child process 18554 start, parent process 18547
Child receive message: { hello: 'Hello, I am master 18547' }
Master receive message from: 18554 , message: { hello: 'Hello, I am child: 18554' }
2.3 共享句柄
在Node.js中,IPC管道发送数据会经过序列化和反序列化,所以无法直接发送对象引用。如果 IPC 管道仅仅只用来发送一些简单的数据,显然不够我们的实际应用使用。
所以在Node.js 中,扩展了IPC的能力,使其可以发送 句柄
。句柄
是一个网络链接的文件描述符,在Node.js 中为net.Server
、net.Socket
或它们的子类的实例。
通过IPC管道发送HTTP server示例
- socket-master.js
const { fork } = require('child_process');
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('response from master ' + process.pid + '\n');
});
const port = 3000
console.log('master process ', process.pid, ' start');
const child = fork('socket-child.js');
server.listen(port, () => {
console.log(`server start at ${port}`);
child.send('server', server);
});
- socket-child.js
const http = require('http');
console.log('child process ', process.pid, ' start, parent process ', process.ppid);
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('response from child ' + process.pid + '\n');
});
process.on('message', (m, s) => {
if (m === 'server') {
console.log('Child receive server socket');
s.on('connection', (socket) => {
server.emit('connection', socket);
})
}
})
- 运行结果:
$ node socket-master.js
master process 11687 start
server start at 3000
child process 11694 start, parent process 11687
Child receive server socket
- 发送HTTP请求的响应结果
$ curl localhost:3000/
response from child 11694
$ curl localhost:3000/
response from child 11694
$ curl localhost:3000/
response from master 11687
通过以上示例,可以看到在master进程中创建了一个HttpServer,然后将这个Server发送给child进程,child进程接收到后也监听这个Server,如下图所示。
此时master进程和child进程同时在监听3000端口,都可以处理客户端发起的请求,请求可能是被父进程处理,也可能被子进程处理。
另一个神奇的现象是:如果在master中把Server发送给子进程后,关闭Server,子进程依然可以继续监听响应Server的请求。修改socket-master.js
如下:
const { fork } = require('child_process');
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('response from master ' + process.pid + '\n');
});
const port = 3000
console.log('master process ', process.pid, ' start');
const child1 = fork('socket-child.js');
const child2 = fork('socket-child.js');
const child3 = fork('socket-child.js');
server.listen(port, () => {
child1.send('server', server);
child2.send('server', server);
child3.send('server', server);
server.close();
});
- 发送请求结果如下
$ curl localhost:3000/
response from child 14297
$ curl localhost:3000/
response from child 14303
$ curl localhost:3000/
response from child 14303
$ curl localhost:3000/
response from child 14296
$ curl localhost:3000/
response from child 14303
示意图如下:
2.4 句柄发送过程
上面的示例看起来好像是master进程把Server对象发送了给子进程,但真实情况却不是这样。
当调用send()方法发送message和句柄时,发送到IPC管道中的实际上是我们要发送的句柄
的文件描述符
,文件描述符实际上是一个整数值。这个 message 对象和句柄的文件描述符在写入到IPC管道时会通过 JSON.stringify()
进行序列化。所以最终发送到IPC通道中的信息都是字符串, send() 方法能发送消息和句柄并不意味着它能发送任意对象。
连接了IPC通道的子进程可以读取到父进程发来的消息,将字符串通过JSON.parse()
解析后,如果消息包含文件描述符,则将其还原出一个对应的句柄对象,再触发 message 事件将消息和句柄传递给应用层使用。
2.5 端口共同监听原理
前面示例中多个进程可以监听到相同的端口而不引起 EADDRINUSE
异常,是因为Node.js在创建socket监听端口时,指定了SO_REUSEPORT
参数,而且在不同进程中都使用相同的文件描述符句柄。
在多个进程监听相同端口时,端口上的请求,默认使用抢占式策略,会由操作系统内核随机挑选一个进程,来进行响应。Node.js还支持另外一种调度模式——Round-Robin(轮叫调度),轮叫调度的工作方式是由主进程接受连接,将其依次分发给工作进程。分发的策略是在N个工作进程中,每次选择第 i = (i + 1) mod n 个进程来发送连接。要使用轮叫调度策略需要使用cluster
模块。
3. Node.js 多进程集群
使用上面的示例代码,可以实现一个多进程共享监听端口的Web Server集群。但是使用起来比较麻烦,因此Node.js又提供了一个cluster
模块,专门用来实现多进程集群。
- cluster 示例
const cluster = require('cluster');
const http = require('http');
const workerCount = 3;
const port = 3000;
if (cluster.isMaster) {
console.log('master process ', process.pid, ' start');
for (let i = 0; i < workerCount; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log('worker ' + worker.process.pid + ' died');
});
} else {
console.log('child process ', process.pid, ' start, parent process ', process.ppid);
http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('response from child ' + process.pid + '\n');
}).listen(port, () => console.log('worker [', process.pid, '] start at ', port));
}
事实上 cluster 模块就是 child_process
和 net
模块的组合应用。 cluster 启动时,它会在内部启动TCP服务器,在 cluster.fork()
创建子进程时,将这个TCP服务器端socket的文件描述符发送给工作进程。如果进程是通过 cluster.fork() 复制出来的,那么它的环境变量里就存在 NODE_UNIQUE_ID
,如果工作进程中存在 listen()
侦听网络端口的调用,它将拿到该文件描述符,通过 SO_REUSEPORT 端口重用,从而实现多个子进程共享端口。
4. Node.js多线程
Node.js在10.5之前是没有多线程支持的,要使用多线程需要使用C++扩展模块来支持。从v10.5开始,Node.js添加了线程的支持模块worker_threads
,其实现类似于浏览器中的。在v10.x中只是试验版本,需要加--experimental-worker
参数才能启用 。到了v12.xworker_threads
模块成为了稳定版。下面是一个简单的示例:
#! node --experimental-worker
const { Worker, isMainThread } = require('worker_threads');
let num = 100;
if (isMainThread) {
num = 200;
console.log('main thread pid:', process.pid, 'num=' + num);
new Worker(__filename);
} else {
console.log('worker thread pid:', process.pid, 'num=' + num);
}
- 输出如下
main thread pid: 846 num=200
worker thread pid: 846 num=100
可以看到,主线程和worker线程的PID相同,而且主线程和worker线程不能直接共享变量值。
4.1 线程间通信
Node.js线程间发送消息是通过MessageChannel
和MessagePort
进行通讯的,MessageChannel
是一个异步双向通信通道,包含port1
、port2
两个MessagePort
类型对象。MessagePort
是一个通信的一个端,通过postMessage()
和on(message)
来发送与接收消息。在创建Worker时会自动创建一个MessageChannel
。
postMessage()
方法可以发送的消息对象将会通过HTML结构化克隆算法 (HTML structured clone algorithm)
序列化,与Json的序列化有较大的区别:
- 可以有循环引用
- 可以包含内置JS类型的实例,例如RegExps,BigInts,Maps,Sets等。
- 可以包含使用ArrayBuffers和SharedArrayBuffers的类型化数组。(实现内存共享)
- 可以包含
WebAssembly.Module
实例。 - 可以包含MessagePorts对象。
MessagePort
区别于child_process
,当前不支持传送句柄
。由于对象克隆使用结构化克隆算法,因此不会保留不可枚举的属性,属性访问器和对象原型。
- 进程间发送消息示例
#! node --experimental-worker
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
console.log('== main thread pid:', process.pid);
const worker = new Worker(__filename);
worker.postMessage('hello');
const sharedUint8Array = new Uint8Array(new SharedArrayBuffer(4));
worker.postMessage(sharedUint8Array);
console.log('== parent thread:', sharedUint8Array);
worker.on('message', (m) => {
if (m === 'ok') {
console.log('== parent thread:', sharedUint8Array);
}
});
} else {
console.log('-- worker thread pid:', process.pid);
parentPort.on('message', (m) => {
console.log('-- receive message from main thread:', m);
if (m instanceof Uint8Array) {
m[0] = 1;
m[2] = 100;
parentPort.postMessage('ok');
console.log('-- changed data:', m)
}
})
}
- 输出如下:
== main thread pid: 5758
== parent thread: Uint8Array [ 0, 0, 0, 0 ]
-- worker thread pid: 5758
-- receive message from main thread: hello
== parent thread: Uint8Array [ 1, 0, 100, 0 ]
-- receive message from main thread: Uint8Array [ 0, 0, 0, 0 ]
-- changed data: Uint8Array [ 1, 0, 100, 0 ]
* 参考资源:
- Node.js Document child_process worker_threads
- 《深入前出Node.js》--朴灵
- SO_REUSEADDR和SO_REUSEPORT参数详解
- MDN -- Web API MessagePort