Node.js 中的事件循环是基于单线程的异步非阻塞模型。它是 Node.js 的核心机制,基于 libuv 实现,用于处理非阻塞的 I/O 操作和异步事件。
1. Node.js 事件循环介绍
Node.js 的事件循环是一个 Event Loop,通过异步回调函数的方式实现非阻塞的处理。事件循环会在主线程上不断地执行,监听和处理事件,执行相应的回调函数。
Node.JS的事件循环比浏览器端复杂很多。Node.js的运行机制如下:
- V8引擎解析JavaScript脚本。
- 解析后的代码,调用Node API 。
- libuv 库(C++)负责Node API的执行。它将不同的任务分配给不同的线程,形成一个事件循环,以异步的方式将任务的执行结果返回给V8引擎。
- V8引擎再将结果返回给用户。
2. 事件循环的六个阶段
libuv 引擎中的事件循环分为六个阶段,每个阶段都有对应的回调队列(回调函数的集合)和触发器,依次执行以下步骤:
- timers 定时器检测阶段:处理定时器和
setTimeout
/setInterval
设置的回调函数。 - I/O callbacks 事件回调阶段:处理上一轮循环中少量未执行的与 I/O 相关的回调函数,例如网络请求的响应、文件读写等。
- idle, prepare 闲置阶段:内部使用。
- poll 轮询阶段:检索新的 I/O 事件,执行与 I/O 相关的回调函数。(几乎所有情况,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的函数之外,其余情况的 node 将在适当的时候在此阻塞)
- check 检查阶段:执行
setImmediate
设置的回调函数。 - close callbacks 关闭事件回调阶段:执行一些关闭的回调函数,例如
socket.on('close', ...)
。
外部输入数据一>轮询阶段(poll)->检查阶段(check)->关闭事件回调阶段(close callback)->定时器 检测阶段(timer)->I/O事件回调阶段(I/O callbacks)->闲置阶段(idle、prepare)->轮询阶段(按照该顺序反复运行)
每个阶段对应一个队列,当事件循环进入某个阶段时,将会在该阶段内执行回调,直到队列耗尽或者回调的最大数量已执行,那么将进入下一个处理阶段。
除了上述6个阶段,还存在process.nextTick,其不属于事件循环的任何一个阶段,它属于该阶段
与下阶段之间的过渡,即本阶段执行结束,进入下一个阶段前,所要执行的回调,类似插队。
timers
timers阶段会执行setTimeout和setInterval回调,并且是由poll阶段控制的。同样,在Node.js中定时器指定的时间也不是准确时间,只能是尽快执行。
poll
poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情:
- 回到timer阶段执行回调
- 执行 I / O 回调
并且在进入该阶段时如果没有设定了timer的话,会发生以下两件事情: - 如果 poll 队列不为空,会遍历 poll 回调队列并同步执行,直到队列为空或者达到系统限制
- 如果 poll 队列为空时,会有两件事发生:
- 如果有setlmmediate回调需要执行,poll 阶段会停止并且进入到check阶段执行回调
- 如果没有setlmmediate回调需要执行,会等待其他异步任务回调被加入到队列中并立即执行回调,这里同样会有个超时时间,防止一直等待下去
当然设定了timer的话且 poll 队列为空,则会判断是否有timer超时,如果有的话会回到timer阶段执行回调。
假设 poll 被堵塞,那么即使 timer 已经到时间了也只能等着,这也是为什么上面说定时器指定的时间并不是准确的时间。
const fs = require('fs')
const start = Date.now()
setTimeout(()=>{
console.log('setTimeout', Date.now() - start) // 503ms
}, 200)
fs.readFile('./index.js', ()=>{
const start = Date.now()
console.log('文件读取结束')
// 强行拖时间
while(Date.now() - start < 500) {}
})
//timer队列 setTimeout 异步需要等待 poll 全部执行完之后再执行
//poll队列 readFile
check
setimmediate()的回调被加入check队列中,从事件循环的阶段图可以知道,check阶段的执行顺序在poll 阶段之后。
3. 一些注意点:
- setTimeout 和 setImmediate 区别:setImmediate 在 poll阶段完成时执行,即check阶段;setTimeout 在 poll 阶段为空闲时,且设定时间到达后执行,但在 timer 阶段执行
setTimeout(function() {
console.log('timeout');
}, 0)
setImmediate(function() {
console.log('immediate');
})
他们执行的先后顺序是未知的,进入事件循环的准备也是需要花费成本的,如果准备阶段花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调;否则就先执行 setImmediate 回调。
但当二者在异步 I/O callback内部调用时,总是先执行setlmmediate, 再执行setTimeout。例如:
const fs = require('fs')
fs.readFile(__filename,()=>
setTimeout(()=>{
console.log('timeout');
},0)
setImmediate(()=>{
console.log('immediate')
})
})
// immediate
// timeout
在上述代码中,setlmmediate永远先执行。因为两个代码写在I/O回调中,I / O 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在setlmmediate回调,所以就直接跳转到check阶段去执行回调了。
- process.nextTick
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function () {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
// timers: setTimeout
// 微任务队列:nextTick
nextTick 有一个自己的队列,独立于事件循环,每个阶段执行完成后,如果存在 nextTick,就会清空队列中的所有回调函数,并且优先于其他 微任务队列 执行。
4. Node.js 事件循环和浏览器事件循环的区别:
- 浏览器环境下,微任务队列在每一个宏任务执行完之后执行。
- node.js 环境,微任务会在事件循环的各个阶段之间执行,每一个阶段执行完毕,都会去清空微任务队列中的任务。
通过事件循环,Node.js 实现了高效的异步 I/O 操作,并能够处理大量的并发连接,适用于构建高性能的网络应用程序。
5. 宏任务和微任务
nodejs 中同样存在宏任务和微任务。
微任务对应有:
- nextTick queue:process.nextTick
- other queue:Promise的then回调、queueMicrotask
宏任务对应有:
- timer queue:setTimeout、setInterval
- poll queue:IO事件
- check queue:setlmmediate
- close queue:close事件
其执行顺序为:
- nextTick queue microtask queue
- other microtask queue
- timer queue
- poll queue
- check queue
- close queue
一个小练习:
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("setTimeout2");
}, 300);
setImmediate(() => console.log("setImmediate"));
process.nextTick(() => console.log("nextTick1"));
async1();
process.nextTick(() => console.log("nextTick2"));
new Promise(function (resolve) {
console.log("promise1");
resolve();
console.log("promise2");
}).then(function () {
console.log("promise3");
});
console.log("script end");
分析过程:
-
先找到同步任务,输出script start
-
遇到第一个setTimeout,.将里面的回调函数放到timer队列中
-
遇到第二个setTimeout,300ms后将里面的回调函数放到timer队列中
-
遇到第一个setlmmediate,将里面的回调函数放到check队列中
-
遇到第一个nextTick,将其里面的回调函数放到本轮同步任务执行完毕后执行
-
执行async1函数,输出async1 start
-
执行async2函数,输出async:2,async2后面的输出async1 end进入微任务,等待下一轮的事件循环
-
遇到第二个process.nextTick,将其里面的回调函数放到本轮同步任务执行完毕后执行
-
遇到new Promise,执行里面的立即执行函数,输出promise1、promise2
-
then里面的回调函数进入微任务队列
-
遇到同步任务,输出script end
-
执行下一轮回调函数,先依次输出nextTick的函数,分别是nextTick1、nextTick2
-
然后执行微任务队列,依次输出async1end、promise3
-
执行timer队列,依次输出setTimeout0
-
接着执行check队列,依次输出setlmmediate
-
300ms后,timer队列存在任务,执行输出setTimeout2
script start
async1 start
async2
promise1
promise2
script end
nextTick1
nextTick2
async1 end
promise3
setTimeout0
setImmediate
setTimeout2
setTimeout 和 setImmediate 的小 tips
setTimeout(() => {
console.log("setTimeout");
}, 0);
setImmediate(() => {
console.log("setImmediate");
});
分两种情况:
// 情况一:
setTimeout
setImmediate
// 情况二:
setImmediate
setTimeout
分析下流程:
- 外层同步代码一次性全部执行完,遇到异步API 就塞到对应的阶段
- 遇到setTimeout,虽然设置的是0毫秒触发,但实际上会被强制改成1ms,时间到了然后塞入times阶段
- 遇到setImmediate塞入check阶段
- 同步代码执行完毕,进入Event Loop
- 先进入times阶段,检查当前时间过去了1毫秒没有,如果过了1毫秒,满足setTimeout条件,执行回调,如果没过1毫秒,跳过
- 跳过空的阶段,进入check阶段,执行setImmediate回调
这里的关键在于这1ms,如果同步代码执行时间较长,进入Event Loop的时候1毫秒已经过了,setTimeout先执行,如果1毫秒还没到,就先执行了setImmediate ,所以可能出现有两种情况。