Fork me on GitHub

NodeJS 进程模型介绍

NodeJS的进程模型

关于进程模型首先需要理解的有大概这么几个问题:

  1. 什么是同步异步?
  2. 什么是异步IO?
  3. 什么是阻塞非阻塞?
  4. 什么是事件循环和事件驱动?
  5. 什么是单线程?
  6. 什么是进程
  7. 什么是子进程
  8. 怎么样来启动子进程
  9. 进程间如何来通信

什么是同步,异步?什么是阻塞,非阻塞

对于一个系统来说,一般它是被调用方,在调用方和被调用方之间如果调用方需要被调用方完成手头上的事情并且一直等待它结束,这种方式其实就是同步的,如果调用方不需要一直等待被调用方来响应,那么这种调用方式就是异步的,实现这种方式可以通过调用方的主动轮询也可以是被调用方的主动通知,也就是执行调用方之前已经注册的回调代码,而阻塞和非阻塞是指在调用过程中,调用方获取消息的状态,如果这个获取是需要等待的并且注册方什么都不能做,那么这个过程就是阻塞,反之就是非阻塞。往往同步的方式会导致阻塞,异步不会导致阻塞

同步和异步是调用的过程,而阻塞和非阻塞是调用时候的状态。

显然异步非阻塞的方式是十分节约时间的。这玩意儿就是 NodeJS 的一大卖点了。

我们可以通过下面的代码来演示一波同步异步阻塞和非阻塞的代码演示一波:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 通过doSync来模拟同步的过程
const doSync = (sth, time) => new Promise(resolve => {
setTimeout(() => {
console.log(sth + '用了' + time + '毫秒');
resolve();
}, time)
});
const doAsync = (sth, time, cb) => {
setTimeout(() => {
console.log(sth + '用了' + time + '毫秒');
// 使用cb来做一个判断,如果有回调的话,执行回调函数
cb && cb();
})
};
const doElse = (sth) => {
console.log(sth);
};
const Wd = {
doSync,
doAsync
};
const Xyx = {
doSync,
doAsync,
doElse
};
(async () => {
// 同步阻塞的过程
// 表示第一个用户已经来等待
console.log('case1: xyx等待wd的座位');
// 里面使用的人需要用1000ms
await Wd.doSync('wd 正在吃饭 ', 1000);
console.log('啥也没干,一直等待');
await Xyx.doSync('xyx吃饭', 2000);
Xyx.doElse('xyx 去忙别的去了');
// 能够主动通知调用方的系统的过程
console.log('case3: xyx来餐厅按下通知开关');
Wd.doAsync('wd 正在吃饭', 1000, () => {
console.log('餐厅通知xyx来恰饭');
Xyx.doAsync('xyx恰饭',2000);
})
Xyx.doElse('xyx 去忙别的去了');
})()

上面代码两种case都是不同的,一种情况下是wd在吃饭xyx要一直等wd吃完饭才能吃饭,而另外一种情况是wd正在吃饭,xyx按下了通知的开关,这个时候她去干其他的事情了,然后wd吃完饭了,这个时候餐厅的通知开关通知xyx去吃饭了,然后xyx就去吃饭了。

IO模型

实际上 IO 指的就是数据的输入和输出的能力。在人机交互的时候,我们会把键盘和鼠标这些东西看做是 INPUT ,也就是输入,对应到主机上面会有专门的接受这些输入的接口,显示器对应的外设会有专门的用于显示输出的接口。这个接口在向下会对应到操作系统的层面,在操作系统的层面,会对应到很多的能力,例如磁盘的查写,数据的读取,DNS解析等等。在不同的操作系统的层面,他们所表现出来的也是不同的,有的是同步的阻塞的,有的是异步非阻塞的。

在nodejs里面,提到异步IO,我们可以拿读写文件为例。koa是一个上层的web应用服务框架,全部是用JavaScript来实现的,他所有与操作系统沟通的能力都建立在nodejs的整个的通信服务模型的基础之上,nodejs提供了fs这个模块,里面提供了一些接口,例如readFile就是一个异步的接口,而readFileAsync相当于是个同步的接口。

我们可以通过下面这些代码来做一个示例的查看:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const {
readFile
} = require('fs');
const EventEmitter = require('events');
// 这个的优先级
class EE extends EventEmitter {}
const yy = new EE();
yy.on('event', () => {
console.log('出大事了');
})
setTimeout(() => {
console.log('0 毫秒后执行定时器回调');
}, 0)
setTimeout(() => {
console.log('100 毫秒后执行定时器回调');
}, 100)
setTimeout(() => {
console.log('200 毫秒后执行定时器回调');
}, 200)
readFile('./../../package.json', 'utf-8', data => {
console.log('完成文件 1 读操作的回调');
})
readFile('./../../ReadMe.md','utf-8', data => {
console.log('完成文件 2 读操作的回调');
})
setImmediate(() => {
console.log('immediate 立即回调');
})
process.nextTick(() => {
console.log('process.nextTick 回调');
})
Promise.resolve().then(() => {
yy.emit('event');
// process在事件机制中的优先级
process.nextTick(() => {
console.log('process.nextTick 的第二次回调');
})
console.log('Promise的第 1 次回调');
}).then(() => {
console.log('Promise的第 2 次回调');
})

我们可以先自己猜一下答案是什么 = =?

不妨可以先看看这篇文章

解释一波吧:
nodejs在运行的时候使用的是谷歌v8作为的解释引擎。在处理异步IO上面使用的是自家设计的libuv,libuv封装了不同操作系统的 IO 操作,向上向nodejs的服务层提供了一部分的异步非阻塞接口和事件循环。

当我们讨论nodejs的事件循环的时候,一般都会和libuv息息相关。所以我们先去github上面去看一下libuv的源码。

直接去访问这个地址:https://github.com/libuv/libuv/blob/v1.x/src/unix/core.c

去里面查看一个uv_run的函数,同时也可以去NodeJS的官网上面看看关于事件循环相关的文章

上面的执行过程是这样的:
首先在第一个阶段有process.nextTick()的回调,所以就先执行这个回调。执行完成之后,这个时候会直接到Promise的回调函数这里,它的优先级是仅次于process.nextTick()的所以会去第二个执行回调,在它的第一个.then里面有一个yy.emit,那么这个时候就会触发一个事件回调,那么他就会立即去执行yy.on里面回调函数里面的代码语句。执行完毕之后,继续执行promise里面的函数,它会先打印process.nextTick的第一次回调,然后在继续执行第二个.then里面的内容,.then里面的内容执行完成之后,这个时候会回到第一个.then里面的回调那里,执行那里的那个地方的回调(因为这个地方是nextTick,它的优先级是第二次事件循环里面最高的,所以会最优先执行)。等到这个执行完成之后,这个时候的事件循环队列里面就没有像process.nextTickprocess 里面这样的micro tasks了,这个时候事件循环就到了定时器的执行阶段,它会首先去执行那个0毫秒的定时器。执行完毕之后,发现100毫秒和200毫秒的定时器还没到期,这个时候就会执行到文件 1 和 2 读操作的回调里面去,然后分别执行里面的两个语句。等到这个队列被执行清空后,它会立即去执行setImmediate里面的回调,这个执行完成之后,队列清空,然后两个定时器已经被清空了,这个时候就回去执行那两个。

我们可以通过自己的一些测试用例来检测一下关于事件循环的猜想。

首先我们可以先设定有三个阶段:第一阶段是定时器阶段,第二阶段是文件读写 IO 操作的一个回调阶段,第三个阶段就是setImmediate阶段(checked阶段)。

在test目录下面新建一个 eventloop.js 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在checked(第三个阶段)会执行的函数
setImmediate(() => {
console.log('[阶段3.immediate] immediate 回调1');
})
setImmediate(() => {
console.log('[阶段3.immediate] immediate 回调2');
})
setImmediate(() => {
console.log('[阶段3.immediate] immediate 回调3');
})
// 这个是第一阶段里面的函数
setTimeout(()=>{
console.log('[阶段1....定时器] 定时器 回调1');
},0)

这个初次执行的话输出的顺序是这样的:

1
2
3
4
[阶段1....定时器] 定时器 回调1
[阶段3.immediate] immediate 回调1
[阶段3.immediate] immediate 回调2
[阶段3.immediate] immediate 回调3

但是如果反复执行的话,它的顺序是有可能发生变化的。

原因是在事件循环启动的时候,有可能定时器的回调函数还没有被检测到,那么这个时候就会进入到第二个阶段,在第二个阶段发现没有IO的回调函数,这个时候就会直接进入到第三个阶段(checked),就会执行setImmediate。这个执行完成之后,就回去检查有没有到期的定时器,这个时候就会发现setTimeout到期了,然后就回去执行setTimeuot里面的回调函数。

我们可以继续将上面的代码写的更复杂一点:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 在checked(第三个阶段)会执行的函数
setImmediate(() => {
console.log('[阶段3.immediate] immediate 回调1');
})
setImmediate(() => {
console.log('[阶段3.immediate] immediate 回调2');
})
setImmediate(() => {
console.log('[阶段3.immediate] immediate 回调3');
})
// 这个是第一阶段里面的函数
setTimeout(()=>{
console.log('[阶段1....定时器] 定时器 回调1');
},0)
setTimeout(()=>{
console.log('[阶段1....定时器] 定时器 回调5');
// 这个回调函数并不处于任何一个阶段
process.nextTick(()=>{
console.log('[...待切入下一阶段] nextTick 回调2');
});
},0)
setTimeout(()=>{
console.log('[阶段1....定时器] 定时器 回调3');
},0)
setTimeout(()=>{
console.log('[阶段1....定时器] 定时器 回调4');
},0)
process.nextTick(()=>{
console.log('[...待切入下一阶段] nextTick 回调1');
});
process.nextTick(()=>{
console.log('[...待切入下一阶段] nextTick 回调2');
process.nextTick(()=>{
console.log('[...待切入下一阶段] nextTick 回调4');
})
})
process.nextTick(()=>{
console.log('[...待切入下一阶段] nextTick 回调3');
});

上面代码中,第一次事件循环的时候,先执行的是nextTick函数,(虽然回调4在回调2里面,但是它会被放到第一个队列的队列尾)。

等到nextTickpromise执行完成之后,它会再去执行第一阶段定时器里面的函数,同样的,在setTimeout回调函数里面的nextTick这个时候会被放到回调队列的尾部,最后才执行。第一阶段的执行完成之后,这个时候再去执行第三节阶段的 setImmediate 函数。依次去执行。所以输出的结果是:

1
2
3
4
5
6
7
8
9
10
11
12
[...待切入下一阶段] nextTick 回调1
[...待切入下一阶段] nextTick 回调2
[...待切入下一阶段] nextTick 回调3
[...待切入下一阶段] nextTick 回调4
[阶段1....定时器] 定时器 回调1
[阶段1....定时器] 定时器 回调5
[阶段1....定时器] 定时器 回调3
[阶段1....定时器] 定时器 回调4
[...待切入下一阶段] nextTick 回调2
[阶段3.immediate] immediate 回调1
[阶段3.immediate] immediate 回调2
[阶段3.immediate] immediate 回调3

一些自我验证性的代码:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
const {
readFile,
readFileSync
} = require('fs');
// 在checked(第三个阶段)会执行的函数
setImmediate(() => {
console.log('[阶段3.immediate] immediate 回调1');
})
setImmediate(() => {
console.log('[阶段3.immediate] immediate 回调2');
})
setImmediate(() => {
console.log('[阶段3.immediate] immediate 回调3');
})
Promise.resolve().then(() => {
console.log('[...待切入下一个阶段] promise 回调1');
setImmediate(() => { // 这个回调会在阶段三来执行
console.log('[...阶段3.immediate] promise 回调1 增加的immediate 回调4');
})
})
readFile('../package.json', 'utf-8', data => {
console.log('[阶段2...IO 回调] 读文件回调1');
readFile('../video.mp4', 'utf-8', data => { // 读一个体积比较大的文件
console.log('[阶段2...IO 回调] 读文件回调2');
setImmediate(() => { // 这个回调会在阶段三来执行
console.log('[...阶段3.immediate] 读文件 回调2 增加的immediate 回调4');
})
})
setImmediate(() => {
console.log('[....阶段3.immediate] immediate 回调4');
Promise.resolve().then(() => {
console.log('[...待切入下一个阶段] promise 回调2');
process.nextTick(()=>{
console.log('[...待切入下一阶段] promise 回调2增加的 nextTick 回调5');
})
}).then(()=>{
console.log('[...待切入写一个阶段] promise 回调3');
})
})
setImmediate(()=>{
console.log('[....阶段3.immediate] immediate 回调5');
process.nextTick(()=>{
console.log('[...待切入下一个阶段] immediate 回调5 增加的 nextTick 回调6');
})
console.log('[...待切入到下一个阶段] 这里正在同步阻塞读写一个大文件');
const video = readFileSync('../video.mp4','utf-8');
process.nextTick(()=>{
console.log('[...待切入下一个阶段] immediate 回调5 增加的 nextTick 回调7');
})
readFile('../package.json','utf-8',data=>{
console.log('[阶段2...IO 回调] 读文件回调2');
})
setImmediate(() => { // 这个回调会在阶段三来执行
console.log('[...阶段3.immediate] 读文件 回调2 增加的immediate 回调6');
})
setTimeout(()=>{
console.log('[阶段1....定时器] 定时器回调8');
},0)
})
process.nextTick(()=>{
console.log('[...待切入下一阶段] 读文件回调 1 增加的 nextTick 回调6');
});
setTimeout(() => {
console.log('[阶段1....定时器] 定时器 回调6');
}, 0)
setTimeout(() => {
console.log('[阶段1....定时器] 定时器 回调7');
}, 0)
})
// 这个是第一阶段里面的函数
setTimeout(() => {
console.log('[阶段1....定时器] 定时器 回调1');
}, 0)
setTimeout(() => {
console.log('[阶段1....定时器] 定时器 回调5');
// 这个回调函数并不处于任何一个阶段
process.nextTick(() => {
console.log('[...待切入下一阶段] nextTick 回调2');
});
}, 0)
setTimeout(() => {
console.log('[阶段1....定时器] 定时器 回调3');
}, 0)
setTimeout(() => {
console.log('[阶段1....定时器] 定时器 回调4');
}, 0)
process.nextTick(() => {
console.log('[...待切入下一阶段] nextTick 回调1');
});
process.nextTick(() => {
console.log('[...待切入下一阶段] nextTick 回调2');
process.nextTick(() => {
console.log('[...待切入下一阶段] nextTick 回调4');
})
})
process.nextTick(() => {
console.log('[...待切入下一阶段] nextTick 回调3');
});

它的执行过程大概是这样的:
先执行第一阶段的回调函数(定时器setTimeout)=>然后执行第二阶段的回调函数(读写IO里面的(readFile))=>最后执行setImmediate函数里面的回调函数,但是在执行这一阶段的函数之前,首先要先把process.nextTick()里面的回调函数执行干净,然后再去执行Promise里面的回调函数。(process.nextTick的优先级是高于promise),但是process.nextTick的优先级是低于同步代码的。

-------------本文结束感谢您的阅读-------------