再谈JavaScript运行时的事件循环机制

以下是自己在过往不断总结的一些关于 JavaScript 在浏览器和 Node 环境下事件循环的理解。如有谬误欢迎指正。

你能否说出这个例子输出什么?

image.png

这里的结果其实是:不确定的输出。为什么呢?这里就要给大家澄清几个误解

下面这些都是误解,你有吗?

误解 1:在 JavaScript 平台上有一个用户态的主线程,用来执行 JavaScript 代码;除此之外,还有个 EventLoop 线程
用来做事件循环的检查,检查到有事件任务时,再交给 JavaScript 执行线程来执行

误解 2:所有的异步操作(无论文件读写还是 database 操作)都交给 libuv 提供的线程池来处理。

误解 3:EventLoop 的事件队列就是一个类似 queue 的先进先出数据结构的队列

解释

误解 1:EventLoop 和执行 JavaScript 就在一个线程内。

误解 2:libuv 默认创建 4 个线程来进行异步工作;但现在 OS 一般都有提供异步接口例如 linux 的 AIO,一般都是优先使用异步接口。

误解 3:EventLoop 作为进程,它有一组阶段,他会以循环的方式去处理各个阶段的事件,每个阶段是一种类似队列的结构。

https://www.dynatrace.com/news/blog/all-you-need-to-know-to-really-understand-the-node-js-event-loop-and-its-metrics/

后面我们来继续分享 EventLoop 的机制。

浏览器内核会在其它线程中执行异步操作,当操作完成后,将操作结果以及事先定义的回调函数放入 JavaScript 主线程的任务队列中。
JavaScript 主线程会在执行栈清空后,读取任务队列,读取到任务队列中的函数后,将该函数入栈,一直运行直到执行栈清空,再次去读取任务队列,不断循环。
当主线程阻塞时,任务队列仍然是能够被推入任务的。这也就是为什么当页面的 JavaScript 进程阻塞时,我们触发的点击等事件,会在进程恢复后依次执行。

图解浏览器中的 JavaScript 运行时线程

image.png
image.png

Promise.then 的回调是异步执行的

image.png

之所以 promise 无论有没有真的走异步,但 then 始终要异步,是因为规范要求的。onFulfilled 必须在执行上下文栈(Execution Context Stack) 只包含 平台代码(platform code) 后才能执行。平台代码指引擎,环境,Promise 实现代码等。实践上来说,这个要求保证了 onFulfilled 的异步执行(以全新的栈),在 then 被调用的这个事件循环之后

也就是说,必须等到所有业务代码执行完(相当于 resolve 之前存在的所有 macro 和 micro 都做完,再执行)。 其实对我们关注业务代码来说,相当于 then 里面的代码就是异步执行。

macroTask 和 microtask

image.png

JavaScript 中的任务又分为 MacroTask 与 MicroTask 两种,在 ES2015 中 MacroTask 即指 Task,而 MicroTask 则是指代 Job

(macro)task 主要包含:script(整体代码)、setTimeout、setInterval、I/O(包括网络)、UI 交互事件、postMessage、MessageChannel、setImmediate(Node.js 环境),

自测发现: macrotask 队列也不止一个

image.png

(macro)task 主要包含:script(整体代码)、setTimeout、setInterval、I/O(包括网络)、UI 交互事件、postMessage、MessageChannel、setImmediate(Node.js 环境),requestAnimationFrame(这个比较特殊,他跟 ui render 相关)

可以看到:假如做一个实验(先让页面产生一个 setTimeout3 秒,然后让页面 sleep6 秒,sleep 第 4 秒去点击页面 click),最终鼠标点击事件要在 setTimeout 之前触发。哪怕 setTimeout 事件放入队列要早于 click.

为了验证自己的结论,我写了这样一个实验:

image.png

发现,dom 事件会比 setTimeout 更早触发。

UI render 在事件循环中什么位置?

image.png

可以看到,其实,实际上浏览器渲染的触发并不是每次事件循环都会触发,他会以大约 60 帧每秒的频率来触发。
如果一次循环内没有触发 render,那你的 requestAnimation 回调也不会执行

复习 UI 渲染的基本流程:
image.png

Promise 的 microtask 何时放入队列?

Promise 加入 microtask 的顺序 是以 then 触发时为依据的。且对于已经 resolve 的 promise,then 注册时也会立刻放入 microtask 队列。

例子:
image.png

microtask 的本质

其实他是本轮宏任务操作末尾的一些 c++层面的临时队列,即 c++在本轮同步任务干完活之后(干活过程中就可能新增了一些微任务 task),当本轮 c—++准备进入“空闲状态前”,忽然发现“咦,微任务 tasks 数组又有了很多微任务”,所以 c++要把这坨微任务给清掉,然后才进入空闲状态。

网上常说的 Tick 是什么意思。何为一个 Tick?

Tick 是否包含 microTask 队列清空的这一部分时间? 待定,我觉得看你怎么理解了。

vue 官方文档认为把 dom 更新放到 microTask 里就称作 “when in the next tick”, 而且 vue 把 nextTick 这个函数名的实现细节实现为 microTask,看似都是把 nexttick 这个概念认为是“执行完当前一个 macroTask 任务之后,下一个 macroTask 任务开始之前”

而 node 中的 nextTick,也是放在 microTask 队列。其 microTask 是在 macroTask 执行过程的两个阶段之间执行。它的粒度是“清空一个阶段的 macroTask 任务之后,执行下一个阶段的 macroTask 任务之前”

总之,他们 nextTick 都有点像两个大粒度之间插入的任务。

但我们不要被 nextTick 名字误导,我觉得可以理解为下一个 Tick 之前要执行的东西,所以函数名叫 nextTick。

关于这个问题,在 Stack Overflow 上面有个问题:

https://stackoverflow.com/questions/47508649/when-does-the-event-loop-turn

关于 microtask 执行 到底是在一个 tick(loop turn 或 loop iterator)里面,不是很重要。但是为了能有个标准,我倾向于把它归类在一个 tick 里面。

image.png

HTML 规范中对 EventLoop 的描述:
image.png

浏览器与 Node.js 的 EventLoop 机制的区别

  1. 浏览器中是**“**执行一个**macroTask+**清空**microtask**队列**”**,依次循环
  2. Node.js 中是**“**执行一个**macroTask**队列的所有任务**+**清空**microtask**队列**”;** 再取下一种**macroTask**队列

image.png

原因可能是:浏览器需要尽快做一些 UI 渲染等操作,所以一个 Tick 周期的粒度要小(因为 UI 渲染放在 microtask 执行之后)
而 Node.js 主要关注 IO 回调(fs.readFile(callback))的执行要尽早(因为要尽早给客户端响应内容),所以就以某个类型的 macroTask 任务批量做完再去执行该阶段的 microtask 队列(如 nextTick)。

Vue.nextTick 的实现原理?

vue 数据修改会被 watcher 记录下来(watcher 会调用 nextTick 放入 microtask 队列),因此 dom 树文档对象模型的实际修改是在 microtask 执行的时候才会修改的。
也正因如此,你要想访问到 dom 元素的变更,也需要在 nextTick 里面去获取 dom 修改后的内容,如果你依旧在 microtask 执行之前去获取 dom 内容肯定是拿不到的。

那么,为什么 vue 要尽量用 microtask 实现呢?
原因是期望尽快的修改 dom,从而尽快让浏览器完成渲染。
image.png

image.png

那为啥不直接修改 dom 而是要放到微任务队列修改?因为数据驱动的 dom 更新不可能让你改一下数据他就改一次 dom,他要等你把所有数据都改完再批量更新 dom

如何理解 JavaScript 会阻塞 UI 渲染?

首先,我们要搞清楚 UI render 发生在什么时候:一般会在 microTask 执行结束之后,下一个 Tick 之前。即 UI 渲染一般是在两个 macroTask 之间的间隙。one macroTask —> one microTask queue —>

其次,我们要区分 dom 操作和 UI 渲染的关系。dom 操作是修改 dom 对象模型,是实时生效的,ui 渲染是指的浏览器根据 dom 模型重绘 UI 界面,这个是只发生在 js 主线程的下一个 Tick 之前的那个时间点的。
其实就相当于:UI render 是等到 JavaScript 不执行(比如完成一个 task 之后的空闲间隙)的时候,UI 才会渲染。
因此:UI 渲染跟 JavaScript 执行不会同时发生(网上常说的互相阻塞),从表面上可以认为 js 执行会阻塞 UI 渲染。 对开发者的意义是,不要让 js 线程执行太耗时的任务,否则 ui 很久看不到 dom 渲染结果。

UI render 是单独的线程,但跟 JavaScript 运行互斥
因此,UI render 是在 microtask 执行完 下一个 macroTask 开始前 的间隙来执行的

dom 事件冒泡时 task 的处理顺序?

例子:
image.png

发现,一个点击事件的处理过程中插入微任务,那么微任务会优先于下一个冒泡执行。

问题原因核心在于三点:

  1. dom event 优先级高于其他 macroTask;
  2. microTask 会在下一个 macroTask 执行前清空;
  3. 冒泡事件是多个独立的 macroTask

image.png

由于冒泡是多个 macroTask 任务,所以肯定要执行完所有 microTask(Promise.then)之后,再执行上一层冒泡事件的回调。
至于 timer 回调,自然要等到所有 dom event 处理完再说咯~

Node 中的 EventLoop 与浏览器有所不同?

Node 中的 EventLoop 有所不同。* nodejs**的**event**是基于**libuv**,而浏览器的**event loop**则在**html5**的规范中明确定义。**
libuv**已经对**event loop**作出了实现,而**html5**规范中只是定义了浏览器中**event loop**的模型,具体实现留给了浏览器厂商**

EventLoop 的一切都在官方文档进行详细解释了:
https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/#

Node 每个阶段有一个队列;所有 phase 的认为可以理解为我们所谓的 macroTask。

image.png

各个阶段的举例:

timer queue: setTimeout, setInterval
IO queue: fs.readFile…
immediate queue: setImmediat

执行流程图:

image.png

它们当然在从当前阶段到下一个阶段之前尽可能快的运行。不像其他阶段,它们两个没有系统依赖的最大限制,node 运行它们直到两个队列是空的。然而,nextTickQueue 会比 microTaskQueue 有着更高的任务优先级
(图解:整个圈圈叫做一个 EventLoop,圈圈里每个节点叫一个 phase,每个 phase 执行之前都要先清掉 nextTick 和 microtask 两个 queue。另外事件循环应该是在 main.js 主代码执行完之后启动的,当然如果 main.js 里有注册 microtask 和 nextTick,那么第一次事件循环的第一个 timer 也不会优先执行,而是先把 nextTick 和 microtask 清空,具体例子看下一页 ppt)

Node 中防止 IO 饥饿

由于 process.nextTick 会在每个阶段都要清空才会进入下一个阶段,且 nextTick 会优先于正常 macroTask 任务执行。因此假设你处在 IO phrase 阶段,如果你弄了一个 process.nextTick ,那么你的回调会立刻优先得到执行,结果你又搞了一个耗时长的任务,这时你就卡住你的 IO phrase 了。这就是 IO 饥饿。

我们知道, IO 阶段,通常是在处理用户 IO 结果,那么饥饿就意味着你的浏览器端的用户可能在苦苦等不到响应了。因此要尽量防止 IO 饥饿。

解决办法是,尽量用 setImmediate。因为 setImmediate 处于 IO 阶段的下一个阶段,当你执行 setImmediate 后,你的回调不会在本次 IO Tick 内执行,所以 IO 回调依然会得到正常处理,不会饥饿。

别人家的解释:
image.png

Node 什么时候适合用 nextTick 呢?

node 内部的 httpServer 就用到了。
image.png

这个地方,我们一般首先创建 server 实例,然后调用 listen 方法监听本机端口(这个在底层是个同步系统调用,相当于并没有系统的 listening 回调)。 而 Node 为了让我们可以收到一个 listen 完成的消息,因此,他模拟了一个异步回调,即在 nextTick 后发射了一个 “listening” 事件。

为啥他不在 listen 后立刻触发回调呢?因为他认为你此时还没绑定上 listening 回调函数。所以这时候就适合用 nextTick 来实现了。

最后,给大家出一个题目。

image.png