-
Notifications
You must be signed in to change notification settings - Fork 0
Description
从严格意义上讲, Node 并非真正的单线程架构,在第3章中讲过 Node 自身还有一定的 I/O 线程存在,这些 I/O 线程由底层 libuv 处理,对 js 开发者而言是透明的,只有在 C++ 扩展开发时才会关注到。js 代码永远运行在 V8 上,是单线程的。
单线程的问题:
- 如何充分利用多核 CPU
启动多进程即可 - 如何保证进程的健壮性和稳定性
一旦单线程上抛出的异常没有被捕获,将会引起整个进程的崩溃。
服务器模型的变迁
同步 -> 复制进程 -> 多线程(Apache) -> 事件驱动(Node、Nginx)
多进程架构
require('child_process').fork() 实现进程复制。
面对单线程对多核使用不足的问题,启动多进程即可。理想状态下每个进程各自利用一个 CPU,以此实现对多核 CPU 的利用。
下面的代码会根据当前机器上的 CPU 数量复制出对应 Node 进程数。
// worker.js
var http = require('http')
// 监听不同的端口,到下面句柄传递部分可以看到,所有进程如何监听同一个端口
http.createServer((req, res) => {...}).listen((1+Math.random()*1000), '127.0.0.1')// master.js
var fork = require('child_process').fork
var cpus = require('os').cpus()
for (var i = 0; i < cpus.length; i++) {
fork('./worker.js')
};通过 fork 复制的进程都是一个独立的进程,这个进程中有着独立而全新的 V8 实例,它需要至少30毫秒的启动时间和至少10MB的内存。
这里启动多个进程并不是为了解决大并发的问题,而是为了充分将 CPU 资源利用起来。大并发的问题已经被 Node 事件驱动的方式解决。
创建子进程
child_process 模块提供了4种方式用于创建子进程(实际上后面3种都是 spawn() 的延伸应用):
spawn()
启动一个子进程来执行命令exec()
启动一个子进程来执行命令,有回调,可设置超时execFile()
启动一个子进程来执行可执行文件,有回调,可设置超时fork()
与spawn()类似,但是只需指定 js 文件即可
如具体实现:
var cp = require('child_process')
cp.spawn('node',['worker.js'])
cp.exec('node worker.js', (err, stdout, stderr) => {})
cp.execFile('worker.js', (err, stdout, stderr) => {})
cp.fork('./worker.js')进程间通信
要实现主进程管理和调度工作进程的功能,需要主进程和工作进程之间的通信。
父子进程之间会创建 IPC 通道,通过 IPC 通道,父子进程之间通过 message 和 send() 传递信息。
// parent.js
var cp = require('child_process')
var n = cp.fork(__dirname + '/sub.js')
n.on('message', m => {
console.log('parent got message' + m)
})
n.send({
hello:'world'
})// sub.js
// 直接用 process, 不用require p23
process.on('message', m => {
console.log('child got message' + m)
})
process.send({
foo:'bar'
})进程间通信原理
IPC 进程间通信(Inter-Process Communication)。进程间通信的目的是让不同的进程能互相访问资源并进行协调工作。
Node 中实现 IPC 通道的是管道技术。具体实现由 libuv 提供:
- windows 下由命名管道实现
- *nix 系统采用 Unix Domain Socket实现
表现在应用层上的进程间通信只有简单的 message 事件和 send() 方法。
父进程在实际创建子进程之前,会先创建 IPC 通道并监听它,然后才真正创建出子进程,并通过环境变量 NODE_CHANNEL_FD 告诉子进程这个 IPC 通道的文件描述符(p50,应用程序和系统内核之间的凭证)。子进程在启动的过程中,根据文件描述符去连接这个已经存在的 IPC 通道,从而完成父子进程之间的连接。
连接后的父子进程就可以双向通信了。
只有启动的子进程是 Node 进程时,子进程才会根据环境变量去连接 IPC 通道,对于其他类型的子进程则无法实现进程间通信,除非其他进程也按照约定去连接这个已经创建好的 IPC 通道。
句柄传递
下面的代码会报错:
// worker.js
var http = require('http')
// 监听同一个端口
http.createServer((req, res) => {...}).listen(8888, '127.0.0.1')// master.js
var fork = require('child_process').fork
var cpus = require('os').cpus()
for (var i = 0; i < cpus.length; i++) {
fork('./worker.js')
};- 情况:不同进程不能监听同一端口,通常做法是让每个进程监听不同的端口,其中主进程监听主端口(80),主进程对外接收所有的网络请求,再将这些请求分别代理到不同的端口的进程上。
- 问题:进程每收到一个连接,将会用掉一个文件描述符,因此上面的代理方案中 客户端连接到代理进程,代理进程连接到共组进程的过程需要用掉2个文件描述符,比下面将要介绍的句柄传递的方案浪费了1倍数量的文件描述符。
- 为了解决上述问题,Node 在 v0.5.9 版本引入了进程间发送句柄的功能。
send()方法除了能通过 IPC 发送数据外,还能发送句柄,第二个可选参数就是句柄,如下:
child.send(message, [sendHandle])
句柄是一种引用,是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。比如句柄可以用来标识:
- 服务器 socket 对象
- 客户端 socket 对象
- UDP 套接字
- 管道
发送句柄意味着:在前面的情况中,我们可以去掉代理这种方案,使主进程收到 socket 请求后,将这个 socket 直接发送给工作进程,而不是重新与工作进程之间建立新的 socket 连接来发送数据。文件描述符浪费的问题轻松解决。(进程间建立连接要花费文件描述符,p50)
// parent.js
var cp = require('child_process')
var child1 = cp.fork('child.js')
var child2 = cp.fork('child.js')
var server = require('net').createServer()
server.on('connection', socket => {
socket.end('handled by parent')
})
server.listen(1337, () => {
child1.send('server', server)
child2.send('server', server)
})// child.js
process.on('message', (m, server) => {
if (m === 'server') {
server.on('connection', socket => {
socket.end('handled by child pid is:' + process.pid + '\n')
})
};
})// 测试
curl 'http://127.0.0.1:1337/'
handled by child pid is 24673
curl 'http://127.0.0.1:1337/'
handled by parent
curl 'http://127.0.0.1:1337/'
handled by child pid is 24672测试的结果是每次出现的结果都可能不同,结果可能被父进程处理,也可能被不同的子进程处理。
我们神奇的发现,多个子进程可以同时监听相同的端口,没有异常发生了。
句柄发送与还原
send() 方法在将消息发送到 IPC 管道前,将消息组装成2个对象,一个参数是 handle, 另一个是 message, 如下所示:
{
handle: 从server对象得到的句柄
}
{
cmd: 'NODE_HANDLE',
type: 'net.Server',// 可发送的句柄类型有 net.Socket net.Server net.Native dgram.Socket dgram.Native
msg: 'server'// send 方法第一个参数
}- 发送到 IPC 管道中的实际上是我们要发送的句柄文件描述符,文件描述符实际上是一个整数值。上面的对象在写入 IPC 管道时会通过
JSON.stringify()进行序列化。所以最终发送到 IPC 通道中的信息都是字符串,send()方法能发送消息和句柄并不意味着它能发送任意对象。 - 连接了 IPC 通道的子进程可以读取到父进程发来的消息,将字符串通过
JSON.parse()解析还原为对象后,才触发message事件将消息体传递给应用层使用。在这个过程中,消息对象还要被进行过滤处理,message.cmd的值如果以 NODE_ 为前缀,它将响应一个内部事件 internalMessage。如果message.cmd的值为NODE_HANDLE,它将取出message.type的值和得到的文件描述符一起还原出一个对应的对象。
所以在子进程中,开发者会有一种服务器就是从父进程中直接传递过来的错觉。Node 进程之间只有消息传递,不会真正地传递对象,这种错觉是抽象封装的结果。
端口共同监听
- Node 在底层对每个端口监听都设置了 SO_REUSEADDR 选项,这个选项的涵义是不同的进程可以就相同的网卡和端口进行监听,这个服务器端套接字可以被不同的进程复用。
- 之前创建子进程直接监听同一个端口之所以报错,是因为独立启动的进程互相之间并不知道文件描述符。但对于
send()发送的句柄还原出来的服务而言,他们的文件描述符是同一个,所以监听相同的端口不会引起异常。 - 多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用。换言之就是网络请求向服务器端发送时,只有一个幸运的进程能够抢到连接,也就是说只有它能为这个请求进行服务。这些进程服务是抢占式的。
即:多个文件描述符监听同一个端口就会报错。
最后,上面的服务可以更轻量,将服务器句柄发送给子进程之后,就可以关掉服务器的监听,让子进程处理请求。
// parent.js
var cp = require('child_process')
var child1 = cp.fork('child.js')
var child2 = cp.fork('child.js')
var server = require('net').createServer()
server.listen(1337, () => {
child1.send('server', server)
child2.send('server', server)
// 停止接收新的连接,但保持当前存在的连接,即不再监听 p153
server.close()
})// child.js
var http = require('http')
var server = http.createServer((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'})
res.end(''handled by child pid is:' + process.pid + '\n'')
})
process.on('message', (m, tcp) => {
if (m === 'server') {
tcp.on('connection', socket => {
server.emit('connection', socket) // p160
})
};
})这样一来,所有请求都是子进程处理了。
集群稳定之路
我们需要建立一个健全的机制来保障 Node 应用的健壮性:
- 性能问题
- 多个工作进程的存活状态管理
- 配置或者静态数据的动态重新载入
- 其他细节
进程事件
父进程能监听到的与子进程相关的事件:
errorexitclosedisconnect
父进程除了 send() 外,还能通过 kill() 方法给子进程发送消息。kill() 方法并不能真正将通过 IPC 相连的子进程杀死,它只是给子进程发送了一个系统信号。Node 提供了这些信号对应的信号事件。
自动重启
我们能够通过监听子进程的 exit 事件来获知其退出的消息。接着前文的多进程架构,我们在主进程上药加入一些子进程管理的机制,比如重新启动一个工作进程来继续服务。
worker.on('exit', () => {
createWorker()
})自杀信号
有可能所有工作进程都在等待退出的状态,不能等到工作进程退出才重启新的工作进程,于是可以在工作进程退出的流程中想父进程发送一个信号,告诉父进程不要等 exit 才创建新的子进程了, process.send({act: 'suicide'}) .
限量重启
有可能自杀信号不是正常的,导致工作进程无意义频繁重启,为此,我们可以规定单位时间内只能重启多少次,超过限制就触发 giveup 事件,然后让监控系统监控 giveup 事件。
负载均衡
保证多个处理单元工作量公平的策略叫负载均衡。
Node 默认的机制是操作系统的抢占式策略,一般而言是公平的,但是对于 Node 而言,需要分清的是它的繁忙是有 CPU I/O 两个部分构成的,影响抢占的是 CPU 的繁忙度。对不同的业务,可能存在 I/O 繁忙,而 CPU 较为空闲的情况,这可能造成某个请求能够抢到较多请求,形成负载不均衡的情况。
为此 Node 在 v0.11 中提供了 Round-Robin(轮叫调度) 的新策略使得负载均衡更合理。在 cluster 模块中启用它的方式如下:
// 启用 Round-Robin
cluster.schedulingPolicy = cluster.SCHED_RR
// 不启用 Round-Robin
cluster.schedulingPolicy = cluster.SCHED_NONE状态共享
在第5章中我们提到 Node 进程中不宜存放太多的数据,因为它会加重垃圾回收的负担,进而影响性能。同时, Node 也不允许多个进程之间共享数据。但是在实际的业务中,往往需要共享数据,比如配置数据,这在多个进程中应当是一致的。
- 解决数据共享最直接最简单的方式就是通过第三方来进行数据存储。
- 数据变化时怎么通知各个进程需要一个机制,可以专门有一个进程来查询和发送通知状态,而不是每个子进程都进行轮询。
Cluster 模块
前文介绍了通过 child_process 模块构建强大的单机集群。由于有这么多细节需要处理,于是 Node 在 v0.8 时直接引入了 cluster 模块,用以解决多喝 CPU 的利用率问题,同时也提供了较完善的 API, 用于处理进程的健壮性问题。
var cluster = require('cluster')
cluster.setupMaster({
exec: 'worker.js'
})
var cpus = require('os').cpus()
for (var i = 0; i < cpus.length; i++) {
cluster.fork()
}Cluster 工作原理
事实上 cluster 模块是 child_process 和 net 模块的组合应用。cluster 启动时,它会在颞部启动 TCP 服务器,在 cluster.fork() 子进程时,将这个 TCP 服务器端 socket 的文件描述符发送给工作进程。
如果进程是通过 cluster.fork() 复制出来的,那么它的环境变量里就存在 NODE_UNIQUE_ID ,如果工作进程中存在 listen() 侦听网络端口的调用,它将拿到该文件描述符,通过 SO_REUSEADDR 端口重用,从而实现多个子进程共享端口。
对于普通方式启动的进程,则不存在文件描述符传递共享的事情。
Cluster 事件
fork
复制一个工作进程后触发该事件。online
复制好一个工作进程后,工作进程主动发送一条online消息给主进程,主进程收到消息后,触发该事件。listening
工作进程中调用listen()(共享了服务器端 Socket) 后,发送一条listening消息给主进程,主进程收到消息后,触发该事件。disconnect
主进程和工作进程之间 IPC 通道断开后会触发该事件。exit
有工作进程退出时触发该事件。setup
cluster.setupMaster()执行后触发该事件。