愿你坚持不懈,努力进步,进阶成自己理想的人

—— 2017.09, 写给3年后的自己

《深入浅出NodeJS》读书笔记之集群

一、单线程模型的优缺点与服务模型

1、Node与单线程模型

NodeJS基于Chrome V8引擎构建,因此JavaScript将会运行在单个进程的单个线程上。如此的优点在于:

1)程序状态是单一的,没有多线程的锁和线程同步问题
2)较少的上下文切换,可以提高CPU的使用率

但是单进程模型也不是完美的结构,它的缺点则在于:

1)一个Node进程只能利用一个CPU核心,如何提高CPU核心利用率?
2)Node执行在单线程上,一旦单线程抛出错误挂掉,那么整个进程就退出,故如何保证进程的健壮性和稳定性?

2、服务模型

WEB服务器的架构历经了多次变迁,总结而言,有:同步、复制进程、多线程、事件驱动这些架构。它们的介绍如下:
1)同步:执行模型是同步的,一次只服务一个请求,其他请求只能处于等待状态。QPS为1/N
2)复制进程:通过复制进程来同时服务更多的请求,到来多少个请求就启动多少个进程。但由于进程复制代价昂贵:需要复制内部状态,对于每个连接都进行重复的复制过程,相同的状态可能在内存中存在多份,造成浪费。虽然可以使用预复制(事先复制一定数量的进程)和进程复用策略,但是这个模型的伸缩性很差,一旦并发的请求过高,内存将快速耗尽。假设进程上线为M,则QPS为M/N
3)多线程:通过让一个线程服务一个请求的方式来解决进程复制中的浪费问题。线程创建代价要小于进程,且由于线程之间可共享数据,因此内存的浪费问题也得到了解决,还可以利用线程池减少创建和销毁进程的开销,此外线程的上下文切换速度也要快于进程,但多线程只能说比复制进程策略好,但是仍然无法做到非常强大的伸缩性。假设线程占用的资源为进程的1/L,那么QPS为(M×L)/N
4)事件驱动:多线程服务模型使用了很长一段时间,但事实证明当并发量达到上万时,内存耗用问题很明显(即为著名的C10K问题)。事件驱动在每个新的请求到来时,会触发相应的事件,并且对于I/O操作,也会在操作完成时触发相应的事件,通过事件循环,相应的回调函数就会得以执行。它的好处在于,它避免了不必要的内存开销和上下文切换开销,因此能够处理更多的并发连接。由于所有处理都在单线程上进行,因此影响事件驱动服务模型性能的因素在于CPU的计算能力,它不受多进程/多线程模式中资源上限的影响,可伸缩性远比前两者高。再通过解决多核CPU的利用问题,其性能相当可观。


二、多进程架构

面对单进程单线程对多核利用不足的问题,解决方式是启动多进程。通过child_process模块里的child_process.fork()函数,我们可以实现进场的复制。示例如:

// worker.js
const http = require('http')
http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' })
    res.end('Hello, world\n')
}).listen(Math.round(1 + Math.random() * 1000), '127.0.0.1')
// master.js
const fork = require('child_process').fork
const cpus = require('os').cpus()
for (let i = 0; i < cpus.length; i++) {
    fork('./worker.js')
}

以上代码会根据当前机器的CPU核心数复制出对应的Node进程数,可以使用ps aux | grep worker.js来验证一下:
9-1.png
而以上代码采用的模式便是著名的主从模式(Master-Worker模式),它是典型的分布式架构中用于并行处理业务的模型,具备较好的伸缩性和稳定性。主进程负责调度和管理工作进程,而工作进场则负责具体业务处理,因此主进程是趋向于稳定的。
9-2.png
通过fork()复制出的进程都是独立的进程,且是独立而全新的V8实例,它需要至少30ms的启动时间和至少10MB的内存,但是启动多进程只是为了 充分利用CPU资源,而非解决并发问题

1、创建子进程

child_process模块提供了4个方法用于创建子进程:
1)spawn(command[, args][, options]):启动一个子进程来执行命令
2)exec(command[, options][, callback]):启动一个子进程来执行命令,与spawn()不同的是,它有回调函数可以获知子进程的状况
3)execFile(file[, args][, options][, callback]):启动一个子进程来执行可执行文件,注意:通过这个方法执行的JavaScript文件,需要在首行添加:

#!/usr/bin/env node

4)fork(modulePath[, args][, options]):与spawn()类似,但是它创建Node的子进程只需指定要执行的JavaScript文件模块即可。此外,fork只能执行JavaScript文件
它们的区别可总结为:

  • spawn()exec()execFile()的区别在于,后两者可以指定超时间timeout,一旦创建的进程运行超过设定的时间就会被杀死
  • exec()execFile()的区别则在于,前者适合执行命令,后者适合执行文件。

四个方法的用法示例如下:

const 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')

2、进程间通信

Master-Worker模式中,要实现主进程管理和调度工作进程的功能,就需要主进程工作进程之间的通信。在child_process模块中,主要通过message事件和send()方法进行通信,如下:

// master.js
const cp = require('child_process')
const worker = cp.fork(__dirname + '/worker.js')
worker.on('message', (m) => {
    console.log('Get message from worker: ', m)
})
worker.send({
    msg: 'I am master process'
})

// worker.js
process.on('message', (m) => {
    console.log('Get message from master: ', m)
})
process.send({
    msg: 'I am worker process'
})

3、进程间通信(Inter-Process Communication, IPC)原理

进程间通信的目的是让不同的进程能够互相访问资源并进行协调工作,实现进程间通信的技术有:命名管道匿名管道socket信号量共享内存消息队列Domain Socket等。
Node中使用的是管道技术来实现IPC,但管道是个抽象层面的称呼,具体实现由libuv提供。libuv中,在windows下采用命名管道实现,*nix下采用Unix Domain Socket实现。示意图如下:
9-3.png
父进程在实际创建子进程之前,会创建并监听IPC通道,然后才真正去创建子进程,通过环境变量NODE_CHANNEL_FD告诉子进程这个IPC通道的文件描述符。然后子进程在启动时,便根据文件描述符连接已存在的IPC通道,然后完成父子进程之间的连接。
建立连接后,父子进程之间就可以进行双向通信了,类似于网络socket的行为,但是不会经过网络层。此外,IPC通道在Node中会被抽象为Stream对象,在调用send()时就发送数据,接收到的消息则会通过message事件触发给应用层

注意:只有启动的子进程是Node进程,子进程才会根据环境变量去连接IPC通道,其他类型的子进程则无法实现IPC,除非其他进程也按约定去连接已创建的IPC通道

4、句柄传递

在开头的示例中,worker进程中的端口号是不同的,这是因为如果端口号相同,会导致如下错误:

events.js:183
      throw er; // Unhandled 'error' event
      ^

Error: listen EADDRINUSE 127.0.0.1:8888

可以发现,此时只有一个工作进程正常,而其他的进程在监听的过程中都抛出了EADDRINUSE异常,即端口被占用了。所以一种处理方式是代理:主进程监听主端口(如80端口),再将这些请求分别代理到不同端口的进程上,如:
9-4.png
如此,可以避免端口占用问题,还可以在代理进程上做适当的负载均衡,使得每个工作进程都能较均衡地执行任务。但是由于进程每收到一个连接,就会用掉一个文件描述符,代理进程连接到工作进程又会用掉一个文件描述符,所以代理方式下,会用掉双倍的文件描述符。但操作系统文件描述符的数量是有限的,因此代理方案这种做法限制了系统的扩展能力。
Node在v0.5.9中引入了进程间发送句柄的功能,从而能够解决上述问题。send()方法除了能够通过IPC发送数据外,还能发送句柄(第二个参数),如:

child.send(message, [sendHandle])

句柄是一种可以标识资源的引用,它的内部包含了指向对象的文件描述符。因此,句柄可以标识一个服务端socket对象、一个客户端socket对象、一个UDP套接字、一个管道等。
因此,利用句柄,我们就可以换种方案:主进程接收到socket请求后,socket直接发送给工作进程,而不是重新与工作进程建立新的socket连接来转发数据,如下:

// master.js
const worker = require('child_process').fork('./worker.js')
const server = require('net').createServer()
server.on('connection', (socket) => {
    socket.end('handled by master\n')
})
server.listen(1337, () => {
    worker.send('server', server)
})
// worker.js
process.on('message', (m, server) => {
    if (m === 'server') {
        server.on('connection', (socket) => {
            socket.end('handled by worker\n')
        })
    }
})

然后,启动master进程,使用curl发送测试命令如下:

$ curl 'http://127.0.0.1:1337'
handled by master
$ curl 'http://127.0.0.1:1337'
handled by worker
$ curl 'http://127.0.0.1:1337'
handled by worker
$ curl 'http://127.0.0.1:1337'
handled by master

可以看到,主进程和工作进程都有可能处理客户端发起的请求。我们还可以进一步测试,将服务发送给多个子进程,如下:

// master.js
const cpus = require('os').cpus()
const fork = require('child_process').fork
const workers = []
const server = require('net').createServer()
for (let i = 0; i < cpus.length; ++i) {
    workers.push(fork('./worker.js'))
}

server.on('connection', (socket) => {
    socket.end('handled by master\n')
})
server.listen(1337, () => {
    workers.forEach((worker) => {
        worker.send('server', server)
    })
})
// worker.js
process.on('message', (m, server) => {
    if (m === 'server') {
        server.on('connection', (socket) => {
            socket.end(`handled by worker, pid is ${process.pid}\n`)
        })
    }
})

启动主进程,测试结果如下:

$ curl 'http://127.0.0.1:1337'
handled by worker, pid is 2891
$ curl 'http://127.0.0.1:1337'
handled by worker, pid is 2894
$ curl 'http://127.0.0.1:1337'
handled by worker, pid is 2892
$ curl 'http://127.0.0.1:1337'
handled by worker, pid is 2893
$ curl 'http://127.0.0.1:1337'
handled by master

接下来,我们还可以再做一些改动:将服务器句柄发给子进程后,就关掉服务器的监听,让子进程来处理请求,如下:

// master.js
const cpus = require('os').cpus()
const fork = require('child_process').fork
const workers = []
const server = require('net').createServer()
for (let i = 0; i < cpus.length; ++i) {
    workers.push(fork('./worker.js'))
}

server.on('connection', (socket) => {
    socket.end('handled by master\n')
})
server.listen(1337, () => {
    workers.forEach((worker) => {
        worker.send('server', server)
    })
    server.close()
})
// worker.js
const http = require('http')
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' })
    res.end(`handled by worker, pid is ${process.pid}\n`)
})
process.on('message', (m, tcp) => {
    if (m === 'server') {
        tcp.on('connection', (socket) => {
            server.emit('connection', socket)
        })
    }
})

如此一来,所有请求都是由子进程处理了,图示如下:
9-5.png

5、句柄的发送原理

以上示例中,我们可以可能会有几个疑问:
1)句柄发送跟直接将服务器对象发送给子进程有没有差别?
2)句柄是否真的将服务器对象发送给了子进程?为什么它可以发送到多个子进程中?
3)发送给子进程后,为什么父进程中还存在这个对象?
实际上,send()方法可以发送的句柄类型有:

  • net.Socket,TCP套接字
  • net.Server,TCP服务器
  • net.Native,C++层面的TCP套接字或IPC管道
  • dgram.Socket,UDP套接字
  • dgram.Native,C++层面的UDP套接字

send()方法在将消息发送到IPC管道前,会将消息组装成两个对象:handle和message,其中message形如:

{
    cmd: 'NODE_HANDLE',
    type: 'net.Server',
    msg: message
}

发送到IPC管道的实际上是要发送的句柄文件描述符(文件描述符是一个整数值),然后message对象在写入到IPC管道时,会通过JSON.stringify()进行序列化,故最终发送到IPC通道中的信息都是字符串,因此虽然send()方法可以发送消息和句柄,但并不意味着它可以发送任何对象。
因此,子进程是这么响应父进程的消息的:
1)子进程通过IPC通道读取到父进程发送来的消息,然后将字符串使用JSON.parse()解析为对象后,触发message事件将消息体传送给应用层使用。
2)消息对象会被进行过滤处理。判断message.cmd,若其值以NODE_为前缀,就将响应内部事件internalMessage,若message.cmd值为NODE_HANDLE,就取出message.type和文件描述符,然后还原出一个对应的对象,图示如下:
9-6.png
以发送的TCP句柄为例,子进程收到消息后,其还原过程如下所示:

function (message, handle, emit) {
    var self = this;
    var server = new net.Server()
    server.listen(handle, function() {
        emit(server)
    })
}

子进程根据message.type创建对应TCP服务器对象,然后监听到文件描述符上。所以:Node进程之间只有消息传递,不会真正地传递对象,开发者之所以会有种server是从服务器传过来的错觉,则是由抽象封装带来的

6、端口共同监听

为何通过发送句柄,多个进程可以监听到相同的端口而不引起EADDRINUSE异常?这是因为在独立启动的进程中,TCP服务器端socket套接字的文件描述符并不相同,这就导致监听相同的端口会抛出异常。
Node底层对每个端口监听都设置了SO_REUSEADDR选项,该选项表示不同进程可以就相同的网卡和端口进行监听,这个服务端套接字可以被不同的进程复用,如:

setsocket(tcp->io_watcher.fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on))

由于独立启动的进程之间互不知道文件描述符,故监听相同端口时就会失败。但使用send()发送的句柄还原出来的服务,其文件描述符是相同的,故监听相同的端口不会引起异常。
多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用,所以就是网络请求时,只有一个进程能够抢到连接,即多个工作进程对一个请求进行服务是抢占式的。