导读
ALL THE TIME,我们写的的大部分javascript
代码都是在浏览器环境下编译运行的,因此可能我们对浏览器的事件循环机制了解比Node.JS
的事件循环更深入一些,但是最近写开始深入NodeJS学习的时候,发现NodeJS的事件循环机制和浏览器端有很大的区别,特此记录来深入的学习了下,以帮助自己及小伙伴们忘记后查阅及理解。
什么是事件循环
首先我们需要了解一下最基础的一些东西,比如这个事件循环,事件循环是指Node.js执行非阻塞I/O操作,尽管==JavaScript是单线程的==,但由于大多数==内核都是多线程==的,Node.js
会尽可能将操作装载到系统内核。因此它们可以处理在后台执行的多个操作。当其中一个操作完成时,内核会告诉Node.js
,以便Node.js
可以将相应的回调添加到轮询队列中以最终执行。
当Node.js启动时会初始化event loop
, 每一个event loop
都会包含按如下顺序六个循环阶段:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
- [x] 1.
timers
阶段: 这个阶段执行setTimeout(callback)
和setInterval(callback)
预定的 callback; - [x] 2.
I/O callbacks
阶段: 此阶段执行某些系统操作的回调,例如TCP错误的类型。 例如,如果TCP套接字在尝试连接时收到 ECONNREFUSED,则某些* nix系统希望等待报告错误。 这将操作将等待在==I/O回调阶段==执行; - [x] 3.
idle, prepare
阶段: 仅node内部使用; - [x] 4.
poll
阶段: 获取新的I/O事件, 例如操作读取文件等等,适当的条件下node将阻塞在这里; - [x] 5.
check
阶段: 执行setImmediate()
设定的callbacks; - [x] 6.
close callbacks
阶段: 比如socket.on(‘close’, callback)
的callback会在这个阶段执行;
事件循环详解
这个图是整个 Node.js 的运行原理,从左到右,从上到下,Node.js 被分为了四层,分别是 应用层
、V8引擎层
、Node API层
和 LIBUV层
。
- 应用层: 即 JavaScript 交互层,常见的就是 Node.js 的模块,比如 http,fs
- V8引擎层: 即利用 V8 引擎来解析JavaScript 语法,进而和下层 API 交互
- NodeAPI层: 为上层模块提供系统调用,一般是由 C 语言来实现,和操作系统进行交互 。
- LIBUV层: 是跨平台的底层封装,实现了 事件循环、文件操作等,是 Node.js 实现异步的核心 。
每个循环阶段内容详解
timers
阶段 一个timer指定一个下限时间而不是准确时间,在达到这个下限时间后执行回调。在指定时间过后,timers会尽可能早地执行回调,但系统调度或者其它回调的执行可能会延迟它们。
- 注意:技术上来说,poll 阶段控制 timers 什么时候执行。
- 注意:这个下限时间有个范围:[1, 2147483647],如果设定的时间不在这个范围,将被设置为1。
I/O callbacks
阶段 这个阶段执行一些系统操作的回调。比如TCP错误,如一个TCP socket在想要连接时收到ECONNREFUSED,
类unix系统会等待以报告错误,这就会放到 I/O callbacks 阶段的队列执行.
名字会让人误解为执行I/O回调处理程序, 实际上I/O回调会由poll阶段处理.
poll
阶段 poll 阶段有两个主要功能:(1)执行下限时间已经达到的timers的回调,(2)然后处理 poll 队列里的事件。
当event loop进入 poll 阶段,并且 没有设定的 timers(there are no timers scheduled),会发生下面两件事之一:
- 如果 poll 队列不空,event loop会遍历队列并同步执行回调,直到队列清空或执行的回调数到达系统上限;
如果 poll 队列为空,则发生以下两件事之一:
- 如果代码已经被setImmediate()设定了回调, event loop将结束 poll 阶段进入 check 阶段来执行 check 队列(里面的回调 callback)。
- 如果代码没有被setImmediate()设定回调,event loop将阻塞在该阶段等待回调被加入 poll 队列,并立即执行。
- 但是,当event loop进入 poll 阶段,并且 有设定的timers,一旦 poll 队列为空(poll 阶段空闲状态):
event loop将检查timers,如果有1个或多个timers的下限时间已经到达,event loop将绕回 timers 阶段,并执行 timer 队列。
check
阶段 这个阶段允许在 poll 阶段结束后立即执行回调。如果 poll 阶段空闲,并且有被setImmediate()设定的回调,event loop会转到 check 阶段而不是继续等待。
- setImmediate() 实际上是一个特殊的timer,跑在event loop中一个独立的阶段。它使用
libuv
的API
来设定在 poll 阶段结束后立即执行回调。 - 通常上来讲,随着代码执行,event loop终将进入 poll 阶段,在这个阶段等待 incoming connection, request 等等。但是,只要有被setImmediate()设定了回调,一旦 poll 阶段空闲,那么程序将结束 poll 阶段并进入 check 阶段,而不是继续等待 poll 事件们 (poll events)。
close callbacks
阶段 如果一个 socket 或 handle 被突然关掉(比如 socket.destroy()),close事件将在这个阶段被触发,否则将通过process.nextTick()触发
这里呢,我们通过伪代码来说明一下,这个流程:
// 事件循环本身相当于一个死循环,当代码开始执行的时候,事件循环就已经启动了
// 然后顺序调用不同阶段的方法
while(true){
// timer阶段
timer()
// I/O callbacks阶段
IO()
// idle阶段
IDLE()
// poll阶段
poll()
// check阶段
check()
// close阶段
close()
}
// 在一次循环中,当事件循环进入到某一阶段,加入进入到check阶段,突然timer阶段的事件就绪,也会等到当前这次循环结束,再去执行对应的timer阶段的回调函数
// 下面看这里例子
const fs = require('fs')
// timers阶段
const startTime = Date.now();
setTimeout(() => {
const endTime = Date.now()
console.log(`timers: ${endTime - startTime}`)
}, 1000)
// poll阶段(等待新的事件出现)
const readFileStart = Date.now();
fs.readFile('./Demo.txt', (err, data) => {
if (err) throw err
let endTime = Date.now()
// 获取文件读取的时间
console.log(`read time: ${endTime - readFileStart}`)
// 通过while循环将fs回调强制阻塞5000s
while(endTime - readFileStart < 5000){
endTime = Date.now()
}
})
// check阶段
setImmediate(() => {
console.log('check阶段')
})
/*控制台打印check阶段read time: 9timers: 5008通过上述结果进行分析,1.代码执行到定时器setTimeOut,目前timers阶段对应的事件列表为空,在1000s后才会放入事件2.事件循环进入到poll阶段,开始不断的轮询监听事件3.fs模块异步执行,根据文件大小,可能执行时间长短不同,这里我使用的小文件,事件大概在9s左右4.setImmediate执行,poll阶段暂时未监测到事件,发现有setImmediate函数,跳转到check阶段执行check阶段事件(打印check阶段),第一次时间循环结束,开始下一轮事件循环5.因为时间仍未到定时器截止时间,所以事件循环有一次进入到poll阶段,进行轮询6.读取文件完毕,fs产生了一个事件进入到poll阶段的事件队列,此时事件队列准备执行callback,所以会打印(read time: 9),人工阻塞了5s,虽然此时timer定时器事件已经被添加,但是因为这一阶段的事件循环为完成,所以不会被执行,(如果这里是死循环,那么定时器代码永远无法执行)7.fs回调阻塞5s后,当前事件循环结束,进入到下一轮事件循环,发现timer事件队列有事件,所以开始执行 打印timers: 5008ps:1.将定时器延迟时间改为5ms的时候,小于文件读取时间,那么就会先监听到timers阶段有事件进入,从而进入到timers阶段执行,执行完毕继续进行事件循环check阶段timers: 6read time: 50082.将定时器事件设置为0ms,会在进入到poll阶段的时候发现timers阶段已经有callback,那么会直接执行,然后执行完毕在下一阶段循环,执行check阶段,poll队列的回调函数timers: 2check阶段read time: 7 */
走进案例解析
我们来看一个简单的EventLoop
的例子:
const fs = require('fs');
let counts = 0;
// 定义一个 wait 方法
function wait (mstime) {
let date = Date.now();
while (Date.now() - date < mstime) {
// do nothing
}
}
// 读取本地文件 操作IO
function asyncOperation (callback) {
fs.readFile(__dirname + '/' + __filename, callback);
}
const lastTime = Date.now();
// setTimeout
setTimeout(() => {
console.log('timers', Date.now() - lastTime + 'ms');
}, 0);
// process.nextTick
process.nextTick(() => {
// 进入event loop
// timers阶段之前执行
wait(20);
asyncOperation(() => {
console.log('poll');
});
});
/** * timers 21ms * poll */
这里呢,为了让这个setTimeout
优先于fs.readFile
回调, 执行了process.nextTick
, 表示在进入timers
阶段前, 等待20ms
后执行文件读取.
1. nextTick
与 setImmediate
process.nextTick
不属于事件循环的任何一个阶段,它属于该阶段与下阶段之间的过渡, 即本阶段执行结束, 进入下一个阶段前, 所要执行的回调。有给人一种插队的感觉.setImmediate
的回调处于check阶段, 当poll阶段的队列为空, 且check阶段的事件队列存在的时候,切换到check阶段执行,参考nodejs进阶视频讲解:进入学习
nextTick 递归的危害
由于nextTick具有插队的机制,nextTick的递归会让事件循环机制无法进入下一个阶段. 导致I/O处理完成或者定时任务超时后仍然无法执行, 导致了其它事件处理程序处于饥饿状态. 为了防止递归产生的问题, Node.js 提供了一个 process.maxTickDepth (默认 1000)。
const fs = require('fs');
let counts = 0;
function wait (mstime) {
let date = Date.now();
while (Date.now() - date < mstime) {
// do nothing
}
}
function nextTick () {
process.nextTick(() => {
wait(20);
console.log('nextTick');
nextTick();
});
}
const lastTime = Date.now();
setTimeout(() => {
console.log('timers', Date.now() - lastTime + 'ms');
}, 0);
nextTick();
此时永远无法跳到timer
阶段去执行setTimeout里面的回调方法
, 因为在进入timers
阶段前有不断的nextTick
插入执行. 除非执行了1000次到了执行上限,所以上面这个案例会不断地打印出nextTick
字符串
2. setImmediate
如果在一个I/O周期
内进行调度,setImmediate() 将始终在任何定时器(setTimeout、setInterval)之前执行.
3. setTimeout
与 setImmediate
- setImmediate()被设计在 poll 阶段结束后立即执行回调;
- setTimeout()被设计在指定下限时间到达后执行回调;
无 I/O 处理情况下:
setTimeout(function timeout () {
console.log('timeout');
},0);
setImmediate(function immediate () {
console.log('immediate');
});
执行结果:
C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate
C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate
C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate
C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout
从结果,我们可以发现,这里打印输出出来的结果,并没有什么固定的先后顺序,偏向于随机,为什么会发生这样的情况呢?
答:首先进入的是timers
阶段,如果我们的机器性能一般,那么进入timers
阶段,1ms
已经过去了 ==(setTimeout(fn, 0)等价于setTimeout(fn, 1))==,那么setTimeout
的回调会首先执行。
如果没有到1ms
,那么在timers
阶段的时候,下限时间没到,setTimeout
回调不执行,事件循环来到了poll
阶段,这个时候队列为空,于是往下继续,先执行了setImmediate()的回调函数,之后在下一个事件循环再执行setTimemout
的回调函数。
问题总结:而我们在==执行启动代码==的时候,进入timers
的时间延迟其实是==随机的==,并不是确定的,所以会出现两个函数执行顺序随机的情况。
那我们再来看一段代码:
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
打印结果如下:
C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout
C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout
C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout
# ... 省略 n 多次使用 node test.js 命令 ,结果都输出 immediate timeout
这里,为啥和上面的随机timer
不一致呢,我们来分析下原因:
原因如下:fs.readFile
的回调是在poll
阶段执行的,当其回调执行完毕之后,poll
队列为空,而setTimeout
入了timers
的队列,此时有代码 setImmediate()
,于是事件循环先进入check
阶段执行回调,之后在下一个事件循环再在timers
阶段中执行回调。
当然,下面的小案例同理:
setTimeout(() => {
setImmediate(() => {
console.log('setImmediate');
});
setTimeout(() => {
console.log('setTimeout');
}, 0);
}, 0);
以上的代码在timers
阶段执行外部的setTimeout
回调后,内层的setTimeout
和setImmediate
入队,之后事件循环继续往后面的阶段走,走到poll阶段
的时候发现队列为空
,此时有代码有setImmedate()
,所以直接进入check阶段
执行响应回调(==注意这里没有去检测timers队列中是否有成员
到达下限事件,因为setImmediate()优先
==)。之后在第二个事件循环的timers
阶段中再去执行相应的回调。
综上所演示,我们可以总结如下:
- 如果两者都在主模块中调用,那么执行先后取决于进程性能,也就是你的电脑好撇,当然也就是随机。
- 如果两者都不在主模块调用(被一个异步操作包裹),那么
setImmediate的回调永远先执行
。
4. nextTick
与 Promise
概念:对于这两个,我们可以把它们理解成一个微任务。也就是说,它其实不属于事件循环的一部分。
那么他们是在什么时候执行呢?
不管在什么地方调用,他们都会在其所处的事件循环最后,事件循环进入下一个循环的阶段前执行。
setTimeout(() => {
console.log('timeout0');
new Promise((resolve, reject) => { resolve('resolved') }).then(res => console.log(res));
new Promise((resolve, reject) => {
setTimeout(()=>{
resolve('timeout resolved')
})
}).then(res => console.log(res));
process.nextTick(() => {
console.log('nextTick1');
process.nextTick(() => {
console.log('nextTick2');
});
});
process.nextTick(() => {
console.log('nextTick3');
});
console.log('sync');
setTimeout(() => {
console.log('timeout2');
}, 0);
}, 0);
控制台打印如下:
C:\Users\92809\Desktop\node_test>node test.js
timeout0
sync
nextTick1
nextTick3
nextTick2
resolved
timeout2
timeout resolved
最总结:timers
阶段执行外层setTimeout
的回调,遇到同步代码先执行,也就有timeout0
、sync
的输出。遇到process.nextTick
及Promise
后入微任务队列,依次nextTick1
、nextTick3
、nextTick2
、resolved
入队后出队输出。之后,在下一个事件循环的timers
阶段,执行setTimeout
回调输出timeout2
以及微任务Promise
里面的setTimeout
,输出timeout resolved
。(这里要说明的是 微任务nextTick
优先级要比Promise
要高)
5. 最后案例
代码片段1:
setImmediate(function(){
console.log("setImmediate");
setImmediate(function(){
console.log("嵌套setImmediate");
});
process.nextTick(function(){
console.log("nextTick");
})
});
/* C:\Users\92809\Desktop\node_test>node test.js setImmediate nextTick 嵌套setImmediate*/
解析:
事件循环check
阶段执行回调函数输出setImmediate
,之后输出nextTick
。嵌套的setImmediate
在下一个事件循环的check
阶段执行回调输出嵌套的setImmediate
。
代码片段2:
async function async1(){
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2(){
console.log('async2')
}
console.log('script start')
setTimeout(function(){
console.log('setTimeout0')
},0)
setTimeout(function(){
console.log('setTimeout3')
},3)
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
async1();
new Promise(function(resolve){
console.log('promise1')
resolve();
console.log('promise2')
}).then(function(){
console.log('promise3')
})
console.log('script end')
打印结果为:
C:\Users\92809\Desktop\node_test>node test.js
script start
async1 start
async2
promise1
promise2
script end
nextTick
promise3
async1 end
setTimeout0
setTimeout3
setImmediate
大家呢,可以先看着代码,默默地在心底走一变代码,然后对比输出的结果,当然最后三位,我个人认为是有点问题的,毕竟在主模块运行,大家的答案,最后三位可能会有偏差;
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。