JS异步编程(2)-异步核心Event loop

521 阅读6分钟

这是我参与更文挑战的第2天,活动详情查看:更文挑战

Event loop 是 JavaScript 异步编程的核心,通过事件循环机制,让单线程的 JavaScript 具备异步处理任务的能力

异步任务队列

异步任务队列分为两类

  • 宏任务队列
  • 微任务队列 都用于存放异步任务

为什么异步队列要分宏微任务?

其实在学习了 Event loop 很久之后,才突然反应过来,反问自己这个最初的问题
异步队列有一个就行了,已经能够满足异步操作的需求,为什么还需要分两种队列呢?

答案是:为了插队!

宏微任务的执行逻辑,本质上就是为了满足异步操作的插队需求,让某个后插入的异步操作尽量早的执行

宏任务(macrotasks)

APIWebNode
DOM API
I/O
setTimeout
setInterval
setImmediate
requestAnimationFrame

有些地方会把 UI Rendering 也列为宏任务
但是在 HTML 规范文档中,发现这其实是和微任务平行的一个操作步骤

  • UI Rendering 代表的不是一个单一的任务,而是一个任务队列(queue)
  • 具备一些不同于普通宏任务和微任务的特性
    • 触发的时机在当前微任务队列和下一个宏任务之间
    • UI Rendering 执行中触发的新的 requestAnimationFrame,不会推进当前正在执行的 UI Rendering 队列。而是进入下一次的 UI Rendering 队列

微任务(microtasks)

APIWebNode
process.nextTick
MutationObserver
Promise.then catch finally

process.nextTick 和 web 端的 UI Rendering 类似

  • process.nextTick 也有一个自己的任务队列 nextTick queue
  • 具备一些不同于普通微任务的特性
    • 触发的时机在当前宏任务和当前微任务队列之间

执行机制

Web 中的执行机制

浏览器环境下的 Event loop 是由HTML5规范明确定义,由各大浏览器厂商各自实现
这里主要涉及到下面几个浏览器线程:

  • JS引擎线程:主要处理主执行栈任务(同步任务)
  • 异步http请求线程:主要处理网络请求,将已完成的网络请求回调函数推进事件触发线程
  • 定时器线程:将已完成待执行的定时器回调函数推进事件触发线程
  • 事件触发线程:存储宏微任务的线程

基本流程

异步队列的执行机制,简单来说

  1. 当主执行栈里的任务清空之后,开始读取异步任务队列中的任务
  2. 先读取微任务队列中的任务,依次读取执行直至队列清空
  3. 然后从宏任务中读取第一个任务执行
  4. 从第2步开始重复,直到宏任务队列为空

同步任务 -> 全部微任务 -> UI Rendering -> 宏任务 -> 全部微任务 -> UI Rendering -> 下一个宏任务 -> ...

如果在执行过程中

  • 触发新的宏任务,会将其推进宏任务队列,等待读取

  • 触发新的微任务,会将其推进当前的微任务队列,在本次微任务队列中完成执行 同步任务 -> 全部微任务 -> UI Rendering -> 宏任务(触发新的宏任务和微任务) -> 全部微任务(包含新触发的微任务) -> UI Rendering -> 下一个宏任务(新触发的宏任务被推进宏任务列表等待执行) -> ...

  • 触发新的 UI Rendering,会将其推进下一个 UI Rendering 同步任务 -> 全部微任务 -> UI Rendering(触发新的 RAF) -> 宏任务 -> 全部微任务 -> UI Rendering(包含之前触发的新RAF) -> 下一个宏任务 -> ...

操作触发的浏览器事件回调

// html
<div class="parent" onclick="handleClick()">
    <div class="child" onclick="handleClick()"/>
</div>

// js
function handleClick() {
    Promise.resolve().then(() => console.log('promise then'))
    setTimeout(() => console.log('setTimeout msg'), 0)
}

上面的代码,如果用户点击 child 元素
类似于用宏任务的触发方式,直接注册了 parentchild 元素的 click 回调函数
child click -> child promise then -> parent click -> parent promise then -> child setTimeout msg -> parent setTimeout msg

代码触发的浏览器事件回调

同样是上面的代码,如果使用 JS 代码触发事件

document.querySelector('.child').click()

那么和 dispatchEvent 类似,都是一种同步任务的触发方式
把两次的 click 事件都推入主执行栈队列
child click -> parent click -> child promise then -> parent promise then -> child setTimeout msg -> parent setTimeout msg

Node 中的执行机制

与 Web 端 Event loop 依赖浏览器线程一样,Node 端 Event loop 也依赖一位新同学: libuv

  • libuv 是 Node 的新跨平台抽象层,核心是提供 i/o 的事件循环和异步回调
  • libuv使用异步,事件驱动的编程方式
  • libuv的API包含有时间,非阻塞的网络,异步文件操作,子进程等等。
  • Event Loop就是在libuv中实现的。

6个阶段

Node的 Event loop一共分为6个阶段,会按照顺序反复运行
每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行
当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段

image.png

每个细节具体如下:

  1. timers: 执行 setTimeout 和 setInterval 中到期的 callback,由 poll 调度进入该阶段
  2. pending: 某些系统操作级别的回调,在这个阶段执行
  3. idle, prepare: 仅在内部使用。
  4. poll: 执行 I/O 回调,在适当的情况下回阻塞在这个阶段。
  5. check: 执行 setImmediate 的回调函数
  6. close: 执行close事件的 callback
timers
  • timers 阶段会执行 setTimeout 和 setInterval 回调,由 poll 调度进入该阶段
  • timers 阶段如果触发了新的 setTimeout 和 setInterval,会推入到下一次的 timers 阶段,不会在本次 timers 阶段执行
poll

这一阶段主要处理两件事情

  1. 回到 timers 阶段执行回调
  2. 执行 I/O 回调

执行逻辑: image.png

Node 10.x 及以前的基本流程

在 Node 10.x 及以前。Event loop 的每个阶段,都是先执行宏任务队列,再执行微任务队列
全部宏任务 -> 全部 nextTick 任务 -> 全部微任务

Node 11.x 及以后的基本流程

Node.js 在升级到 11.x 后,Event Loop 运行原理发生了变化。一个宏任务执行完成就执行微任务队列,和浏览器一致了
宏任务 -> 全部 nextTick 任务 -> 全部微任务 -> 下一个宏任务 -> 全部 nextTick 任务 -> 全部微任务

总结

在 Web 端,Event loop 依赖各个浏览器厂商的实现
除了正常的宏微任务外,还拥有独特的 UI Rendering 和 MutationObserver
依靠浏览器各线程的配合,完成 Event loop 的循环

而在 Node 端,Event loop 依赖 libuv 的实现,同时在 Node 11 版本前后有差异
Node 端拥有 6 个事件阶段,每个阶段都可以进行 Event loop 循环

参考文章

Tasks, microtasks, queues and schedules
一次弄懂Event Loop(彻底解决此类面试问题)
面试题:说说事件循环机制(满分答案来了)