Skip to main content

事件循环

事件循环(Event Loop)是指浏览器或 Node.js 中解决 JavaScript 单线程运行时可能阻塞的一种机制,即异步机制。JavaScript 运行特点是:单线程,非阻塞

执行栈和任务队列

JavaScript 有一个 main thread 主线程和 call-stack 执行栈,所有的任务都会被放到执行栈等待主线程依次执行。

执行栈

执行栈,也叫调用栈,用于存储在代码执行期间创建的所有执行上下文,具有后进先出的特点。当函数执行时,会被添加到执行栈的顶部,当函数执行完成后,就会从栈顶移出,直到栈被清空。

任务队列

任务队列,用于存储异步事件的回调函数,具有先进先出的特点。异步代码的执行,遇到异步事件不会等待它返回结果,而是将这个事件挂起,继续执行执行栈中的其他任务。当异步事件有了返回结果,将它的回调函数放入相应的任务队列中,被放入任务队列的回调不会立刻执行。

异步任务

JavaScript 中任务有两种类型,分别是同步任务和异步任务。异步任务通常又分为 macro-task(task) 和 micro-task(jobs) 两种,对应的任务队列分别称作宏任务队列和微任务队列。

引入微任务的原因:页面渲染事件,各种 IO 的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。如果此时突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。

可产生宏任务的事件有:

  • script 整体代码
  • setTimeout/setInterval/setImmediate
  • postMessage (H5)
  • I/O
  • UI 交互事件 (H5)

可产生微任务的事件有:

note

宏任务和微任务本质上无差,因为它们都是待执行函数,不同的是该任务是由哪种事件产生。相对应的宏任务队列和微任务队列也是如此,不同的是两种任务队列中的任务何时执行、如何执行。通常我们通过产生任务的事件来区分宏任务和微任务。

浏览器中事件循环机制

异步事件有了返回结果后,会把其注册的回调函数放入任务队列中,根据异步事件的类型,这个事件实际上会被放入对应的宏任务和微任务队列中去。

当前执行栈为空时,主线程会检查微任务队列是否有待执行任务存在。如果存在,依次执行任务队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最靠前的一个任务,放入执行栈中执行。如果不存在,那么再去宏任务中取出最靠前的一个任务,放入执行栈中执行。

同一次时间循环中,微任务永远在宏任务之前执行,每个宏任务执行完成后都会清空微任务队列,这就是微任务比宏任务优先级更高的原因。

在事件循环中,每进行一次循环操作称为 tick,关键步骤如下:

  1. 执行一个宏任务(栈中没有就从事件队列中获取)
  2. 执行过程中如果遇到微任务,产生结果后将回调添加到微任务队列中
  3. 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(微任务优先级高的原因)
  4. 当前宏任务执行完毕,开始检查渲染,然后 GUI 线程接管渲染
  5. 渲染完毕后,JS 线程继续接管,开始下一个宏任务(从事件队列中获取)

Event Loop

简单概括为:执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,直到所有微任务执行完毕,再回到宏任务中进行下一轮循环。

Nodejs 中事件循环机制

在 Node.js 中,事件循环表现出的状态与浏览器中大致相同。不同的是 Node.js 中有一套自己的模型。Node.js 中事件循环的实现是依靠的 libuv 引擎。我们知道 Node.js 选择 chrome v8 引擎作为 js 解释器,v8 引擎将 js 代码分析后去调用对应的 node API,而这些 API 最后则由 libuv 引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行。因此实际上 Node.js 中的事件循环存在于 libuv 引擎中。

最新 Node.js 事件循环操作顺序的简化概览如图所示:

   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘

注意:每个框被称为事件循环机制的一个阶段。

每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段,等等。

阶段概述:

  • timer: 本阶段执行已经被 setTilemoutsetInterval 的调度回调函数。定时器由轮询阶段控制何时执行。
  • pending callbacks: 执行延迟到下一个循环迭代的 I/O 回调。(某些系统操作,如 TCP 错误类型)
  • idle,prepare: 仅系统内部调用。
  • poll: 检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate 调度的之外),其余情况 node 将在适当的时候在此阻塞。
  • check: setImmediate 回调函数在这里执行。此阶段允许人员在轮询阶段完成后立即执行回调。
  • close callbacks: 一些关闭的回调函数,如 socket.on('close', callback)

node 中事件循环的顺序:外部输入数据 -> 轮询阶段 -> 检查阶段 -> 关闭事件回调阶段 -> 定时器阶段 -> 挂起的回调阶段 -> 闲置阶段 -> 轮询阶段 ···

其中,当事件循环进入轮询阶段且没有被调度的计时器时,将发生以下两种情况之一:

  • 如果轮询队列不是空的,事件循环将循环访问回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬性限制。
  • 如果轮询队列是空的,还有两件事发生:
    • 如果脚本被 setImmediate 调度,则事件循环将结束轮询阶段,并继续检查阶段以执行那些被调度的脚本。
    • 如果脚本未被 setImmediate 调度,则事件循环将等待回调被添加到队列中,然后立即执行。

一旦轮询队列为空,事件循环将检查已达到时间阈值的计时器。如果一个或多个计时器已准备就绪,则事件循环将绕回计时器阶段以执行这些计时器的回调。

参考资料: