5

内容

9.玩转进程
10.测试
11.产品化

一、玩转进程

node的单线程只不过是js层面的单线程,是基于V8引擎的单线程,因为,V8的缘故,前后端的js执行模型基本上是类似的,但是node的内核机制依然是通过libuv调用epoll或者IOCP的多线程机制。换句话说,node从严格意义上讲,并非是真正的单线程架构,node内核自身有一定的IO线程和IO线程池,通过libuv的调度,直接使用了操作系统层面的多线程。node的开发者,可以通过扩展c/c++模块来直接操纵多线程来提高效率。不过,单线程带来的好处是程序状态单一,没有锁、线程同步、线程上下文切换等问题。但是单线程的程序,并非是完美的。现在的服务器很多都是多cpu,多cpu核心的,一个node实例只能利用一个cpu核心,那么其他的cpu核心不就浪费了吗?并且,单线程的容错也很弱,一旦抛出了没有捕获的异常,必将引起整个程序的崩溃,那这样的程序必然是非常脆弱的,这样的服务器端语言又有什么价值呢?

两个问题:

  1. 如何让node充分利用多核cpu服务器?
  2. 如何保证node进程的健壮性和稳定性?

1.服务模型的变迁

经历了同步(qps为1/n)、复制进程(预先赋值一定数量的进程,prefork,但是,一旦用超了,还是跟同步的服务器一样,qps为m/n)、多线程(qps为M*L/N,这种模型,当并发上万后,内存耗用的问题将会暴露出来也就是C10k问题,apache就是采用了这样的多线程、多进程架构)和事件驱动等几个不同的模型。

2.多进程架构

面对单进程单线程对多核使用不足的问题,前人的经验是启动多个进程,理想状态下,每个进程各自利用一个cpu,以此实现多核cpu的利用。node提供了child_process模块,并提供了child_process.fork()函数来实现进程的复制。

//node 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 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查看到进程的数量。
这就是主从架构了,在这里存在两个进程,master是主进程、worker是工作进程。这是典型的分布式架构用于并行业务处理的模式,具有较好的可伸缩性和稳定性。主进程不负责具体业务处理,只负责调度和管理工作进程,因此主进程是相对于稳定和简单的,工作进程负责具体的业务处理,因为,业务多种多样,所以,工作进程的稳定性,是我们需要考虑的。

clipboard.png

通过fork复制的进程都是独立的,每个进程都有着独立而全新的v8实例,因此,需要至少30毫秒的启动时间和10mb左右的内存,但是,我们要记得fork进程是昂贵的,好在node在事件驱动的方式上,实现了单线程解决大并发的问题,这里启动多个进程只是为了充分将cpu资源利用起来,而不是为了解决并发的问题。

1).创建子进程

child_process模块给予了node随意创建子进程(child_process)的能力,它提供了4个方法用于创建子进程。

  1. spawn():启动一个子进程来执行命令
  2. exec():启动一个子进程来执行命令,与spawn()不同的是使用了不同的接口,它有一个回调函数获知子进程的状况。
  3. execFile():启动一个子进程来执行可执行文件
  4. fork():与spawn()类似,不同点在于,它创建node的子进程只需要指定要执行的js文件模块即可。

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

以上四个方法在创建子进程后,均会返回子进程对象,他们的差别如下:

clipboard.png

这里的可执行文件是指直接可以执行的,也就是*.exe或者.sh,如果是js文件,通过execFile()运行,那么这个文件的首行必须添加环境变量:#!/usr/bin/env node,尽管4种创建子进程的方式存在差别,但是事实上后面3种方法都是spawn()的延伸应用。

2)进程间通信
主线程与工作线程之间通过onmessage()和postMessage()进程通信,子进程对象则由send()方法实现主进程向子进程发送数据,message事件实现收听子进程发来的数据,与api在一定程度上相似。通过消息传递,而不是共享或直接操纵相关资源,这是较为轻量和无依赖的做法。

// parent.js
var cp = require('child_process');
var n = cp.fork(__dirname + '/sub.js');
n.on('message', function (m) {
    console.log('PARENT got message:', m);
});
n.send({ hello: 'world' });
// sub.js
process.on('message', function (m) {
    console.log('CHILD got message:', m);
});
process.send({ foo: 'bar' });

通过fork()或其他api创建子进程后,为了实现父子进程之间的通信,父进程与子进程之间将会创建IPC通道,通过IPC通道,父子进程之间才能通过message和send()传递消息。

进程间通信原理

PC的全称是Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源,并进程协调工作。实现进程间通信的技术有很多,如命名管道、匿名管道、socket、信号量、共享内存、消息队列、Domain Socket等,node中实现IPC通道的是管道技术(pipe)。

在node中管道是个抽象层面的称呼,具体细节实现由libuv提供,在win下是命名管道(named pipe)实现,在*nix下,采用unix Domain Socket来实现。

但是,具体在应用层面只是简单的message事件和send()方法,接口十分简洁和消息化。

clipboard.png

父进程在实际创建子进程前,会创建IPC通道并监听它,然后才真正创建出子进程,并通过环境变量(NODE_CHANNEL_FD)告诉子进程这个IPC通信的文件描述符。子进程在启动的过程中,根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。

clipboard.png

建立连接之后的父子进程就可以自由的通信了,由于IPC通道是用命名管道或者Domain Socket创建的,他们与网络socket的行为比较类似,属于双向通道。不同的是他们在系统内核中就完了进程间的通信,而不经过实际的网络层,非常高效。在node中,IPC通道被抽象为stream对象,在调用send()时发送数据(类似于write()),接收到的消息会通过message事件(类似于data)触发给应用层。

注意:只有启动的子进程是node进程是,子进程才会根据环境变量去连接IPC通道,对于其他类型的子进程则无法自动实现进程间通信,需要让其他进程也按照约定去连接这个已经创建好的IPC通道才行。

3)句柄传递

进程间发送句柄的功能,send()方法除了能够通过IPC发送数据外还能发送句柄,第二个可选参数就是句柄:

child.send(message, [sendHandle])

句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。因此,句柄可以用来标识一个服务端的socket对象、一个客户端的socket对象、一个udp套接字、一个管道等。
这个句柄就解决了一个问题,我们可以去掉代理方案,在主进程接收到socket请求后,将这个socket直接发送给工作进程,而不重新与工作进程之间建立新的socket连接转发数据。我们来看一下代码实现:

clipboard.png

主进程发送完句柄,并关闭监听之后,就变成了如下结构:

clipboard.png
这样,就可以实现多个子进程可以同时监听相同端口,再没有EADDRINUSE的异常发生。

1.句柄发送与还原
子进程对象send()方法可以发送的句柄类型包括如下几种:

  1. net.socket,tcp套接字
  2. net.Server,tcp服务器,任意建立在tcp服务上的应用层服务都可以享受到它带来的好处。
  3. net.Native,c++层面的tcp套接字或IPC管道。
  4. dgram.socket,UDP套接字
  5. 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
如果message.cmd值为NODE_HANDLE,它将取出message.type的值和得到的文件描述符一起还原出一个对应的对象。这个过程的示意图如下:

clipboard.png

2.端口共同监听

3.集群稳定之路

1)进程事件
2)自动重启
3)负载均衡
4)状态共享

4.Cluster模块

1)Cluster工作原理
2)Cluster事件

二、测试

1.单元测试

2.性能测试

三、产品化

1.项目工程化

2.部署流程

3.性能

4.日志

5.监控报警

6.稳定性

7.异构共存


suyue
77 声望7 粉丝