说到事件循环,经常会将浏览器和Node的事件循环进行对比,那么本文也会对这两个展开叙述。
浏览器中的 Event Loop
浏览器的事件循环大概可总结为以下几个阶段:
一次循环过程:
当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。
页面的时间循环系统:事件循环 + 消息队列:
node的 Event Loop
简介
Node.js采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制由libuv实现。
事件循环:libuv库提供两个最重要的东西是事件循环和线程池,两者共同构建了异步非阻塞I/O模型
事件驱动:事件驱动是在整个非阻塞I/O模型当中线程池通知事件循环它已经完成I/O操作的一种机制。
libuv的事件循环分为6个阶段,这6个阶段,按照顺序反复运行。每进一个阶段,从对应的回调队列中取出函数执行。
六个阶段
外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段(按照该顺序反复运行)
timers 阶段:这个阶段执行timer(setTimeout、setInterval)的回调
I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
idle, prepare 阶段:仅node内部使用
poll 阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里
check 阶段:执行 setImmediate() 的回调
close callbacks 阶段:执行 socket 的 close 事件回调
每个阶段都有一个先入先出的(FIFO)的用于执行回调的队列,事件循环运行到每个阶段,都会从对应的回调队列中取出回调函数去执行,
直到队列当中的内容耗尽,或者执行的回调数量达到了最大。然后事件循环就会进入下一个阶段,然后又从下一个阶段对应的队列中取出回调函数执行,
这样反复直到事件循环的最后一个阶段。而事件循环也会一个一个按照循环执行,直到进程结束。
注意:上面六个阶段都不包括 process.nextTick()
接下去我们详细介绍timers、poll、check这3个阶段,因为日常开发中的绝大部分异步任务都是在这3个阶段处理的。
timer
timers 阶段会执行 setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的。 同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。
poll
poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情
1.回到 timer 阶段执行回调
2.执行 I/O 回调
并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情
如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
如果 poll 队列为空时,会有两件事发生
如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去
当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调
check阶段
setImmediate()的回调会被加入check队列中,从event loop的阶段图可以知道,check阶段的执行顺序在poll阶段之后。 我们先来看个例子:
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function() {
console.log('promise3')
})
console.log('end')
start
end
promise3
timer1
promise1
timer2
promise2
Micro-Task 与 Macro-Task
Node端事件循环中的异步队列也是这两种:macro(宏任务)队列和 micro(微任务)队列。
常见的 macro-task 比如:setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作等。
常见的 micro-task 比如: process.nextTick、new Promise().then(回调)等。
混淆点:
1. 宏任务队列和微任务队列都只是概念,在node当中没有说哪个具体队列名字就叫做宏任务队列,
正确的认知应该是前面我们说的事件循环当中的6个阶段对应的6个基本的队列都属于宏队列
2. 比如timer阶段对应的是timer宏队列,I/O callback阶段对应的就是I/O callback宏队列,依次类推。
事件循环当中的6个宏队列和微队列的关系如下:
微队列(microtask)在事件循环的各个阶段之间执行,或者说在事件循环的各个阶段对应的宏队列(macrotask)之间执行
node10.X之前版本 与 node11.X之后版本区别:
node10.X之前版本:宏队列当中的有几个宏任务,是要等到宏队列当中的所有宏任务全部执行完毕才会去执行微队列当中的微任务
node11.X之后版本:一旦执行一个阶段里对应宏队列当中的一个宏任务(setTimeout,setInterval和setImmediate三者其中之一,
不包括I/O)就立刻执行微任务队列,执行完微队列当中的所有微任务再回到刚才的宏队列执行下一个宏任务。跟浏览器端运行一致。
process.nextTick
这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。
执行机制:process.nextTick是用于在事件循环的下一次循环中调用回调函数的,将一个函数推迟到代码执行的下一个同步方法执行完毕,或异步事件回调函数开始执行时再执行
执行原理:Node每一次循环都是一个tick,每次tick,Chrome V8都会从时间队列当中取所有事件依次处理。遇到nextTick事件,将其加入事件队尾,等待下一次tick到来的时候执行
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')
})
})
})
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1
Node与浏览器的 Event Loop 差异
浏览器和Node 环境下,microtask 任务队列的执行时机不同
Node端,microtask 在事件循环的各个阶段之间执行
浏览器端,microtask 在事件循环的 macrotask 执行完之后执行
总结
对于新版本node11及以后,在只执行浏览器和Node共有的宏任务的时候虽然过程不一样,但结果一样
- 本文链接:http://example.com/2022/03/13/node/eventloop/
- 版权声明:本博客所有文章除特别声明外,均默认采用 许可协议。