事件循环与异步

事件循环与异步

1. 前言

简介: 关键词:多进程、单线程、渲染、事件循环、消息队列、异步、微任务
你是不是有过以下困惑:

  • 我执行了一段 js,页面就卡了挺久才有响应
  • 多个方法互相嵌套,但是最终还是蒙对了
  • 我用 setTimeout(callback, 1000) 给代码加了 1s 的延时,1 秒里发生了很多事情,然后功能正常了
  • 不是很明白为什么浏览器有时候会卡死
  • 我用 Promise,async/await 顺序执行了异步代码
  • js 为什么要设计成单线程,不适用多个线程来处理这些事情
  • 事件循环好像知道那么点,但是就是讲不出来为啥
  • ……

本次就把这些问题给一一解答,当然这些东西想完弄清楚,肯定离不开进程线程浏览器内核渲染事件循环消息队列等,我们就一个一个的来看,它们到底是怎么工作的。

2. 浏览器进程

浏览器从关闭到启动,然后新开一个页面至少需要:1 个浏览器进程,1 个 GPU 进程,1 个网络进程,和 1 个渲染进程,一共 4 个进程

可以在浏览器的任务管理器中查看当前的所有进程

后续如果再打开新的标签页:浏览器进程,GPU 进程,网络进程是共享的,不会重新启动,然后默认情况下会为每一个标签页配置一个渲染进程,但是也有例外,比如从 A 页面里面打开一个新的页面 B 页面,而 A 页面和 B 页面又属于同一站点的话,A 和 B 就共用一个渲染进程,其他情况就为 B 创建一个新的渲染进程

所以,最新的 Chrome 浏览器包括:1 个浏览器主进程,1 个 GPU 进程,1 个网络进程,多个渲染进程,和多个插件进程

  • 浏览器进程:负责控制浏览器除标签页外的界面,包括地址栏、书签、前进后退按钮等,以及负责与其他进程的协调工作,同时提供存储功能
  • GPU 进程:负责整个浏览器界面的渲染。Chrome 刚开始发布的时候是没有 GPU 进程的,而使用 GPU 的初衷是为了实现 3D CSS 效果,只是后面网页、Chrome 的 UI 界面都用 GPU 来绘制,这使 GPU 成为浏览器普遍的需求,最后 Chrome 在多进程架构上也引入了 GPU 进程
  • 网络进程:负责发起和接受网络请求,以前是作为模块运行在浏览器进程一时在面的,后面才独立出来,成为一个单独的进程
  • 插件进程:主要是负责插件的运行,因为插件可能崩溃,所以需要通过插件进程来隔离,以保证插件崩溃也不会对浏览器和页面造成影响
  • 渲染进程:负责控制显示 tab 标签页内的所有内容,核心任务是将 HTML、CSS、JS 转为用户可以与之交互的网页,排版引擎 Blink 和 JS 引擎 V8 都是运行在该进程中,默认情况下 Chrome 会为每个 Tab 标签页创建一个渲染进程

我们平时看到的浏览器呈现出页面过程中,大部分工作都是在渲染进程中完成,所以我们来看一下渲染进程中的线程

3. 渲染进程中的线程

  • GUI 渲染线程:负责渲染页面,解析 HTML、CSS、构建 DOM 树、CSSOM 树、渲染树、布局树、绘制、分层、栅格化、合成,所以重绘、重排、合成都在该线程中执行。

GUI 线程和 JS 引擎线程是冲突的,当 GUI 线程执行时,js 引擎线程会被挂起,当 js 引擎线程执行任务时,有需要 GUI 线程执行的任务,会被保存到一个队列中,等待 js 引擎执行完执行。

  • 主线程(JS 引擎线程):一个 tab 页中只有一个 JS 引擎线程 (单线程),负责解析和执行 JS。只要消息队列不为空,就会一直从中取任务执行。它与 GUI 渲染进程不能同时执行,只能一个一个来,所以当一个 js 任务执行过长时,会阻塞页面的渲染,造成页面的卡顿。
  • 计时器线程:指 setInterval 和 setTimeout,因为 JS 引擎是单线程的,所以如果处于阻塞状态,那么计时器就会不准了,所以需要单独的线程来负责计时器工作
  • 异步 http 请求线程:XMLHttpRequest 连接后浏览器开的一个线程,比如请求有回调函数,异步线程就会将回调函数加入事件队列,等待 JS 引擎空闲执行
  • 事件触发线程:主要用来控制事件循环,比如 JS 执行遇到鼠标事件、计时器、AJAX 异步请求等,就会将对应任务添加到事件触发线程中,在对应事件符合触发条件触发时,会将任务从事件触发线程中取出,放到消息队列的队尾,等 JS 引擎处理。

image-20230321220253465

4. 渲染主线程是如何工作的?

渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于:

  • 解析 HTML
  • 解析 CSS
  • 计算样式
  • 布局
  • 处理图层
  • 每秒把页面画 60 次
  • 执行全局 JS 代码
  • 执行事件处理函数
  • 执行计时器的回调函数
  • ……

思考题:为什么渲染进程不适用多个线程来处理这些事情?
JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。

要处理这么多的任务,主线程遇到了一个前所未有的难题:如何调度任务?

比如:

  • 我正在执行一个 JS 函数,执行到一半的时候用户点击了按钮,我该立即去执行点击事件的处理函数吗?
  • 我正在执行一个 JS 函数,执行到一半的时候某个计时器到达了时间,我该立即去执行它的回调吗?
  • 浏览器进程通知我 ” 用户点击了按钮 “,与此同时,某个计时器也到达了时间,我应该处理哪一个呢?
  • ……

渲染主线程想出了一个绝妙的主意来处理这个问题:排队

image-20220809223027806

  1. 在最开始的时候,渲染主线程会进入一个无限循环
  2. 每一次循环会检查消息队列中是否有任务存在。如果有,事件触发线程就取出第一个任务执行,执行完一个后进入下一次循环;如果没有,则进入休眠状态。
  3. 其他所有线程(包括其他进程的线程)可以随时向消息队列添加任务。新任务会加到消息队列的末尾。在添加新任务时,如果主线程是休眠状态,则会将其唤醒以继续循环拿取任务

这样一来,就可以让每个任务有条不紊的、持续的进行下去了。

整个过程,被称之为事件循环(消息循环)

5. 何为异步?

代码在执行过程中,会遇到一些无法立即处理的任务,比如:

  • 计时完成后需要执行的任务——setTimeoutsetInterval
  • 网络通信完成后需要执行的任务 — XHRFetch
  • 用户操作后需要执行的任务 — addEventListener

如果让渲染主线程等待这些任务的时机达到,就会导致主线程长期处于「阻塞」的状态,从而导致浏览器「卡死」

image-20220810104344296

渲染主线程承担着极其重要的工作,无论如何都不能阻塞!

因此,浏览器选择 异步 来解决这个问题

image-20220810104858857

使用异步的方式,渲染主线程永不阻塞

6. 消息队列中的任务类型

参考资料:【Chromium 的官方源码】

内部消息类型:

  1. 输入事件(鼠标滚动、点击、移动)
  2. 微任务
  3. 文件读写
  4. WebSocket
  5. JavaScript 定时器等等

与页面相关的事件:

  1. JavaScript 执行
  2. 解析 DOM
  3. 样式计算
  4. 布局计算
  5. CSS 动画等

7. Js 为何会阻碍渲染?

先看代码

<p>Hello World</p>
<button>change</button>
<script>
  var h1 = document.querySelector("h1");
  var btn = document.querySelector("button");
  // 死循环指定的时间
  function delay(duration) {
    var start = Date.now();
    while (Date.now() - start < duration) {}
  }
  btn.onclick = function () {
    h1.textContent = "你好,世界!";
    delay(3000);
  };
</script>

点击按钮后,会发生什么呢?

<见具体代码演示>

8. 任务有优先级吗?

任务没有优先级,在消息队列中先进先出

消息队列是有优先级的

根据 W3C 的最新解释:

  • 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。
    在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。
  • 浏览器必须准备好一个微队列,微队列中的任务优先所有其他任务执行
    https://html.spec.whatwg.org/multipage/webappapis.html#perform-a-microtask-checkpoint

随着浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法

在目前 chrome 的实现中,至少包含了下面的队列:

  • 延时队列:用于存放计时器到达后的回调任务,优先级「中」
  • 交互队列:用于存放用户操作后产生的事件处理任务,优先级「高」
  • 微队列:用户存放需要最快执行的任务,优先级「最高」
  • ……

添加任务到微队列的主要方式:

  • Promise 的 then 回调、
  • Mutation Observer API
  • queueMicrotask()
  • async await
  • process.nextTick(node 中)

例如:

// 立即把一个函数添加到微队列
Promise.resolve().then(函数);

queueMicrotask(() => {
  /* 微任务中将运行的代码 */
});

9. 微队列,DOM 渲染,其他任务队列的执行顺序

const main = document.getElementById("main");
const frg = document.createDocumentFragment();

for (let i = 0; i < 10; i++) {
  const li = document.createElement("li");
  li.innerHTML = i;
  frg.appendChild(li);
}
main.appendChild(frg);

new Promise((resolve) => {
  resolve();
}).then(() => {
  console.log("微任务已经执行");
  alert("dom 还未插入");
});

setTimeout(() => {
  console.log("宏任务执行");
  alert("dom 已经插入");
});

微任务——> Dom 渲染——> 其他任务(宏任务)

10. 怎么证明

目录