Skip to content

第9章 玩转进程 #15

@liujunyang

Description

@liujunyang

从严格意义上讲, 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 通道,父子进程之间通过 messagesend() 传递信息。

// 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 应用的健壮性:

  • 性能问题
  • 多个工作进程的存活状态管理
  • 配置或者静态数据的动态重新载入
  • 其他细节

进程事件

父进程能监听到的与子进程相关的事件:

  • error
  • exit
  • close
  • disconnect

父进程除了 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_processnet 模块的组合应用。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() 执行后触发该事件。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions