文章目录
本文将详细介绍事件循环,首先声明事件循环分为
浏览器下js引擎的事件循环
和
node事件循环
(P.S. 事件循环简直是一个老生常谈的问题了,我最近在看node,发现之前写的博客中讲的不够清晰,现在再次做一次总结和完善)
附上我之前的文章链接,有兴趣的可以看下:
https://blog.csdn.net/Welkin_qing/article/details/88956200
一、js事件循环
还是按基础的来,首先介绍两个概念执行栈
和消息队列
(1)执行栈【先进后出】
我早期的文章中,说执行栈是一个存储同步函数的地方,这句话需要细细理解。
我们都知道js在调用一个方法时,会生成一个与这个方法的相对应的执行环境【context】称为执行上下文。
执行上下文:存着这个方法的私有作用域,在这个作用域中定义着这个方法【或成为对象】定义的变量以及这个作用域中的this对象。
执行栈:当一系列方法被调用时,因为js引擎是单线程的,同一时间只能执行一个方法,所以就将这些方法排列在一个单独的地方,这个地方就是执行栈。
如下图所示:
js引擎在解析代码时,会将同步代码按照执行顺序加入执行栈中,从上开始往下执行。
如果当前执行的是一个方法,js引擎则会在当前这个栈中添加这个方法的执行环境,并进入该环境中继续执行其中的代码。
当执行环境的代码执行完毕并返回其结果后,js引擎会退出该执行环境,并且把这个执行环境销毁,回到上一个方法的执行环境,依次操作。直到栈中的方法全部执行完毕。
(2)消息队列【先进先出】
执行栈是同步代码的执行,而消息队列就是针对于异步函数的处理。
用我上一篇文章的话来说,js引擎遇到异步函数时,不会一直等待其结果,而是将其挂起,优先执行执行栈中的任务。
当异步操作触发且执行完成后,就会被放入消息队列中去排队。放入消息队列中不回立即去执行其回调函数,而是等待主线程处理完执行栈中的任务后,处于闲置状态后再从消息队列中查找是否有任务,如果有就会取出排在第一位的任务,将这个任务的回调函数放入对应的执行栈中,执行该任务,如此循环。
供奉一张神图:
左边的stack是执行栈,web API代表异步事件,下边的callback queue是消息队列
(3)宏观任务和微观任务
能看懂上面已经不容易了,现在又要花点时间去理解下面的概念了
上面已经介绍了一个事件循环的基本宏观表述,实际上,异步任务又不是那么简单的事,异步任务根据发起者的不同又分为以下两类:
详情参考上一篇博客
- 宏任务:【宿主对象发起的任务】
setInterval()
setTimeout()
- 微任务:【js引擎发起的任务】
new Promise()
new MutaionObserver()
前面介绍过,异步任务的结果会被放入消息队列中,但是根据上面异步任务发起者类型不同会将异步任务放入不同的消息队列中。
这里又会产生一个天大的问题,为什么微观任务比宏观任务先执行?
首先咱们先来看js引擎的运行机制
1. js引擎运行机制
在当前执行栈为空时,主线程会查看微任务队列是否有事件存在
- 存在,依次执行队列中的事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的事件,把当前的回调加到当前指向栈。
- 如果不存在,则在宏任务队列中取出一个事件并把对应的回调到加入当前执行栈;
需要记住的是:当前执行栈执行完毕后时会立刻处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。
同一次事件循环中,微任务永远在宏任务之前执行。
2. 变相理解
从19年起我就开始关注这个问题,网上的说法是在太多了,所以需要跳出整个圈子来理解。
在极客时间的重学前端中,winter这样说到:
事件循环在做的一件事就是 等待-执行,当然代码执行并没有那么简单。我们把这里的执行过程看作一个宏观任务,可以大致理解为宏观任务的队列就是事件循环。
于是就会有人说不是说异步任务分为宏观任务和微观任务吗?
我们把这里产生的微观任务保证在一个宏观任务中完成,因此每个宏观任务中又包含了一个微观任务队列【这种概念对应于网上说,宏观任务是按个执行,而微观任务是按一整个对列执行的这种说法】
即结果如下图所示:
了解了宏观任务和微观任务的机制,就可以理解js引擎和宿主级任务了。js引擎发起的任务就会在队列尾部添加任务,而宿主对象发起的任务则会添加宏观任务。
二、node事件循环
(1)与浏览器事件循环有何不同
不同之处是node中有属于自己的一套模型。
node中事件循环依靠的是libuv引擎。node 选择chrome V8引擎作为js解释器,V8引擎将js 代码分析后去调用对应的node API,而这些API 最后由libuv 引擎驱动,来执行对应的任务,并且把不同的事件放在不同的队列等待主线程执行。
因此实际上,node中的事件循环存在与libuv引擎中。
(2)node事件循环模型
模型中的每一个方块代表事件循环的一个阶段
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<──connections─── │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
注意: 每个阶段都有一个执行的回调FIFO队列。尽管每个阶段都有其自己的特殊方式。但是通常,当事件循环进入给定阶段时,它将执行该阶段特定的任何操作,然后在该阶段的队列中执行回调,直到队列耗尽或执行回调的最大数量为止。当队列已为空或达到回调限制时,事件循环将移至下一个阶段,依此类推。
(3)事件循环阶段详情
从上面的模型中,可以分析node事件循环的顺序:
外部输入数据
轮询阶段【poll】
检查阶段【check】
关闭事件回调阶段【close callback】
定时器检测阶段【timer】
I/O事件回调阶段【I/O callbacks】
闲置阶段【idle,prepare】
- timers:此阶段执行由 setTimeout 和 setInterval 设置的回调。
- pending callbacks:执行推迟到下一个循环迭代的 I/O 回调。
- idle, prepare, :仅在内部使用。
- poll:取出新完成的 I/O 事件;执行与 I/O 相关的回调(除了关闭回调,计时器调度的回调和 setImmediate 之外,几乎所有这些回调) 适当时,node 将在此处阻塞。
- check:在这里调用 setImmediate 回调。
- close callbacks:一些关闭回调,例如
socket.on('close', ...)
。
在每次事件循环运行之间,Node会检查是否正在等待任何异步I/O
或者timers
,如果没有,则将其关闭。
1. poll阶段
V8引擎将js 代码解析后传入libuv 引擎后,循环首先进入 poll阶段
轮询阶段具有两个主要功能:
- 计算应该阻塞并 I/O 轮询的时间
- 处理轮询队列 (poll queue) 中的事件
执行逻辑如下:
- 首先查看poll queue【队列】中是否有事件
- 有,按照先进先出的顺序执行回调
- 没有,1. 检查是否有
setImmediate()
的回调函数,有,则进入check阶段执行回调函数 - 2 同时检查是否有到期的timer,有,把到期的timer的回调函数按照调用顺序放到定时器队列中,之后循环会进入定时器阶段执行队列中的回调函数
- 如果以上都为空,则循环会在poll阶段停留,直到有一个I/O事件返回,循环会进入I/O的callback阶段,并且立即执行这个事件的callback。
注意:
poll阶段在执行poll queue中的回调时实际上不会无限的执行下去。有两种情况poll阶段会终止执行poll queue中的下一个回调:
- 所有回调执行完毕。
- 执行数超过了node的限制。
2. check阶段
check阶段专门用来执行setImmediate()方法的回调,当poll阶段进入空闲状态,并且setImmediate queue中有callback时,事件循环进入这个阶段。
3. close阶段
当一个socket连接或者一个handle被突然关闭时(例如调用了socket.destroy()
方法),close事件会被发送到这个阶段执行回调。否则事件会用process.nextTick()
方法发送出去。
4. timer阶段
这个阶段以先进先出
的方式执行所有到期的timer加入timer队列里的callback,一个timer callback指得是一个通过setTimeout
或者setInterval
函数设置的回调函数。
5. I/O callback阶段
如上文所言,这个阶段主要执行大部分I/O事件的回调,包括一些为操作系统执行的回调。例如一个TCP连接生错误时,系统需要执行回调来获得这个错误的报告。
(4)process.nextTick
,setTimeout
与setImmediate
的区别与使用场景
这三个函数都是用来推迟任务执行的方法。
1. process.nextTick
在node上算是一个特殊队列,这个队列的回调执行不被表示为一个阶段,但这个事件会在每一个阶段执行完毕准备进入下一个阶段前优先执行。
当事件循环准备进入下一个阶段之前,会先检查nextTick queue中是否有任务,如果有,那么会先清空这个队列。与执行poll queue中的任务不同的是,这个操作在队列清空前是不会停止的。这也就意味着,错误的使用process.nextTick()
方法会导致node进入一个死循环。。直到内存泄漏。
2. setTimeout
setTimeout()
方法是定义一个回调,并且希望这个回调在我们所指定的时间间隔后第一时间
去执行。
当收到操作系统和当前任务的很多影响,该回调不会在我们预期的时间间隔后准时执行,执行的时间存在一定的延迟和误差,这是不可避免的。node会在可以执行timer回调的第一时间去执行你所设定的任务。
3. setImmediate
setImmediate()
方法从意义上将是立刻执行的意思,但是实际上它却是在一个固定的阶段才会执行回调,即poll阶段之后。
有趣的是,这个函数的意义和之前提到过的process.nextTick()
方法才是最匹配的。node的开发者们也清楚这两个方法的命名上存在一定的混淆,他们表示不会把这两个方法的名字调换过来—因为有大量的node程序使用着这两个方法,调换命名所带来的好处与它的影响相比不值一提。