Node.js异步IO模型

前言

最近在学习 HTTP 和 Node.js 相关的一些东西,越发感觉对以往没搞明白的一些问题自然而然的懂了(比如浏览器缓存方面)。因此决定先把前后端的开发的相关领域给打通,对每个环节都有一个较为全面的了解。这应该会对自己对于整个项目开发流程的理解会有一个较大的提升。

注:此篇文章主要是:
https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/
的体会加上了自己的一些体会。但由于这篇文章是纯英文的,后面的部分还是没有很好地理解。往后需要根据自己对这些东西的理解与后面重复看这篇文章的体会来进行补充。

一、事件循环

我以前也总结过关于JavaScript的事件循环机制:

http://srtian96.gitee.io/blog/2018/05/06/JavaScript%E5%BC%82%E6%AD%A5%E7%BC%96%E7%A8%8B%E5%9F%BA%E7%A1%80/

同样的在Node.js中Event Loop也是其执行机制的重要的一环,Event Loop的职责就在于不断的等待事件的发生,然后将这个事件的所有处理器一他们订阅这个事件的时间顺序依次执行,当这个事件的所有处理器都被执行完毕后,事件循环就会开始继续等待下一个事件的出发,不断往复,直到所有事件全部执行完毕。

虽然这样说起来 Event Loop并不复杂,但在Node.js中,Event Loop存在着属于自己的规则。我们首先来看下面的这段代码:

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
const { readFile } = require('fs')
const EventEmitter = require('events')

class Events extends EventEmitter {}

const call = new Events()


call.on('call', () => {
console.log('call 执行拉!')
})

setTimeout(() => {
console.log('0 毫秒后执行此回调')
}, 0)
setTimeout(() => {
console.log('200 毫秒后执行此回调')
}, 200)
setTimeout(() => {
console.log('100 毫秒后执行此回调')
}, 100)
console.log('这里是global')
readFile('DOM.html', 'utf-8', data => {
console.log('读取了DOM文件')
})

setImmediate(() => {
console.log('immediate执行了')
})

readFile('package.json', 'utf-8', data => {
console.log('读取了package文件')
})

process.nextTick(() => {
console.log('process.nextTick 执行了')
})

Promise.resolve()
.then(() => {
call.emit('call')

process.nextTick(() => {
console.log('这是Promise里的process.nextTick')
})
console.log('第一个Promise then')
})
.then(() => {
console.log('第二个Promise then')
})

如果对Node.js事件循环机制熟悉的大佬,应该仔细看看就知道它的打印结果了,而像我这样的咸鱼在没有好好了解Node.js事件循环机制的人来说,将打印结果事先确定就有点难度了,但没关系,都是需要学习的嘛!要搞清楚这个问题,首先我们要清楚的是,虽然Node.js采用V8引擎作为JavaScript的执行引擎,但同时也使用了libuv来实现自己的事件驱动式的异步I/O。因此要搞清楚Node.js的时间循环机制,最好的方式就是去看libuv的源码了。不过libuv的源码是C++的,所以在网上有搜了下,挺快的就搜到了关于这段代码的注释:

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
//deps/uv/src/unix/core.c
int uv_run(uv_loop_t *loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;
//uv__loop_alive返回的是event loop中是否还有待处理的handle或者request
//以及closing_handles是否为NULL,如果均没有,则返回0
r = uv__loop_alive(loop);
//更新当前event loop的时间戳,单位是ms
if (!r)
uv__update_time(loop);
while (r != 0 && loop->stop_flag == 0) {
//使用Linux下的高精度Timer hrtime更新loop->time,即event loop的时间戳
uv__update_time(loop);
//执行判断当前loop->time下有无到期的Timer,显然在同一个loop里面timer拥有最高的优先级
uv__run_timers(loop);
//判断当前的pending_queue是否有事件待处理,并且一次将&loop->pending_queue中的uv__io_t对应的cb全部拿出来执行
ran_pending = uv__run_pending(loop);
//实现在loop-watcher.c文件中,一次将&loop->idle_handles中的idle_cd全部执行完毕(如果存在的话)
uv__run_idle(loop);
//实现在loop-watcher.c文件中,一次将&loop->prepare_handles中的prepare_cb全部执行完毕(如果存在的话)
uv__run_prepare(loop);

timeout = 0;
//如果是UV_RUN_ONCE的模式,并且pending_queue队列为空,或者采用UV_RUN_DEFAULT(在一个loop中处理所有事件),则将timeout参数置为
//最近的一个定时器的超时时间,防止在uv_io_poll中阻塞住无法进入超时的timer中
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
//进入I/O处理的函数(重点分析的部分),此处挂载timeout是为了防止在uv_io_poll中陷入阻塞无法执行timers;并且对于mode为
//UV_RUN_NOWAIT类型的uv_run执行,timeout为0可以保证其立即跳出uv__io_poll,达到了非阻塞调用的效果
uv__io_poll(loop, timeout);
//实现在loop-watcher.c文件中,一次将&loop->check_handles中的check_cb全部执行完毕(如果存在的话)
uv__run_check(loop);
//执行结束时的资源释放,loop->closing_handles指针指向NULL
uv__run_closing_handles(loop);

if (mode == UV_RUN_ONCE) {
//如果是UV_RUN_ONCE模式,继续更新当前event loop的时间戳
uv__update_time(loop);
//执行timers,判断是否有已经到期的timer
uv__run_timers(loop);
}
r = uv__loop_alive(loop);
//在UV_RUN_ONCE和UV_RUN_NOWAIT模式中,跳出当前的循环
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}

//标记当前的stop_flag为0,表示当前的loop执行完毕
if (loop->stop_flag != 0)
loop->stop_flag = 0;
//返回r的值
return r;
}

然后我们就可以再根据这张广为流传的Node.js的流程图一起学习:

image

官方的还有个这种流程图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘

总结起来主要有以下几个阶段:

  • 定时器:这一阶段会执行由 setTimeout() 和 setInterval() 所设置的回调。
  • I/O 回调:执行除了关闭回调、定时器的回调和 setImmediat() 以外的几乎所有的回调。
  • ide,prepare:仅内部使用。
  • 轮询:检索新的I / O事件;执行与I/O相关的回调(几乎所有回调都是关闭回调,定时器和setImmediate()调度的回调);node 将在适当的时候阻塞在这里。
  • check: setImmediate() 的回调。
  • 关闭事件回调:比如 socket.on(‘close’, …) 的回调。

注:每一格称为事件循环的一个阶段。

值的注意的是,以上的每一阶段都有一个先进先出的待执行任务队列。而且在每一阶段,其内部都有自己的执行方法,当进入其中一个阶段时,每个阶段都会执行任何该阶段自己特定的操作,然后才执行在该阶段的队列中的回调,直到队列里的回调都执行完了或执行的次数达到最大限制。当队列耗尽或执行的次数达到最大限制时,事件循环进入下一个阶段,如此循环。

因此按照上面的思路,我们逐个分析可以得到,上面的那串代码的执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
这里是global
process.nextTick 执行了
call 执行拉!
第一个Promise then
第二个Promise then
这是Promise里的process.nextTick
0 毫秒后执行此回调
immediate执行了
读取了package文件
读取了DOM文件
100 毫秒后执行此回调
200 毫秒后执行此回调