Node在选型时决定在V8引擎之上构建,就意味着它的模型和浏览器类似,JavaScript是运行在单个进程的单个线程上。它带来的好处是:程序状态是单一的,在没有多线程的情况下没有锁、线程同步问题,操作系统在调度时也因为较少上下文的切换,可以很好地提高CPU的使用率。
但是一个Node进程只能利用一个核,无法充分利用多核CPU服务器。
另外,一旦单线程上抛出的异常没有被捕获,将会引起整个进程的崩溃。
从严格意义上而言,Node并非真正的单线程架构,它自身还有一定的I/O线程的存在,这些I/O线程由底层libuv处理。这部分线程,只有C++扩展开发时才会关注到。JavaScript代码永远运行在V8上,是单线程的。本章将围绕JavaScript部分展开,所以屏蔽底层细节的讨论。
服务模型的变迁
web服务器的架构经历了几次变迁。服务器处理客户端请求的并发量,就是每个里程碑的见证。
石器时代:同步
一次只为一个请求服务,所有请求都得按次序等待服务器。假设每次响应服务耗用的时间稳定为N秒,这类服务的QPS为1/N。
青铜时代:复制进程
通过进程的复制同时服务更多的请求和用户。每个连接都需要一个进程来服务,这是非常昂贵的代价。在进程复制的过程中,需要复制进程内部的状态,相同的状态将会在内存中存在很多份,造成浪费。并且这个过程由于要复制较多的数据,启动是较为缓慢的。
所以预复制被引入服务模型中,即预先复制一定数量的进程。同时将进程复用,避免进程创建、销毁带来的开销。但是这个模型并不具备伸缩性,一旦并发请求过高,内存使用随着进程数的增长将会被耗尽。
假设通过进行复制和预复制的方式搭建的服务器有资源的限制,且进程数上限为M,那这类服务的QPS位M/N.
白银时代:多线程
为了解决进程复制中的浪费问题,多线程被引入服务模型,让一个线程服务一个请求。线程相对进程的开销要小许多,并且线程之间可以共享数据,内存浪费的问题可以得到解决,并且利用线程池可以减少创建和销毁线程的开销。但是多线程所面临的并发问题只能说比多进程略好,因为每个进程都拥有自己独立的堆栈,这个堆栈都需要占用一定的内存空间。另外,由于一个CPU核心在一个时刻只能做一件事,操作系统只能通过将CPU切分为时间片的方法,让线程可以较为均匀地使用CPU资源,但是操作系统内核在切换线程的同时也要切换线程的上线文,当线程数量过多时,时间将会被耗用在上下文切换中。所以在大并发量时,多线程结构还是无法做到强大的伸缩性。
如果忽略掉多线程上下文切换的开销,假设线程所占用的资源为进程的1/L,受资源上限的影响,它的QPS为M*L/N
黄金时代:事件驱动
多线程的服务器模型服役了很长一段时间,Apache就是采用多线程/多进程模型实现的,当并发增长到上万时,内存耗用的问题将会暴露出来,即著名的C10k问题。
Node与Nginx则是基于事件驱动的服务模型实现的,采用单线程避免了不必要的内存开销和上下文切换开销。
基于事件驱动的服务模型存在前面提及的CPU利用率和进程健壮性的两个问题。
由于所有处理都是在单线程上进行,影响事件驱动服务模型性能的点在于CPU的计算能力,它的上限决定了这类服务模型的性能上限,但它不受多进程或多线程模式中资源上限的影响,可伸缩性远比前两者高。如果解决掉多核CPU的利用问题,带来的性能上提升是可观的。
多进程架构
面对单进程单线程对多核CPU使用不足的问题,前人的经验是启用多进程即可。每个进程利用一个CPU,以此实现多核CPU的利用。Node提供了child_process模块,并且也提供了child_process.fork()函数供我们实现进程的复制。
// worker.js
var http = require('http');
http.createServer(function (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');
通过 node mater.js启动它
// mater.js
var fork = require('child_process').fork;
var cpus = require('os')_cpus();
for (var i = 0; i < cpus.length; i++) {
fork('./worker.js');
}
这就是著名的Master-Workers模式,又称主从模式。这是典型的分布式架构中用来并行处理业务的模式,具备较好的可伸缩性和稳定性。主进程不负责具体的业务处理,而是负责调度或管理工作进程,它是趋于稳定的。工作进程负责具体的业务处理。
通过fork()复制的进程都是一个独立的进程,这个进程中有着独立而全新的V8实例。它需要至少30毫秒的启动时间和至少10MB的内存。fork()进程是昂贵的,好在Node通过事件驱动的方式在单线程上解决了大并发的问题,这里启动多个进程只是为了充分将CPU资源利用起来,而不是为了解决并发问题。
创建子进程
child_process模块提供了4个方法用于创建子进程。
- spawn():启动一个子进程来执行命令
- exec():启动一个子进程来执行命令,与spawn不同的是其接口不同,它有一个回调函数获知子进程的状况。
- execFile():启动一个子进程来执行可执行文件
- fork():与spawn类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。
spawn()与exec()、execFile()不同的是,后两者创建时可以指定timeout属性设置超时时间,一但创建的进程运行超过设定的时间将会被杀死。
exec()和execFile()不同的是,exec()适合执行已有的命令,execFile()适合执行文件。
node worker.js分别用上述4种方法实现如下:
var cp = require('child_process');
cp.spawn('node', ['worker.js']);
cp.exec('node worker.js', function (err, stdout, stderr) {
// some code
});
cp.execFile('worker.js', function (err, stdout, stderr) {
// some code
});
cp.fork('./worker.js');
以上4个方法在创建子进程之后均会返回子进程对象。
它们主要在是否有回调/异常、进程类型、执行类型、是否可设置超时上有差别。
这里的可执行文件是指可以直接执行的文件,如果是JavaScript文件通过execFile()运行,它的首行内容必须添加如下代码:
#!/usr/bin/env node
事实上其他三种方法都是spawn()的延伸。
进程间通信
在Master-Worker模式中,要实现主进程管理和调度工作进程的功能,需要主进程和工作进程之间的通信。
和HTML5的WebWorker API一样,Node可以通过消息传递内容,而不是共享或直接操作相关资源,这是较为轻量和无依赖的做法。
// parent.js
var cp = require('child_process');
var n = cp.fork(__dirname + '/sub.js');
n.on('message', function (m) {
console.log('PARENT get message:', m);
});
n.send({ hello: 'world' });
// sub.js
process.on('message', function (m) {
console.log('CHILD got message:', m);
});
process.send({ foo: 'bar' });
父进程与子进程之间将会创建IPC通道。通过IPC通道,父子进程之间才能通过message和send()传递消息。
进程间通信原理
IPC的全称是Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作。Node中实现IPC通道的是管道(pipe)技术,但此管道非彼管道,在Node中管道是个抽象层面的称呼,具体细节实现由libuv提供,在Windows下由命名管道实现,*nix系统则采用Unix Domain Socket实现。表现在应用层上的进程间通信只有简单的message事件和send()方法。
父进程在创建子进程之前,会创建IPC通道并监听它,然后才真正创建出子进程,并通过环境变量(NODE_CHANNEL_FE)告诉子进程这个IPC通道的文件描述符。子进程在启动的过程中,根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。
建立连接之后的父子进程就可以自由地通信了。由于IPC通道是用命名管道或Domain Socket创建的,它们与网路socket的行为比较类似,属于双向通信。不同的是它们在系统内核中就完成了进程间通信,而不用经过实际的网络层,非常高效。在Node中,IPC通道被抽象为Stream对象,在调用send()时发送数据(类似于write()),接收到的消息会通过message事件(类似于data)触发给应用层。
句柄传递
如果让服务器都监听到相同的端口会抛EADDRINUSE异常,这个问题破坏了我们将多个进程监听同一个端口的想法。通常的做法是让每个进程监听不同的端口,主进程监听主端口,主进程对外接收所有的网络请求,再将这些请求分别代理到不同端口的进程中。
在代理进程上可以做适当的负载均衡,使每个子进程可以较为均衡地执行任务。由于进程每收到一个连接,将会用掉一个文件描述符,因此代理方案中客户端连接到代理进程,代理进程连接到工作进程的过程需要用掉两个文件描述符。操作系统的文件描述符是有限的,这影响了系统的扩展能力。
Node在版本v0.5.9引入了进程间发送句柄的功能。send()方法除了能通过IPC发送数据外,还能发送句柄,第二个可选参数就是句柄:
child.send(message, [sendHandle]);
句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。比如句柄可以用来标识一个服务器端socket对象、一个客户端socket对象、一个UDP套接字、一个管道等。
我们可以替换掉代理方法,主进程接收到socket请求后,将这个socket直接发送给工作进程,而不是重新与工作进程之间建立新的socket连接来转发数据。
var child = require('child_process').fork('child.js');
var server = require('net').createServer();
server.on('connection', function (socket) {
socket.end('handled by parent');
});
server.listen(1337, function () {
child.send('server', server);
});
子进程
process.on('message', function (m, server) {
if (m === 'server') {
server.on('connection', function (socket) {
socket.end('handled by child');
});
}
});
示例中直接将一个TCP服务器发送给了子进程。
我们通过curl命令测试:
curl "http://127.0.0.1:1337/"
命令行响应结果是很不可思议的,这里子进程和父进程都有可能处理我们客户端发起的请求。
试试将服务发送给多个子进程:
// 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', function (socket) {
socket.end('handle by parent\n')
});
server.listen(1337, function() {
child1.send('server', server);
child2.send('server', server)
});
在子进程中将进程ID打印出来
// child.js
process.on('message', function (m, server) {
if (m === 'server') {
server.on('connection', function (socket) {
socket.end('handle by child,pid is ' + process.pid + '\n')
})
}
})
每次测试出现的结果都可能不同
handle by child,pid is 24673
handle by parent
handle by child,pid is 24672
这是在TCP层面上完成的事情,我们尝试将其转化到HTTP层面来试试。对于主进程,我们想让它更轻量一些,将服务器句柄发送给子进程之后,关掉服务器的监听,让子进程来处理请求。
// 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, function () {
child1.send('server', server);
child2.send('server', server);
server.close();
});
// child.js
var http = require('http');
var server = http.createServer(function (req, res) {
res.writedHead(200, {"Content-Type": "text/plain"});
res.end("handled by child, pid is " + process.pid + '\n')
})
process.on("message", function (m, tcp) {
if (m === 'server') {
tcp.on('connection', function (socket) {
server.emit("connection", socket);
})
}
})
测试结果
handle by child,pid is 24673
handle by child,pid is 24672
这样一来,所有的请求都是由子进程处理了。整个过程中,服务的过程只发生了一次改变。
先是主进程将请求发送给工作进程,主进程发送完句柄并关闭监听之后,多个子进程直接监听相同端口,并且不会抛EADDRINUSE异常。
句柄发送与还原
前面的句柄发送虽然看上去跟直接将服务器对象发送给子进程没有差别,但其实它并不是真的发送了服务器对象。
目前子进程对象send()方法可以发送的句柄类型包括如下几种
- net.Socket。TCP套接字
- net.Server。 TCP服务器,任意建立在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()方法能发送消息和句柄并不意味着它能发送任意对象。
连接了IPC通道的子进程可以读取到父进程发来的消息,将字符串通过JSON.parse()解析还原为对象后,才触发message事件将消息体传递给应用层使用。在这个过程中,消息对象还要被进行过滤处理,message.cmd的值如果以NODE_为前缀,它将响应一个内部事件internalMessage。如果是NODE_HANDLE,它将取出message.type值和得到的文件描述符一起还原出一个对应的对象。
以发送的TCP服务器句柄为例,子进程收到消息后的还原过程如下所示
function(message, handle, emit) {
var self = this;
var server = new net.Server();
server.listen(handle, function () {
emit(server);
});
}
子进程根据message.type创建对应TCP服务对象,然后监听到文件描述符上。由于底层细节不被应用层感知,所以在子进程中,开发者会有一种服务器就是从父进程中直接传递过来的错觉。值得注意的是,Node进程之间只有消息传递,不会真正地传递对象,这种错觉就是抽象封装的结果。
除了上述提到的几种句柄,并非任意类型的句柄都能在进程之间传递,除非它有完整的发送和还原的过程。
端口共同监听
抛EADDRINUSE异常的原因,是我们独立启动的进程中,TCP服务器端socket套接字的文件描述符并不相同,导致监听到相同的端口时会抛异常。
Node底层对每个端口监听都设置了SO_REUSEADDR选项,含义是不同进程可以就相同的网卡和端口进行监听,这个服务器端套接字可以被不同进程复用,如下所示
setsockopt(tcp->io_watcher.fd, SQL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
由于独立启动的进程互相之间并不知道文件描述符,所以监听相同端口就会失败。但对于send()发送的句柄还原出来的服务而言,它们的文件描述符是相同的,所以监听相同端口不会引起异常。
多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用。换言之就是网络请求向服务端发送时,只有一个幸运的进程能够抢到连接,也就是说只有它能为这个请求进程服务。这些进程服务是抢占式的。
通过这些基础技术,用child_process模块在单机上搭建Node集群是件相对容易的事情,因此在多核CPU的环境下,让Node进程能够充分利用资源不再是难题。
参考
- 《深入浅出node.js》