深入浅出nodeJS4

3次阅读

共计 4647 个字符,预计需要花费 12 分钟才能阅读完成。

内容

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 是工作进程。这是典型的分布式架构用于并行业务处理的模式,具有较好的可伸缩性和稳定性。主进程不负责具体业务处理,只负责调度和管理工作进程,因此主进程是相对于稳定和简单的,工作进程负责具体的业务处理,因为,业务多种多样,所以,工作进程的稳定性,是我们需要考虑的。

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

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

这里的可执行文件是指直接可以执行的,也就是 *.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()方法,接口十分简洁和消息化。

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

建立连接之后的父子进程就可以自由的通信了,由于 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 连接转发数据。我们来看一下代码实现:

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


这样,就可以实现多个子进程可以同时监听相同端口,再没有 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 的值和得到的文件描述符一起还原出一个对应的对象。这个过程的示意图如下:

2. 端口共同监听

3. 集群稳定之路

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

4.Cluster 模块

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

二、测试

1. 单元测试

2. 性能测试

三、产品化

1. 项目工程化

2. 部署流程

3. 性能

4. 日志

5. 监控报警

6. 稳定性

7. 异构共存

正文完
 0