官网:node官网-事件循环
之前分享过浏览器中的事件循环,感兴趣的可以去看:从js单线程到浏览器多线程与多进程,再到JS的事件循环
浏览器中的事件循环是由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渲染,但实际渲染取决于是否触发了重排或重绘。
-
环境差异: 浏览器环境和 Node.js 环境是不同的。浏览器中的事件循环主要用于处理用户交互事件(例如点击、滚动等)和网络请求等异步任务,而 Node.js 的事件循环主要用于处理 I/O 操作(例如文件操作、网络请求等)和自定义的事件。
-
API 差异: 浏览器和 Node.js 提供了不同的 API 来处理异步操作。浏览器中常用的异步 API 包括定时器函数(如 setTimeout、setInterval)、AJAX 请求和 Web API(如 Fetch API、WebSockets 等)等。而 Node.js 中常用的异步 API 包括文件系统操作、网络操作、数据库访问等。
-
宏任务和微任务: 在浏览器中,事件循环通常分为宏任务(macrotasks)和微任务(microtasks)两个阶段。宏任务包括用户交互事件、网络请求等,而微任务包括 Promise、MutationObserver 等。而在 Node.js 中,事件循环没有明确的宏任务和微任务的划分,所有的异步任务都被视为一个个独立的阶段。
-
事件驱动模型: 浏览器中的事件循环通常以单线程的方式执行,即所有的 JavaScript 代码都在同一个线程中执行。而 Node.js 中的事件循环是基于多线程的,通过使用 libuv 库实现了事件循环的异步执行,使得 Node.js 可以处理大量的并发请求。
应用场景影响
● Node.js 更强调后端服务的高效I/O处理和高并发能力,因此其事件循环机制侧重于快速响应I/O事件和维持稳定的事件处理流。
● 浏览器 则侧重于UI渲染和用户交互的实时响应,故其事件循环设计确保了UI的流畅更新和事件的及时处理。