事件循环详解——浏览器和Node中的事件循环

241 阅读10分钟

1 事件循环引入背景 && 什么是事件循环

  1. 在介绍什么是事件循环之前,我们先思考下面一个问题: 为什么在浏览器中,要引入“事件循环”的概念? 它有什么作用,或者说,它的引入解决了什么问题?

只有先知道了“事件循环”解决了什么问题,我们才能更深层地理解【为什么事件循环要那么设计】

先来个省流版,要是用一句话总结,事件循环最大的作用,是:

通过一个规范机制,来保证 浏览器渲染主线程的执行,不因为JS的异步任务而堵塞 ==> 本质上,事件循环就是: 定义不同类型任务的 执行优先级 + 划分阶段执行这些任务

  1. 这里需要了解下什么是 【渲染主线程】 我们知道浏览器是一个多进程多线程的应用程序,它的主要进程有:
  • 浏览器进程: 主要负责 页面的显示、处理用户交互、子进程管理等
  • 网络进程: 负责加载网络资源等
  • 渲染进程: 内部有多个线程,分别有不同的功能
    • 3.1 渲染主线程: 解析和执行JS/ 计算样式/生成DOM/ 绘制页面等;
    • 3.2 工作线程: 运行Web Worker/ Service Worker等;
    • 3.3 合成线程: 把图层分成图块,并发送绘制命令发送给浏览器进程(生成页面,显示在显示器上)等;
    • 3.4 光栅线程: 把图块转换成位图,并发送到GPU
    • PS: 默认情况下,浏览器每打开1个新标签页,都会开启一个新的渲染进程 ==> 以保证标签页之间 互不影响

3.【渲染主线程】需要处理很多任务,它们大致可以分成 同步任务和异步任务,比如:

  • 它原本在执行一段JS脚本,执行过程中触发了 定时器任务: setTimeout/ setInterval
  • 它原本在执行一段JS脚本,执行过程中 用户触发了事件回调任务: addEventListener
  • ......

这些异步任务,有很多是任务的执行时间是未知的,如果【浏览器主线程】遇到异步任务,就简单粗暴地直接执行,那么万一执行时间过长,渲染主线程就会被长时间堵塞,体现出来的用户感知就是: 浏览器长时间空白/无响应,而这是用户无法接受的。

  1. 所以,我们的JS执行机制,就得要 避免堵塞渲染主线程的执行。为此,【浏览器】(而非JS引擎)定义了同步和异步 任务:
  • 同步任务: 在主线程上排队执行的任务,只有一个任务执行完毕,才能执行下一个任务

  • 异步任务: 不进入主线程,而是放在【任务队列】中等待,直到被主线程取出执行

  • 当执行同步脚本过程中遇到了异步任务,渲染主线程就会把这些任务交给其他线程去处理,而自身继续执行下面的逻辑,当其他线程处理完该异步任务后,就会把回调函数包装成任务,进入到对应类型的 事件队列,等待主线程的调度

  • 这种异步模式,保证了浏览器渲染主线程不因异步任务而 阻塞

  • 在Node中,实现异步的思路其实和浏览器中是类似的: Node通过线程池实现异步,具体细节会在下文介绍。

  1. 既然浏览器渲染主线程在执行过程中,会遇到多种类型的任务(包括各种异步任务),那么就需要有一个规范,来明确规定不同类型任务,它们各自在渲染主线程中的执行优先级,而这些 任务读取/执行的优先级规范,就是下文要介绍的 事件循环。

PS: 关于更多浏览器相关的知识,会在后续博文中详细介绍,这里先占坑: Todo_浏览器相关介绍

2 事件循环的流程规范

  1. 事件循环实现的核心思路是:
  • 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)
}
  1. 事件循环按以下规则,定义了渲染主线程的 执行流程:
  • S1 执行主线程入口代码,过程中遇到异步代码,如各类宏任务/微任务,就分别进入宏任务/微任务队列,直到主线程入口代码执行完毕;

  • S2.1 取出最先入队的微任务执行,执行完毕后页面进行 更新渲染

  • S2.2 依次执行微任务队列内的所有微任务, 直到微任务队列被清空;

  • S2.3 如果在执行微任务a过程中, 产生了新的微任务b/c, b/c会在 当前的清空微任务队列的循环中 执行完成

  • S3 从宏任务队列中,取出最先入队的task并执行

  • S4 如果task执行过程中有遇到微任务,则再次按照 S1~S3的步骤处理,即所谓的 “一轮循环”

用图表示为:
事件循环示意图

3 Node中的事件循环

  1. Node.js中的【异步】实现

上文提到Node中的异步实现,是利用了线程池,这里具体说明一下是如何实现的:

  • S1 JS执行主线程仅进行I/O的调用;
  • S2 让其他多个线程 进行 阻塞I/O 或者 非阻塞I/O + 轮询技术完成数据获取;
  • S3 通过线程之间的通信,将I/O得到的数据,传递回给 JS主线程
  • S4 获取到I/O结果数据后,再执行回调函数,来完成 业务逻辑层的功能

用图表示为:

Node中的异步流程图

  1. 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阶段的下一个任务或者 下一个阶段
  • 按照这样的顺序,以此类推后续阶段
  1. 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,才需要进行更新渲染,接下来会按序执行 渲染前的准备工作:

  1. 触发 resize/ scroll事件,建立媒体查询
  2. 建立 css动画
  3. 执行动画回调(RAF回调)
  4. 执行IntersectionObserver回调
  5. 更新渲染
// 例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的结果

参考文档

01 当事件循环遇到更新渲染

  • generator/async/await部分的图片解析 非常好;
  • requestAnimationFrame的介绍部分也很好

02 带你深入理解js事件循环机制
03 彻底搞懂JavaScript事件循环

04 深入理解Node异步与事件循环
05 从源码解读Node事件循环