JS异步编程(2)-异步核心Event loop
Event loop 是 JavaScript 异步编程的核心,通过事件循环机制,让单线程的 JavaScript 具备异步处理任务的能力
异步任务队列
异步任务队列分为两类
- 宏任务队列
- 微任务队列
都用于存放异步任务
为什么异步队列要分宏微任务?
其实在学习了 Event loop 很久之后,才突然反应过来,反问自己这个最初的问题
异步队列有一个就行了,已经能够满足异步操作的需求,为什么还需要分两种队列呢?
答案是:为了插队!
宏微任务的执行逻辑,本质上就是为了满足异步操作的插队需求,让某个后插入的异步操作尽量早的执行
宏任务(macrotasks)
API | Web | Node |
---|---|---|
DOM API | ✅ | ❌ |
I/O | ❌ | ✅ |
setTimeout | ✅ | ✅ |
setInterval | ✅ | ✅ |
setImmediate | ✅ | ✅ |
requestAnimationFrame | ✅ | ❌ |
有些地方会把 UI Rendering 也列为宏任务
但是在 HTML 规范文档中,发现这其实是和微任务平行的一个操作步骤
- UI Rendering 代表的不是一个单一的任务,而是一个任务队列(queue)
- 具备一些不同于普通宏任务和微任务的特性
- 触发的时机在当前微任务队列和下一个宏任务之间
- UI Rendering 执行中触发的新的 requestAnimationFrame,不会推进当前正在执行的 UI Rendering 队列。而是进入下一次的 UI Rendering 队列
微任务(microtasks)
API | Web | Node |
---|---|---|
process.nextTick | ❌ | ✅ |
MutationObserver | ✅ | ❌ |
Promise.then catch finally | ✅ | ✅ |
process.nextTick 和 web 端的 UI Rendering 类似
- process.nextTick 也有一个自己的任务队列 nextTick queue
- 具备一些不同于普通微任务的特性
- 触发的时机在当前宏任务和当前微任务队列之间
执行机制
Web 中的执行机制
浏览器环境下的 Event loop 是由HTML5规范明确定义,由各大浏览器厂商各自实现
这里主要涉及到下面几个浏览器线程:
- JS引擎线程:主要处理主执行栈任务(同步任务)
- 异步http请求线程:主要处理网络请求,将已完成的网络请求回调函数推进事件触发线程
- 定时器线程:将已完成待执行的定时器回调函数推进事件触发线程
- 事件触发线程:存储宏微任务的线程
基本流程
异步队列的执行机制,简单来说
- 当主执行栈里的任务清空之后,开始读取异步任务队列中的任务
- 先读取微任务队列中的任务,依次读取执行直至队列清空
- 然后从宏任务中读取第一个任务执行
- 从第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
元素
类似于用宏任务的触发方式,直接注册了 parent
和 child
元素的 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个阶段,会按照顺序反复运行
每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行
当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段
每个细节具体如下:
- timers: 执行 setTimeout 和 setInterval 中到期的 callback,由 poll 调度进入该阶段
- pending: 某些系统操作级别的回调,在这个阶段执行
- idle, prepare: 仅在内部使用。
- poll: 执行 I/O 回调,在适当的情况下回阻塞在这个阶段。
- check: 执行 setImmediate 的回调函数
- close: 执行close事件的 callback
timers
- timers 阶段会执行 setTimeout 和 setInterval 回调,由 poll 调度进入该阶段
- timers 阶段如果触发了新的 setTimeout 和 setInterval,会推入到下一次的 timers 阶段,不会在本次 timers 阶段执行
poll
这一阶段主要处理两件事情
- 回到 timers 阶段执行回调
- 执行 I/O 回调
执行逻辑:
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(彻底解决此类面试问题)》
《面试题:说说事件循环机制(满分答案来了)》