深入浅出nodejs笔记——进程

进程、线程

进程

进程是系统进行资源分配和调度的基本单位,进程的目的就是担当分配系统资源(CPU时间、内存)的实体。

多进程就是进程的复制(fork),fork 出来的每个进程都拥有自己的独立空间地址、数据栈,一个进程无法访问另外一个进程里定义的变量、数据结构,只有建立了 IPC 通信,进程之间才可数据共享

线程

线程是操作系统能够进行运算调度的最小单位。一个线程只能隶属于一个进程,但是一个进程是可以拥有多个线程的。单线程就是一个进程只开一个线程

进程与线程的关系如下:

image

web属于典型的IO密集型应,而IO密集型应用的发展:
单进程 ->多进程->多进程多线程->事件驱动->协程

Node 中的进程与线程

Node 是 Javascript 在服务端的运行环境,构建在 chrome 的 V8 引擎之上,基于事件驱动、非阻塞I/O模型,充分利用操作系统提供的异步 I/O 进行多任务的执行,适合于 I/O 密集型的应用场景,因为异步,程序无需阻塞等待结果返回,而是基于回调通知的机制,原本同步模式等待的时间,则可以用来处理其它任务,

Node 是运行在单个进程的单个线程上。它带来的好处是:程序状态单一、在没有多线程的情况下没有锁、线程同步问题,操作系统在调度上因为较少切换上下文,这对于单核CPU而言,可以很好的提高CPU的使用率。

但是随着服务器的发展,出现了多核CPU,而一个Node进程只能利用一个核,这导致多核CPU无法被充分利用。

Node 运行在单线程上,一旦单线程上抛出的异常没有被捕获,将会引起整个进程的奔溃。进程的健壮性和稳定性难以得到保障。

为了解决单进场单线程带来的问题,node 提供了一些方法去创建多进程。

node创建子进程

nodejs可以通过child_process模块和cluster模块来创建子进程

child_process模块

以下demo是根据CPU有几个核来创建几个子进程。

master.js

1
2
3
4
5
var fork = require("child_process").fork;
var cpus = require("os").cpus();
for(var i = 0; i < cpus.length; i++){
fork("./worker.js")
}

worker.js

1
2
3
4
5
var http = require('http');
http.createServer(function(req,res){
res.writeHead(200,{'Content-Type': 'text/plain'});
res.end('Hello World');
}).listen(Math.round( 1 + Math.random() * 1000 ), '127.0.0.1' )

child_process模块提供可4个方法用于创建子进程:

  • child_process.spawn():启动一个子进程来执行命令
  • child_process.exec():启动一个子进程来执行命令,与spawn不同的是其接口不同,它有一个回调函数获知子进程的状况
  • child_process.execFile():启动一个子进程来执行可执行文件
  • child_process.fork():与spawn类型,不同点在于它创建node子进程只需要指定要执行的JavaScript文件模块即可

以下是四种方法的区别:

image

cluster模块

cluster 开启子进程Demo

1
2
3
4
5
6
7
8
9
// cluster.js 
var cluster = require('cluster');
cluster.setupMaster({
exec: "worker.js"
});
var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) {
cluster.fork();
}

Master-Worker 模式

Master-Worker模式是常用的并行设计模式。它的核心思想是,系统由两类进程协作工作:Master进程和Worker进程,Master负责接收和分配任务,Worker负责处理子任务,负责具体的业务处理。任务处理过程中,Master还负责监督任务进展和Worker的健康状态;Master将接收Client提交的任务,并将任务的进展汇总反馈给Client

image

要实现Master进程进行管理和调度,就需要实现Master和Worker相互间的通信。

进程间通信

Web Worker

在web端,主线程和UI渲染共同使用一个线程,两者互相阻塞,有些耗时的操作可以通过web Worker单独起一个线程来工作。

主线程通过postMessage和onmessage来跟子线程通信:

1
2
3
4
5
6
7
8
9
10
11
12
13
//master.js
var worker = new Worker('work.js');
worker.postMessage('Hello World');
worker.postMessage({method: 'echo', args: ['Work']});

worker.onmessage = function (event) {
console.log('Received message ' + event.data);
doSomething();
}
function doSomething() {
// 执行任务
worker.postMessage('Work done!');
}

Worker 完成任务以后,主线程就可以把它关掉。

1
worker.terminate();

子线程通过监听message事件和postMessage:

1
2
3
4
//worker.js
self.addEventListener('message', function (e) {
self.postMessage('You said: ' + e.data);
}, false);

node进程通信

进程间的通信与web workder类型,父子进程通过监听message和send方法来进行通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//parent.js
var cp = require("child_process");
var n = cp.fork("sub.js");

m.on("message",function(m){
console.log(" Parent got message:", m);
})

m.send("hello")

//sub.js
process.on("message",function(m){
console.log("Child got message:",m)
})
process.send("hi")

node进程间通信原理

进程间通信,英文简称 IPC, 全称是Inter-Process Communication。

进程的用户空间是互相独立的,一般而言是不能互相访问的,唯一的例外是共享内存区。进程间通信(IPC,Interprocess communication)是一组编程接口,让程序员能够协调不同的进程,使之能在一个操作系统里同时运行,并相互传递、交换信息。

实进程通信的技术有很多,命名管道、匿名管道、socket、信号量、共享内存、消息队列、Domain Socket等。

Node中实现IPC通道的是管道(pipe)技术。在Node中管道是个抽象层面的称呼,具体实现细节又libuv提供。

IPC创建和实现示意图

image

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

image

句柄传递

如何实现网络请求的分发处理

在讲句柄传递前,先来思考一个问题,在Node中如何实现网络请求的分发处理

在Node中实现网络请求的负载均衡,一种比较笨的方法是通过端口代理,通过主端口监听所有的网络请求,然后再将请求分发到不同的端口进行处理。

image

由于进程每接受到一个连接,将会用掉一个文件描述符,客户端端到连接到代理进程,代理进程连接到工作进程需要用到两个文件描述符,操作系统的文件描述符是有限的,这种方式会比较消耗文件描述符。为了解决这种问题,Node在版本V0.5.9引入了进程间传输句柄的功能。

什么是句柄?

句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。比如句柄可以用来标识一个服务端socket对象,一个客户端socket对象、一个UDP套接字、一个管道等。

Node如何发送句柄?

Node在版本V0.5.9引入了进程间传输句柄的功能。有了发送句柄的能力,这意味着我们可以将主进程接受到的socket请求直接发送给工作进程,而不是重新与工作进程之间建立新的socket连接来发送数据。

现在通过句柄来实现网络请求的分发处理。

主进程代码如下:

1
2
3
4
5
6
7
8
9
10
var child = require('child_process').fork('child.js'); 
var server = require('net').createServer();

server.on('connection', function (socket) {
socket.end('handled by parent\n');
});

server.listen(1337, function () {
child.send('server', server);
});

子进程代码如下:

1
2
3
4
5
6
7
process.on('message', function (m, server) { 
if (m === 'server') {
server.on('connection', function (socket) {
socket.end('handled by child\n');
});
}
});

改进上面代码,将请求分发到多个不同的子进程进行处理,主进程不处理,并且主进程发送完句柄后关闭监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// parent.js 
var cp = require('child_process');
var server = require('net').createServer();

var child1 = cp.fork('child.js');
var child2 = cp.fork('child.js');

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.writeHead(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);
});
}
});

通过上面的代码,我们发现多个子进程可以监听相同的端口,不会发生监听同个端口导致的EADDRINUSE错误,从而实现了把客户端的网络请求分发到不同的子进程中去处理,
它的模型结构如下:

image

句柄发送原理

Node通过子进程对象的send()方法来发送句柄,可以发送的句柄类型有如下几种:

  • net.Socket TCP套接字
  • net.Server TCP服务器,任意建立在TCP服务上的应用层服务都可以享受它带来的好处
  • net.Native C++层面的TCP套接字或IPC管道
  • dgram.Socket UDP套接字
  • dgram.Native C++层面的UDP套接字

send()方法在将消息发送到IPC管道前,实际将消息组装成了两个对象,一个参数是hadler,另一个是message。message参数如下所示:

1
2
3
4
5
{
cmd:'NODE_HANDLE',
type:'net.Server',
msg:message
}

发送到IPC管道中的实际上是我们要发送的句柄文件描述符。文件描述符实际上是一个整数值。这个message对象在写入到IPC管道时,也会通过JSON.stringfy()进行序列化。所以最终发送到IPC通道中的信息都是字符串,send()方法能发送消息和句柄并不意味着它能发送任何对象。

连接了IPC通道的子线程可以读取父进程发来的消息,将字符串通过JSON.parse()解析还原为对象后,才触发message事件将消息传递给应用层使用。在这个过程中,消息对象还要被进行过滤处理,message.cmd的值如果以NODE_为前缀,它将响应一个内部事件internalMessage,如果message.cmd值为NODE_HANDLE,它将取出message.type值和得到的文件描述符一起还原出一个对应的对象。

果message.cmd值为NODE_HANDLE,它将取出message.type值和得到的文件描述符一起还原出一个对应的对象。

image

以发送的TCP服务器句柄为例,子进程收到消息后的还原过程代码如下:

1
2
3
4
5
6
7
8
function(message,handle,emit){
var self = this;
var server = new net.Server();

server.listen(handler,function(){
emit(server);
});
}

上面的代码,子进程根据message.type创建对应的TCP服务器对象,然后监听到文件描述符上。由于底层细节不被应用层感知,所以子进程中,开发者会有一种服务器对象就是从父进程中直接传递过来的错觉。

参考: