一段代码引发的思考
1
2
3
4
5
6
7
8
9
10
11
12
13
console.log(1)
setTimeout(function() {
new Promise(function(resolve, reject) {
console.log(2)
resolve()
})
.then(() => {
console.log(3)
})
}, 0)
setTimeout(function() {
console.log(4)
}, 0)

浏览器执行结果

1
2
3
4
1
2
3
4

node环境下执行结果

1
2
3
4
1
2
4
3

浏览器的输出结果是因为js运行机制导致的,我们都知道js是单线程的,所以主线程会优先执行同步代码,所以会先输出1,当遇到异步代码,主线程并不会直接运行,因为会阻塞线程,它会往任务队列中添加事件,待执行栈都执行完,主线程会往任务队列中取事件,异步任务的回调函数得到执行.然而,任务队列中又分为宏任务(macrotask)和微任务(microtask),它们的执行顺序是先去取一个macrotask执行后,将microtask全部执行完,再取一个macrotask,这样子便形成了一个事件循环(event loop)。

  • macro: script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering
  • micro: process.nextTick, Promise, Object.observe(已废弃), MutationObserver(html5新特性)。

所以最终,代码会依次输出2,3,4


我个人的理解,分析node的事件循环不要跟浏览器扯在一块谈,他俩似乎关系并不大。

当你在命令行敲下 node xxx.js 的时候,node在进行事件循环会有一步初始化的操作,文档是这么说的。

“When Node.js starts, it initializes the event loop, processes the provided input script which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.”

这段话告诉我们,在开始事件循环前,node会先做以下几步操作。

  • 执行同步代码
  • 执行异步请求
  • 设置超时定时器
  • 执行process.nextTick()

补充: 异步代码包含了本轮循环和次轮循环,node执行完所有同步任务,接下来就会执行process.nextTick的任务队列。此外,node 规定,process.nextTick和Promise的回调函数,追加在本轮循环,即同步任务一旦执行完成,就开始执行它们。而setTimeout、setInterval、setImmediate的回调函数,追加在次轮循环。

这张图告诉我们几个重要的信息点:

  • event loop是在主线程上面执行的,当执行完初始化步骤后才开始。

Q: JS是单线程,当遇到IO操作或者http请求时不就会出现阻塞么?

A: 我们知道除了主线程外,还有一个重要角色,它叫做任务队列,它的职责就是当遇到异步操作时,这些操作会被推入event loop中,而node之所以可以做到高并发非阻塞的一个最核心原因是只有一个单线程(我们的主线程)不断轮询任务队列中是否有事件,事件执行完会有一个回调函数callback,并通知主线程按照FIFO原则执行回调函数。

Q: 事件执行是在主线程上吗?

A: 可以试想? 如果所有的异步操作都需要在主线程上执行必定会导致线程阻塞,所以event loop这个过程中,当往里面添加一个事件,node就会委托底层的线程池分配一个线程去执行这个事件,执行完成后返回event loop中并通知主线程来执行性回调函数。

  • node中的event loop经过六个阶段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
  1. timers: this phase executes callbacks scheduled by setTimeout() and setInterval().

  2. I/O callbacks: executes almost all callbacks with the exception of close callbacks, the ones scheduled by timers, and setImmediate().

  3. idle, prepare: only used internally.(这个阶段只供libuv内部调用)

  4. poll: retrieve new I/O events; node will block here when appropriate.

  5. check: setImmediate() callbacks are invoked here.

  6. close callbacks: e.g. socket.on(‘close’, …).

当event loop在各个阶段运行过程中,node会检查它是否在等待任何异步I/O或者schedule timer,如果没有,则关闭。

Q: setTimeout回调函数执行时间不确定,如果第一阶段还没到timer超时时间,它最终如何被调用?

A: 首先,timer的回调函数会尽早在它到达指定时间时得到执行,但因为操作系统调度或者主线程还有其它事件在执行所以可能会延迟执行这点没错。事实上,poll阶段会判断是否有超时的timer,如果有会跳出本次循环,再进入第一阶段执行timer回调

e.g.

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
const fs = require('fs');
function someAsyncOperation(callback) {
// 假设读取完这个文件需要95毫秒
fs.readFile('/path/to/file', callback);
}
const timeoutScheduled = Date.now();
setTimeout(function() {
const delay = Date.now() - timeoutScheduled;
console.log(delay + 'ms have passed since I was scheduled');
}, 100);
someAsyncOperation(function() {
const startCallback = Date.now();
// 10毫秒之后才执行,即跳出while循环
while (Date.now() - startCallback < 10) {
}
});

当event loop进入poll阶段,且现在整个队列没有其他异步任务,95毫秒之后文件读取完成,当到达timer的超时时间100毫秒时,此时poll队列还是空的,event loop检查到有一个最近的超时timer,接着它会跳出本次事件循环后去执行timer回调,所以最终someAsyncOperation执行完毕一共花费了105毫秒。

Q: poll阶段就是用来检查是否有超时的timer吗?

A: 前面第4点指出:poll阶段会获取新的I/O事件,并在恰当时阻塞。

A: poll阶段有两个主要功能:

  1. 执行超时timer
  2. 处理poll队列中的事件

当event loop进入poll阶段,并且设置了超时timer,会有两种情况:

  1. 当poll队列非空, event loop会迭代同步执行的队列的callback,直到队列耗尽或者达到依赖于系统的硬限制。

  2. 当poll队列是空的话,也有两种情况:

    • 如果脚本已经被setImmediate()预定了(笔者:这里可能有点拗口,它的意思其实就是说下一步操作setImmediate会被调用),event loop将会结束poll阶段的轮询,进入下一个check阶段去执行setImmediate()。

    • 如果脚本没有被setImmediate()预定,event loop则会阻塞,当到有新的callback加入队列会立刻执行。

一旦poll队列是空的,event loop会检查那些超时timer,如果发现有一个或者更多,event loop会跳出当前轮询,回到第一阶段timer,并执行timer回调。

Q: 前面第2点指出I/O callback阶段用来执行除关闭回调、timer回调和setImmediate回调之外的几乎所有回调,那么手动分发一个事件并触发,也是在poll阶段被回调吗?

A: 答案是否定的。先看下面这段代码

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
var fs = require('fs');
var events = require('events');
var emitter = new events.EventEmitter();
function someAsyncOperation (callback) {
// 花费2毫秒
fs.readFile(__dirname + '/nodeevent.js', callback);
}
var timeoutScheduled = Date.now();
var fileReadTime = 0;
setTimeout(function () {
var delay = Date.now() - timeoutScheduled;
console.log(delay + "ms have passed since I was scheduled");
}, 10);
someAsyncOperation(function () {
fileReadtime = Date.now();
while(Date.now() - fileReadtime < 20) {
}
setImmediate(function(){
console.log('setImmediate');
})
});
emitter.on('someEvent', function(arg1, arg2) {
console.log('listener');
});
emitter.emit('someEvent');

如果event事件是在poll阶段处理,那么最终的输出顺序应该是类似如下(ms时间取决于设备)

setImmediate

25ms have passed since I was scheduled

listener

然鹅,正确的应该是

listener

setImmediate

25ms have passed since I was scheduled

因为,readFile耗费了2ms, 当第一次event loop时并没有到期的timer,当readFile执行完毕,从poll->check,所以输出setImmediate,在20ms之前主线程一直被while循环占据,等到跳出while循环,到达timer,实际时间消耗了25ms。那么listener之所以会在第一条输出,其实events是在V8 (提供js运行环境,相当于java中jvm) 层实现的,event注册的事件被当做同步代码被处理。

可以看到event loop其实都是libuv层实现的,源码是用c/c++实现,这里就不再分析,先打住。

参考链接

event-loop-timers-and-nexttick

感谢您的阅读,本文由 lynhao 原创提供。如若转载,请注明出处:lynhao(http://www.lynhao.cn
Note For SVG