1 事件循环引入背景 && 什么是事件循环
- 在介绍什么是事件循环之前,我们先思考下面一个问题: 为什么在浏览器中,要引入“事件循环”的概念? 它有什么作用,或者说,它的引入解决了什么问题?
只有先知道了“事件循环”解决了什么问题,我们才能更深层地理解【为什么事件循环要那么设计】
先来个省流版,要是用一句话总结,事件循环最大的作用,是:
通过一个规范机制,来保证 浏览器渲染主线程的执行,不因为JS的异步任务而堵塞 ==> 本质上,事件循环就是: 定义不同类型任务的 执行优先级 + 划分阶段执行这些任务
- 这里需要了解下什么是 【渲染主线程】 我们知道浏览器是一个多进程多线程的应用程序,它的主要进程有:
- 浏览器进程: 主要负责 页面的显示、处理用户交互、子进程管理等
- 网络进程: 负责加载网络资源等
- 渲染进程: 内部有多个线程,分别有不同的功能
- 3.1 渲染主线程: 解析和执行JS/ 计算样式/生成DOM/ 绘制页面等;
- 3.2 工作线程: 运行Web Worker/ Service Worker等;
- 3.3 合成线程: 把图层分成图块,并发送绘制命令发送给浏览器进程(生成页面,显示在显示器上)等;
- 3.4 光栅线程: 把图块转换成位图,并发送到GPU
- PS: 默认情况下,浏览器每打开1个新标签页,都会开启一个新的渲染进程 ==> 以保证标签页之间 互不影响
3.【渲染主线程】需要处理很多任务,它们大致可以分成 同步任务和异步任务,比如:
- 它原本在执行一段JS脚本,执行过程中触发了 定时器任务: setTimeout/ setInterval
- 它原本在执行一段JS脚本,执行过程中 用户触发了事件回调任务: addEventListener
- ......
这些异步任务,有很多是任务的执行时间是未知的,如果【浏览器主线程】遇到异步任务,就简单粗暴地直接执行,那么万一执行时间过长,渲染主线程就会被长时间堵塞,体现出来的用户感知就是: 浏览器长时间空白/无响应,而这是用户无法接受的。
- 所以,我们的JS执行机制,就得要
避免堵塞渲染主线程的执行。为此,【浏览器】(而非JS引擎)定义了同步和异步 任务:
-
同步任务: 在主线程上排队执行的任务,只有一个任务执行完毕,才能执行下一个任务
-
异步任务: 不进入主线程,而是放在【任务队列】中等待,直到被主线程取出执行
-
当执行同步脚本过程中遇到了异步任务,渲染主线程就会把这些任务交给其他线程去处理,而自身继续执行下面的逻辑,当其他线程处理完该异步任务后,就会把回调函数包装成任务,进入到对应类型的 事件队列,等待主线程的调度
-
这种异步模式,保证了浏览器渲染主线程不因异步任务而 阻塞
-
在Node中,实现异步的思路其实和浏览器中是类似的: Node通过线程池实现异步,具体细节会在下文介绍。
- 既然浏览器渲染主线程在执行过程中,会遇到多种类型的任务(包括各种异步任务),那么就需要有一个规范,来明确规定不同类型任务,它们各自在渲染主线程中的执行优先级,而这些 任务读取/执行的优先级规范,就是下文要介绍的 事件循环。
PS: 关于更多浏览器相关的知识,会在后续博文中详细介绍,这里先占坑: Todo_浏览器相关介绍
2 事件循环的流程规范
- 事件循环实现的核心思路是:
- S1 定义不同类型的队列,每个异步任务进入各自类型的 任务队列
- S2 定义不同类型队列的优先级,这样浏览器就可以 从最高优先级的任务队列中 依次取出任务执行
- S3 一次循环,会完成 1个宏任务和 这次循环中产生的所有微任务
2.1 在目前的浏览器实现中,按优先级从高到低,至少包含了下面的任务队列
- 微队列: 需要最快执行的任务,如 promose.then/ MutationObserver/ process.nextTick
- 宏任务队列: 又可以细分成多个任务队列,包括:
- 交互列队: 用于存放用户操作后生成的事件处理任务,如 onClick回调
- 延时队列: 用于存放定时器到时的回调任务,如 setTimeout/ setInterval
2.2 关于setTimeout(cb, duration),有以下几个注意点需要注意:
- 表示的含义是: duration毫秒后,cb才会进入task队列,而非cb在duration毫秒后立刻执行;
- 实际上,即使 duration为0,浏览器在执行时的最小延时也会 >=4ms,通常是因为 定时器函数嵌套/ 已执行的(回调)函数阻塞
- 未被激活的tab的 最小延迟 >=1000ms,这是为了 优化后台tab的加载损耗 && 降低耗电量
// 最小延时>=4ms的 情况1: 如果定时器嵌套层级超过5层,则会带有4毫秒的最少延迟时间
function cb() { f(); setTimeout(cb, 0) }
setTimeout(cb, 0) // 注意每次cb都会推入任务队列并取出,所以setTimeout循环调用时,不会阻塞页面的渲染
// 最小延时>=4ms的 情况2: 已执行的(回调)函数阻塞
function foo() {
console.log('timeout callback');
}
setTimeout(foo, 0)
for (let index = 0; index < 10000; index++) {
console.log(index)
}
- 事件循环按以下规则,定义了渲染主线程的 执行流程:
-
S1 执行主线程入口代码,过程中遇到异步代码,如各类宏任务/微任务,就分别进入宏任务/微任务队列,直到主线程入口代码执行完毕;
-
S2.1 取出最先入队的微任务执行,执行完毕后页面进行 更新渲染
-
S2.2 依次执行微任务队列内的所有微任务, 直到微任务队列被清空;
-
S2.3 如果在执行微任务a过程中, 产生了新的微任务b/c, b/c会在 当前的清空微任务队列的循环中 执行完成
-
S3 从宏任务队列中,取出最先入队的task并执行
-
S4 如果task执行过程中有遇到微任务,则再次按照 S1~S3的步骤处理,即所谓的 “一轮循环”
用图表示为:
3 Node中的事件循环
- Node.js中的【异步】实现
上文提到Node中的异步实现,是利用了线程池,这里具体说明一下是如何实现的:
- S1 JS执行主线程仅进行I/O的调用;
- S2 让其他多个线程 进行 阻塞I/O 或者 非阻塞I/O + 轮询技术完成数据获取;
- S3 通过线程之间的通信,将I/O得到的数据,传递回给 JS主线程
- S4 获取到I/O结果数据后,再执行回调函数,来完成 业务逻辑层的功能
用图表示为:
- Node中的 事件循环实现
2.1 Node根据任务的种类和优先级,分成了7个阶段 来执行异步任务:
-
S1 Timers: 该阶段用于判断 是否执行定时器回调
- S1.1 对定时器(最小堆)进行遍历,检查是否到时,如果到时的话就执行其回调
- S1.2 定时器有一些注意点,会在下文介绍
-
S2 Pending: 该阶段用于执行 网络/IO等异常时的回调
- S2.1 执行上一轮残留的回调
-
S3 Idle & Prepare: 这两个阶段 仅在事件循环内部使用
-
S4 Poll: 该阶段用于执行 所有其他阶段不处理的 I/O回调 + 检索新的I/O事件
-
S4.1 在Poll阶段,会生成该阶段 预设阻塞的时间 + 预设轮询I/O的时间
-
S4.2 在Poll阶段,会执行 大多数的 网络I/O、文件I/O的回调
-
S4.3 当进入poll阶段
- S4.3.1 如果轮询队列不为空: 遍历该队列 + 同步执行回调,直到队列为空/达到可执行的最大数量
- S4.3.2 如果轮询队列为空:
- 如果有 setImmediate()回调需要执行: 立即结束poll阶段,并进入 check阶段以执行回调
- 如果没有 setImmediate()回调需要执行: 事件循环将停留在该阶段以等待回调被添加到队列中,然后立即执行它们。在超时时间到达前,事件循环会一直停留等待
-
-
S5 Check: 该阶段用于 依次执行setImmediate()的回调
-
S6 Close: 该阶段用于 执行一些关闭资源的回调,优先级最低
用流程图表示为
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
2.2 对于微任务,Node11之后微任务的执行时间提前了,即
- 事件循环的每一轮循环(tick),会先执行timers阶段的一个任务,然后按照先后顺序清空 process.nextTick() + promsie.then()的微任务队列
- 接着继续执行 timers阶段的下一个任务或者 下一个阶段
- 按照这样的顺序,以此类推后续阶段
- Node事件循环中的一些注意点
3.1 定时器(setTimeout/setImmediate)的执行顺序,会因 调用它们的上下文 而有所不同
- 如果两者都是从顶层上下文中调用的,那么它们的执行时间 取决于进程/机器的性能
- 如果是在同一个I/O周期内,setImmediate()的回调 会始终在任何计时器之前执行
// 例1
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
/**
这段代码的输出结果是不确定的,可能先输出 timeout,也可能先输出 immediate
这是因为:
S1 这两个定时器都是在全局上下文中调用的;
S2 当事件循环开始运行并执行到 timers阶段时,当前时间可能大于1ms,也可能不足 1ms,具体取决于机器的执行性能
S3 如果 delay(setTimeout的第二个参数)的值 >2147483647 || <1时, delay 会被设置为 1
S4 因此setTimeout() 在第一个 timers阶段是否会被执行 实际上是不确定的,因此才会出现不同的输出结果。
*/
// 例2
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
/**
在这段代码中两个定时器都被封装成回调函数传入 readFile 中,很明显当该回调被调用时, 当前时间肯定大于1ms了,所以无需考虑机器性能的问题
S1 当该I/O回调被调用时,传入setTimeout和setImmediate 的回调 分别被推进 Timers和 Check任务的队列中,然后该回调执行完毕;
S2.1 由于事件循环此时处于 Poll 阶段,接着它会判断 轮询队列中是否有还要执行的任务,
S2.2 因为 轮询队列中没有任务了,所以接着判断 有无setImmediate()回调
S2.3 因为有setImmediate()回调,于是 事件循环进入Check阶段执行回调,打印出 immdiate
S3 Timers阶段会在下一轮循环中进行,打印 timeout
S4 所以,最后的打印结果为: immediate timeout
*/
4 事件循环常见问题
Q1 什么是Event Loop
A:
S1 浏览器中的事件循环,见上文【##2 事件循环的流程规范- 3.事件循环按以下规则,定义了渲染主线程的 执行流程】部分
S2 Node中的事件循环,见【##3 Node中的事件循环】部分
Q2 Event Loop每一轮执行完task,都会进行页面重新渲染吗
A:
不一定,需要根据 浏览器刷新率 && 页面性能/是否后台运行等因素综合判断,如果hasARenderingOpportunity为true,才需要进行更新渲染,接下来会按序执行 渲染前的准备工作:
- 触发 resize/ scroll事件,建立媒体查询
- 建立 css动画
- 执行动画回调(RAF回调)
- 执行IntersectionObserver回调
- 更新渲染
// 例1: task执行完,页面不进行渲染
setTimeout(function settimeout1() {
btn.innerHTML = '111'
}, 0);
setTimeout(function settimeout2() {
btn.innerHTML = '222'
}, 0);
/** 流程分析如下:
S1 用户触发click事件,执行 DOM点击回调task ==> 发现task是定时器任务 ==> 通知定时器线程进行监听,以确定在规定时间把定时器回调入队;
S2 DOM点击回调task 执行完成后,进行 Paint更新渲染;
S3.1 从任务队列取出新的task, 即settimeout1的回调,执行 settimeout1;
S3.2 settimeout1执行完成,执行 settimeout2;
S4 settimeout2执行完后,才再次进行Paint
*/
// 例2: task执行完,页面进行重新渲染
setTimeout(function settimeout1() {
btn.innerHTML = '111'
}, 0);
setTimeout(function settimeout2() {
btn.innerHTML = '222'
}, 40);
// 因为目前部分浏览器是 16.7ms刷新一帧,所以如果定时器的延迟时间大于16.7ms时,就会出现多次Paint的结果
参考文档
- generator/async/await部分的图片解析 非常好;
- requestAnimationFrame的介绍部分也很好