Node15 Node中的事件循环

Node中的事件循环,与浏览器中的事件循环,还是有一些不同的。

事件循环

Node.js是单线程的语言,是通过事件循环处理非阻塞I/O操作的,Node会将这些操作转移到系统内核中,内核会在后台处理多种操作。当其中一个操作完成的时候,内核将通知Node将对应的回调函数加入轮询队列中。

Node的I/O处理使用了自己设计的基于事件驱动的跨平台抽象层libuv,它封装了不同操作系统的一些底层特性,对外提供统一的API,事件循环也是有libuv负责

Node中的每次事件循环都包含了6个阶段:

(1)timers阶段:这个阶段执行Timer(setTimeoutsetInterval)的回调函数

(2)I/O回调阶段:执行一些系统调用错误的回调(比如网络通信的错误回调函数)

(3)idle,prepare阶段:仅供Node内部使用

(4)poll(轮询)阶段:获取新的I/O事件,执行I/O相关的回调函数,适当的条件下将Node阻塞在这里

(5)check阶段:执行setImmediate()的回调函数

(6)close callbacks阶段:执行一些准备关闭的回调函数,比如执行Socket的close事件回调

重点关注timerspollcheck三个阶段,日常开发中的绝大部分异步任务都是在这三个阶段处理的。

timers阶段

在这个阶段,Node会检查有无超时的Timer,如果有则把其回调函数压入timer的任务队列中等待执行。

同浏览器环境一样,Node并不能保证Timer在预设时间到了就会立即执行,因为Node对timer的过期检查不一定靠谱,它会受到系统的影响,比如下面的代码setTimeoutsetImmediate的执行顺序是不确定的:

1
2
3
4
5
6
7
setTimeout(() => {
console.log('timeout')
}, 0)

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

但是如果在一个I/O回调中,那一定是setImmediate先执行,因为poll阶段后面就是check阶段。

poll阶段

这个阶段主要有两个功能:

  1. 处理poll队列的事件
  2. 如果有超时的timer,则执行timer的回调函数

在这个阶段,Event Loop会同步执行poll队列中的回调函数,直到队列为空,然后Event Loop会去检查check队列中有无预设的setImmediate()

  1. 有预设的setImmediate(),Event Loop将结束poll阶段进入check阶段,并执行check阶段的任务队列
  2. 如果没有预设的setImmediate(),Event Loop检查timer队列是否为空,如果timer非空,则Event Loop开始下一轮事件循环
  3. 如果timer队列也为空,那么Event Loop将阻塞在该阶段等待。

check阶段

setImmediate()的回调函数会被加入check队列中

process.nextTick()

从语义角度来看,setImmediate应该与process.nextTick()名字调换。process.nextTick()会在各个阶段之间进行,准确的说,是在当前阶段的尾部执行。一旦执行就要直到nextTick队列被清空,才会进入到下一个事件阶段。

nextTick会在异步任务之前执行

如果递归调用,会导致Event Loop卡死。

与浏览器事件循环的差异

浏览器环境下,微任务Microtask的任务队列是在每个宏任务Macrotask任务执行完成后执行:

在Node中,Microtask会在事件循环的各个阶段之间执行,也就是在一个阶段执行完毕,就回去执行Microtask队列的任务。

总结

Node.js的事件循环分为了六个阶段,其中常用的是timerspollcheck阶段

Event Loop在每个阶段都有一个任务队列,当执行到某个阶段时将执行该阶段的任务队列,知道队列清空才会进入下一个阶段。

当所有阶段被顺序执行一次后,事件循环就完成了一个Tick。

浏览器环境和Node环境下,Microtask任务队列的执行时机不同:浏览器的Microtask在事件循环的Macrotask执行完成后执行,Node中Microtask会在各个循环阶段之间执行。

练习1

下面的代码在浏览器和Node环境下执行的结果各是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)

setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)

浏览器环境下:

1
2
3
4
timer1 
promise1
timer2
promise2

Node环境下:

1
2
3
4
timer1
timer2
promise1
promise2

浏览器环境下比较好理解了,每次执行完一次宏任务,都要去检查并执行微任务队列。

在Node环境下,在timer阶段,先执行timer1后将promsie1放到微任务队列,由于Node中的微任务队列是在各个阶段之间执行的,所以此时不会执行微任务队列,而是继续执行第二个timer2,所以两个setTimeout先后执行,执行完成后在会执行为微任务。

练习2

1
2
3
4
5
6
7
8
9
10
process.nextTick(function A() {
console.log(1);
process.nextTick(function B() {
console.log(2);
});
});

setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
})

结果是:

1
2
3
1
2
TIMEOUT FIRED

这是因为process.nextTick会在每个阶段之间进行,也可以理解为在所有阶段之前进行,它会在所有异步任务之前进行,而且其队列清空之前会持续执行。

练习3

1
2
3
4
5
6
7
8
9
10
setImmediate(function A() {
console.log(1);
setImmediate(function B() {
console.log(2);
});
});

setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0);

上面代码中,1TIMEOUT FIRED哪个先执行是不确定的,运行结果可能是1--TIMEOUT FIRED--2,也可能是TIMEOUT FIRED--1--2

但是如果放在了一个I/O回调中,执行顺序就是确定的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const fs = require('fs');

fs.readFile('readme.md', err => {
if (err) {
console.log(err);
return;
}
setImmediate(function A() {
console.log(1);
setImmediate(function B() {
console.log(2);
});
});

setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0);
});

在一个I/O回调中,那一定是setImmediate先执行,因为poll阶段后面就是check阶段。

参考