背景
去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。
没有计划的阅读,收效甚微。
新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。
这个“玩法”虽然常见且板正,但是有效。
已读完书籍:《架构简洁之道》。
当前阅读周书籍:《深入浅出的Node.js》。
玩转进程
服务模型的变迁
Web服务器的架构发展到现在,已经历了几次变迁。服务器处理客户端请求的并发量,就是每个里程碑的见证。
石器时代:同步
最早的服务器,其执行模型是同步的,它的服务模式是一次只为一个请求服务,所有请求都得按次序等待服务。
除了当前的请求被处理外,其余请求都处于耽误的状态,服务器的处理能力相当低下。
青铜时代:复制进程
为了解决同步架构的并发问题,一个简单的改进是通过进程的复制同时服务更多的请求和用户。
在进程复制的过程中,需要复制进程内部的状态,这个过程由于要复制较多的数据,启动是较为缓慢的,且相同的状态将会在内存中存在很多份,造成浪费。
为了解决启动缓慢的问题,预复制(prefork)被引入服务模型中,即预先复制一定数量的进程。同时将进程复用,避免进程创建、销毁带来的开销。
白银时代:多线程
为了解决进程复制中的浪费问题,多线程被引入服务模型,让一个线程服务一个请求。线程相对进程的开销要小许多,并且线程之间可以共享数据,内存浪费的问题可以得到解决,并且利用线程池可以减少创建和销毁线程的开销。
在大并发量时,多线程结构还是无法做到强大的伸缩性。
黄金时代:事件驱动
为了解决高并发问题,基于事件驱动的服务模型出现了,像Node与Nginx均是基于事件驱动的方式实现的,采用单线程避免了不必要的内存开销和上下文切换开销。
基于事件的服务模型存在两个问题: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 worker.js`启动它,将会侦听1000到2000之间的一个随机端口。
新增master.js文件保存如下代码,然后通过`node master.js`启动它:
var fork = require('child_process').fork;
var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) {
fork('./worker.js');
}
这段代码将会根据当前机器上的CPU数量复制出对应Node进程数。在*nix系统下可以通过ps aux | grep worker.js查看到进程的数量,如下所示:
> ps aux | grep worker.js
> yeyiyi 4441 0.0 0.1 4550636 21436 s003 S+ 10:29上午 0:00.05 /usr/local/bin/node ./worker.js
> yeyiyi 4440 0.0 0.1 4576236 21548 s003 S+ 10:29上午 0:00.05 /usr/local/bin/node ./worker.js
> yeyiyi 4439 0.0 0.1 4585452 21592 s003 S+ 10:29上午 0:00.05 /usr/local/bin/node ./worker.js
> yeyiyi 4438 0.0 0.1 4559852 21564 s003 S+ 10:29上午 0:00.05 /usr/local/bin/node ./worker.js
> yeyiyi 4437 0.0 0.1 4576236 21604 s003 S+ 10:29上午 0:00.05 /usr/local/bin/node ./worker.js
> yeyiyi 4436 0.0 0.1 4558828 21560 s003 S+ 10:29上午 0:00.05 /usr/local/bin/node ./worker.js
> yeyiyi 4435 0.0 0.1 4558828 21524 s003 S+ 10:29上午 0:00.05 /usr/local/bin/node ./worker.js
> yeyiyi 4434 0.0 0.1 4549612 21572 s003 S+ 10:29上午 0:00.05 /usr/local/bin/node ./worker.js
> yeyiyi 4418 0.0 0.1 4552172 19216 s001 S+ 10:28上午 0:00.08 node worker.js
Master-Worker模式,又称主从模式。它的进程分为两种:主进程和工作进程。主进程不负责具体的业务处理,而是负责调度或管理工作进程。工作进程负责具体的业务处理。
创建子进程
Node的child_process模块可以随意创建子进程(child_process)。它提供了4个方法用于创建子进程:
- spawn():启动一个子进程来执行命令。
- exec():启动一个子进程来执行命令,与spawn()不同的是其接口不同,它有一个回调函数获知子进程的状况。
- execFile():启动一个子进程来执行可执行文件。
- fork():与spawn()类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。
进程间通信
在Master-Worker模式中,要实现主进程管理和调度工作进程的功能,需要主进程和工作进程之间的通信。
HTML5提出了WebWorker API。WebWorker允许创建工作线程并在后台运行,使得一些阻塞较为严重的计算不影响主线程上的UI渲染。它的API如下:
var worker = new Worker('worker.js');
worker.onmessage = function (event) {
document.getElementById('result').textContent = event.data;
};
worker.js中的代码如下:
var n = 1;
search: while (true) {
n += 1;
for (var i = 2; i <= Math.sqrt(n); i += 1) if (n % i == 0) continue search;
// found a prime
postMessage(n);
}
主线程与工作线程之间通过onmessage()和postMessage()进行通信,子进程对象则由send()方法实现主进程向子进程发送数据,message事件实现收听子进程发来的数据。
创建子进程之后,为了实现父子进程之间的通信,父进程与子进程之间将会创建IPC通道。通过IPC通道,父子进程之间才能通过message和send()传递消息。
IPC即进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作。Node中实现IPC通道的是管道(pipe)技术。在Node中管道是个抽象层面的称呼,具体细节实现由libuv提供。
句柄传递
当端口被占用的情况出现,新的进程便不能继续监听该端口了。
解决这个问题,通常的做法是让每个进程监听不同的端口,其中主进程监听主端口(如80),主进程对外接收所有的网络请求,再将这些请求分别代理到不同的端口的进程上。
通过代理,可以避免端口不能重复监听的问题,但是代理进程连接到工作进程的过程需要用掉两个文件描述符,这样浪费掉一倍数量的文件描述符的做法影响了系统的扩展能力。
为了解决这个问题,Node在版本v0.5.9引入了进程间发送句柄的功能。send()方法除了能通过IPC发送数据外,还能发送句柄,第二个可选参数就是句柄,如下所示:
child.send(message, [sendHandle])
句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。比如句柄可以用来标识一个服务器端socket对象、一个客户端socket对象、一个UDP套接字、一个管道等。
集群稳定之路
搭建好了集群,充分利用了多核CPU资源,仍有许多细节需要考虑。
虽然创建了很多工作进程,但每个工作进程依然是在单线程上执行的,它的稳定性还不能得到完全的保障。需要建立起一个健全的机制来保障Node应用的健壮性。
进程事件
Node事件:
- error:当子进程无法被复制创建、无法被杀死、无法发送消息时会触发该事件。
- exit:子进程退出时触发该事件,子进程如果是正常退出,这个事件的第一个参数为退出码,否则为null。如果进程是通过kill()方法被杀死的,会得到第二个参数,它表示杀死进程时的信号。
- close:在子进程的标准输入输出流中止时触发该事件,参数与exit相同。
- disconnect:在父进程或子进程中调用disconnect()方法时触发该事件,在调用该方法时将关闭监听IPC通道。
这些事件是父进程能监听到的与子进程相关的事件。除了send()外,还能通过kill()方法给子进程发送消息。
自动重启
有了父子进程之间的相关事件之后,就可以在这些关系之间创建出需要的机制了。可以通过监听子进程的exit事件来获知其退出的信息。
一旦有未捕获的异常出现,工作进程就会立即停止接收新的连接;当所有连接断开后,退出进程。主进程在侦听到工作进程的exit后,将会立即启动新的进程服务,以此保证整个集群中总是有进程在为用户服务的。
负载均衡
在多进程之间监听相同的端口,使得用户请求能够分散到多个进程上进行处理,这带来的好处是可以将CPU资源都调用起来。这种保证多个处理单元工作量公平的策略叫负载均衡。
Node默认提供的机制是采用操作系统的抢占式策略。所谓的抢占式就是在一堆工作进程中,闲着的进程对到来的请求进行争抢,谁抢到谁服务。
Node在v0.11中提供了一种新的策略使得负载均衡更合理,这种新的策略叫Round-Robin,又叫轮叫调度。轮叫调度的工作方式是由主进程接受连接,将其依次分发给工作进程。
状态共享
Node不允许在多个进程之间共享数据。但在实际的业务中,往往需要共享一些数据,譬如配置数据,这在多个进程中应当是一致的。为此,在不允许共享数据的情况下,我们需要一种方案和机制来实现数据在多个进程之间的共享。
1、第三方数据存储
解决数据共享最直接、简单的方式就是通过第三方来进行数据存储,比如将数据存放到数据库、磁盘文件、缓存服务(如Redis)中,所有工作进程启动时将其读取进内存中。
2、主动通知
一种改进的方式是当数据发生更新时,主动通知子进程。
用来发送通知和查询状态是否更改的进程叫做通知进程。
Cluster模块
Node在v0.8版本中引入了cluster模块,用以解决多核CPU的利用率问题,同时也提供了较完善的API,用以处理进程的健壮性问题。
Cluster工作原理
cluster模块是child_process和net模块的组合应用。cluster启动时会在内部启动TCP服务器,在cluster.fork()子进程时,将这个TCP服务器端socket的文件描述符发送给工作进程。
如果进程是通过cluster.fork()复制出来的,那么它的环境变量里就存在NODE_UNIQUE_ID,如果工作进程中存在listen()侦听网络端口的调用,它将拿到该文件描述符,通过SO_REUSEADDR端口重用,从而实现多个子进程共享端口。
Cluster事件
对于健壮性处理,cluster模块也暴露了许多的事件:
- fork:复制一个工作进程后触发该事件。
- online:复制好一个工作进程后,工作进程主动发送一条online消息给主进程,主进程收到消息后,触发该事件。
- listening:工作进程中调用listen()(共享了服务器端Socket)后,发送一条listening消息给主进程,主进程收到消息后,触发该事件。
- disconnect:主进程和工作进程之间IPC通道断开后会触发该事件。
- exit:有工作进程退出时触发该事件。
- setup:cluster.setupMaster()执行后触发该事件。
总结
我们来总结一下本篇的主要内容:
- 通过创建子进程、进程间通信的IPC通道实现、句柄在进程间的发送和还原、端口共用等基础技术,用child_process模块在单机上搭建Node集群是件相对容易的事情。因此在多核CPU的环境下,让Node进程能够充分利用资源不再是难题。
- 通过child_process模块可以大幅提升Node的稳定性,但是一旦主进程出现问题,所有子进程将会失去管理。
- 在实际的复杂业务中,我们可能要启动很多子进程来处理任务,结构甚至远比主从模式复杂,但是每个子进程应当是简单到只做好一件事,然后通过进程间通信技术将它们连接起来即可。
- 在Node的进程管理之外,还需要用监听进程数量或监听日志的方式确保整个系统的稳定性,即使主进程出错退出,也能及时得到监控警报,使得开发者可以及时处理故障。
作者介绍非职业「传道授业解惑」的开发者叶一一。《趣学前端》、《CSS畅想》等系列作者。华夏美食、国漫、古风重度爱好者,刑侦、无限流小说初级玩家。如果看完文章有所收获,欢迎点赞👍 | 收藏⭐️ | 留言📝。