事件循环与异步
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 引擎处理。
4. 渲染主线程是如何工作的?
渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于:
- 解析 HTML
- 解析 CSS
- 计算样式
- 布局
- 处理图层
- 每秒把页面画 60 次
- 执行全局 JS 代码
- 执行事件处理函数
- 执行计时器的回调函数
- ……
思考题:为什么渲染进程不适用多个线程来处理这些事情?
JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。
要处理这么多的任务,主线程遇到了一个前所未有的难题:如何调度任务?
比如:
- 我正在执行一个 JS 函数,执行到一半的时候用户点击了按钮,我该立即去执行点击事件的处理函数吗?
- 我正在执行一个 JS 函数,执行到一半的时候某个计时器到达了时间,我该立即去执行它的回调吗?
- 浏览器进程通知我 ” 用户点击了按钮 “,与此同时,某个计时器也到达了时间,我应该处理哪一个呢?
- ……
渲染主线程想出了一个绝妙的主意来处理这个问题:排队
- 在最开始的时候,渲染主线程会进入一个无限循环
- 每一次循环会检查消息队列中是否有任务存在。如果有,事件触发线程就取出第一个任务执行,执行完一个后进入下一次循环;如果没有,则进入休眠状态。
- 其他所有线程(包括其他进程的线程)可以随时向消息队列添加任务。新任务会加到消息队列的末尾。在添加新任务时,如果主线程是休眠状态,则会将其唤醒以继续循环拿取任务
这样一来,就可以让每个任务有条不紊的、持续的进行下去了。
整个过程,被称之为事件循环(消息循环)
5. 何为异步?
代码在执行过程中,会遇到一些无法立即处理的任务,比如:
- 计时完成后需要执行的任务——
setTimeout
、setInterval
- 网络通信完成后需要执行的任务 —
XHR
、Fetch
- 用户操作后需要执行的任务 —
addEventListener
如果让渲染主线程等待这些任务的时机达到,就会导致主线程长期处于「阻塞」的状态,从而导致浏览器「卡死」
渲染主线程承担着极其重要的工作,无论如何都不能阻塞!
因此,浏览器选择 异步 来解决这个问题
使用异步的方式,渲染主线程永不阻塞
6. 消息队列中的任务类型
参考资料:【Chromium 的官方源码】
内部消息类型:
- 输入事件(鼠标滚动、点击、移动)
- 微任务
- 文件读写
- WebSocket
- JavaScript 定时器等等
与页面相关的事件:
- JavaScript 执行
- 解析 DOM
- 样式计算
- 布局计算
- 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 渲染——> 其他任务(宏任务)