官网:node官网-事件循环
浏览器中的事件循环是由HTML规范来定义,之后由各浏览器厂商实现的,而node中的事件循环的定义与实现均由libuv引擎完成。
node使用chrome v8引擎作为js解释器,v8引擎分析代码后,主线程立即执行同步任务,而异步任务则由libuv引擎驱动执行,而且不同异步任务的回调事件会放在不同的队列中等待主线程执行,不再是简单的宏任务队列和微任务队列。因此在nodeJS中,虽然程序运行表现出的整体状态与浏览器中传统的js大致相同,先同步后异步,但是对于异步的部分,node则依靠libuv引擎来进行更复杂的管理。
宏任务队列和微任务队列
六个基本阶段(六个宏任务队列)
- timers:计时器阶段,处理setTimeout()和setInterval()定时器的回调函数
- pending callbacks :待定回调阶段,用于处理系统级别的错误信息,例如 TCP 错误或者 DNS 解析异常
- idle,prepare:仅在内部使用,可以忽略不计
- poll:轮询阶段,等待I/O事件(如网络请求或者文件I/O等)的发生,然后执行对应的回调函数,并且会处理定时器相关的回调函数。如果没有任何I/O事件发生,此阶段可能会使事件循环阻塞
- check:检查阶段,处理 setImmediate() 的回调函数。check 的回调优先级比 setTimeout 高,比微任务要低
- close callbacks:关闭回调阶段,处理一些关闭的回调函数,比如 socket.on(‘close’)
nextTick队列(微任务队列)
该事件队列独立于6个阶段的事件队列之外,用于存储 process.nextTick() 的回调函数。
microTask队列(微任务队列)
该事件队列也独立于6个阶段的事件队列之外,用于存储 Promise(Promise.then()、Promise.catch()、Promise.finally())的回调函数。
NodeJS事件循环流程
以上六个基本阶段和两个独立的事件队列构成了node事件循环的核心部分,在一次循环迭代的流程中,需要注意:
- nextTick队列、microTask队列中的任务穿插于6个阶段之间进行,每个阶段进行前会先执行并清空nextTick队列、microTask队列中的回调任务(可以理解为一次循环迭代至少处理6次nextTick队列和microTask队列中的任务)
- nextTick队列、microTask队列执行的次数在Node v11.x版本前后有一些差异,(上文中的至少很有深意),具体如下:
a. Node版本小于11时,nextTick队列、microTask队列中的任务只会在6个阶段之间进行,因此一次循环迭代最多处理6次这两个队列
b. Node版本大于11时,任何一个阶段的事件队列中任务之间都会处理一次这两个队列,因此一次循环迭代至少处理6次这两个队列,上限则受各个阶段总任务数影响而不固定
c. 上述2个版本之间的区别,被认为是一个应该要修复的bug,因此在v11.x之后,node修改了nextTick队列、microTask队列的处理时机。从宏、微任务的角度看,修复后的流程和传统js的事件循环保持了一致 - nextTick队列中任务的优先级高于microTask队列
setTimeout() 与 setlmmediate() 的特殊情况
我们知道 setTimeout()的回调是在 timers阶段执行,setImmediate()的回调是在 check阶段执行,并且事件循环是从 timers阶段开始的,那么 setTimeout()的回调一定会先于 setImmediate()的回调执行吗?答案是不一定。在只有这两个函数且近乎同时触发的情况下,它们回调的执行顺序不是固定的(受调用时机、计算机性能影响)。下面是一个例子:
// 示例1(node v12.16.3)
setTimeout(() => {console.log("setTimeout");
});setImmediate(() => {console.log("setImmediate");
});// 结果:
// setTimeout -> setImmediate
// 或
// setImmediate -> setTimeout
上面示例1中的这段代码输出结果就是不固定的,这是因为这种情况下回调不一定完全准备好了。因为主线程没有同步代码需要执行,程序一开始就进入了事件循环。这时setTimeout()的回调并不是一定完全准备好了,因此就可能会在第一次循环迭代的check阶段中执行setImmediate()的回调,再到第二次循环迭代的timers阶段执行setTimeout()的回调;同时也有可能setTimeout()的回调一开始就准备好了,这样就会按照先setTimeout()再setImmediate()的顺序执行回调。由此就造成了输出结果不固定的现象。
有以下两种方法可以使输出顺序固定:
① 人为添加同步代码的延时器,保证回调都准备好了(延时器的时长设定可能会受机器运行程序时的性能影响,因此该方法严格意义上并不能100%固定顺序)。
② 将这两个方法放入pending callbacks、idle,prepare、poll阶段中任意一个阶段即可,因为这些阶段执行完后是一定会先到check再到下一个迭代的timers。由于pending callbacks、idle,prepare阶段都偏向于系统内部,因此一般可以放入poll阶段中使用。
如下示例2,我们人为加上一个2000ms的延时器,输出的结果就固定了,如下所示:
//示例2(node v12.16.3)
setTimeout(() => {console.log("setTimeout");
});setImmediate(() => {console.log("setImmediate2");
});const sleep = (delay) => {const startTime = +new Date();while (+new Date() - startTime < delay) {continue;}
};
sleep(2000);// 结果:setTimeout -> setImmediate
如下示例3,我们将函数放入文件I/O的回调中,输出的结果也就固定了,如下所示:
//示例3(node v12.16.3)
const fs = require("fs");fs.readFile("./fstest.js", "utf8", (err, data) => {setTimeout(() => {console.log("setTimeout");});setImmediate(() => {console.log("setImmediate");});
});// 结果:setImmediate -> setTimeout
NodeJS事件循环示例
console.log('1'); //1层同步//1层timers,setTimeout1
setTimeout(function() {console.log('2'); //2层同步process.nextTick(function() {console.log('3'); //2层nextTick队列})new Promise(function(resolve) {console.log('4'); //2层同步resolve();}).then(function() {console.log('5'); //2层microTask队列})
})process.nextTick(function() {console.log('6'); //1层nextTick队列
})new Promise(function(resolve) {console.log('7'); //1层同步resolve();
}).then(function() {console.log('8'); //1层microTask队列
})//1层timers,setTimeout2
setTimeout(function() {console.log('9'); //2层同步process.nextTick(function() {console.log('10'); //2层nextTick队列})new Promise(function(resolve) {console.log('11'); //2层同步resolve();}).then(function() {console.log('12'); //2层microTask队列})
})console.log('13'); //1层同步//(node v12.16.3)结果:1 -> 7 -> 13 -> 6 -> 8 -> 2 -> 4 -> 3 -> 5 -> 9 -> 11 -> 10 -> 12
//(node v8.16.0)结果:1 -> 7 -> 13 -> 6 -> 8 -> 2 -> 4 -> 9 -> 11 -> 3 -> 10 -> 5 -> 12
图解:node12+版本下的执行顺序
- 首先是1层的同步任务直接执行:1、7、13
- 进入事件循环
- 执行1层的nextTick队列:6
- 执行1层的microTask队列:8
- 进入timer阶段,由于setTimeout1的回调任务先进入队列,因此先执行setTimeout1的2层同步任务:2、4
- 执行setTimeout1的2层nextTick队列:3
- 执行setTimeout1的2层microTask队列:5
- setTimeout1的2层代码均执行完毕,再执行setTimeout2的2层同步代码:9、11
- 执行setTimeout2的2层nextTick队列:10
- 执行setTimeout2的2层microTask队列:12
和浏览器中事件循环的区别
浏览器事件循环在每次宏任务执行后,浏览器有机会进行UI渲染,但实际渲染取决于是否触发了重排或重绘。
● 执行环境:浏览器的事件循环主要运行在JavaScript引擎和渲染引擎之间,而Node.js的事件循环是运行在单独的线程中。这意味着在浏览器中,事件循环可能与渲染进程共享同一个线程,可能会出现线程阻塞的情况。而在Node.js中,事件循环运行在单独的线程中,不会导致浏览器那样的渲染阻塞
● 宏任务和微任务的实现方式:在浏览器中,宏任务和微任务是通过HTML5规范中定义的消息队列来实现的。所有异步任务都被分为宏任务和微任务两种类型,并依次加入到对应的队列中。当当前的宏任务执行完毕后,会立即执行所有的微任务,然后再选择下一个宏任务执行。常见的宏任务包括setTimeout、setInterval、DOM事件等,常见的微任务包括Promise.then、MutationObserver等
● 微任务队列的执行时机:在浏览器事件循环中,每执行完一个宏任务后,便要检查执行微任务队列。而在Node事件循环中,微任务是在两个阶段之间执行的,即在"上一阶段"执行完,"下一阶段"开始前执行微任务队列中的任务。这意味着Node中的微任务是在两个阶段之间执行的,而浏览器中的微任务是在每个宏任务执行完后执行的
● 事件循环的执行机制:浏览器的事件循环是在HTML5中定义的规范,而Node的事件循环则是由libuv库实现。这两个环境的事件循环执行机制不相同,不可以混为一谈
● process.nextTick()的优先级:在Node.js中,process.nextTick()的优先级要高于其他微任务,也就是说,在两个阶段之间执行微任务时,若存在process.nextTick(),则先执行它,然后再执行其他微任务
● 事件循环的执行顺序:浏览器的事件循环机制包括同步代码的执行、宏任务队列的执行、微任务队列的执行以及浏览器UI线程的渲染工作。如果有Web Worker任务,也会被执行。而在Node.js中,事件循环的执行顺序包括脚本作为宏任务的执行、微任务的执行以及可能的Web Worker任务的执行
应用场景影响
● Node.js 更强调后端服务的高效I/O处理和高并发能力,因此其事件循环机制侧重于快速响应I/O事件和维持稳定的事件处理流。
● 浏览器 则侧重于UI渲染和用户交互的实时响应,故其事件循环设计确保了UI的流畅更新和事件的及时处理。