图解浏览器事件循环


事件循环 (Event loop,又叫消息循环 Message loop)是浏览器的核心概念之一,是前端学习和技术提升绕不过去的知识。本文通过图解的方式,讲述一下现代浏览器事件循环的运行原理。

1. 浏览器的进程与线程

进程是计算机操作系统资源分配的最小单位。现代浏览器,以 chrome 为例,广泛采用的是多进程架构。为了减少连环崩溃的几率,启动浏览器后,会将各个任务拆分为多个进程。如下图所示:

进程.png

chrome 将浏览器任务拆分为多个进程:

  • 浏览器进程(Browser process):我负责界面显示、用户交互、子进程管理等业务。
  • 网络进程(Network process):我负责加载网络资源业务。
  • 渲染进程(Rendering process):我是渲染的主角,负责执行 HTML、css、js 代码。默认情况,浏览器会为每一个 tab 页签开启一个渲染进程,以确保相互不影响。
  • ...

频繁开启进程会造成内存占用过多的情况,chrome 后续会进行改进,考虑相同站点(相同顶级域名和二级域名)共用一个渲染进程。

无标题-2023-10-06-19251.png

渲染进程会开启一个渲染主线程,用于无阻塞渲染任务。其余线程协助主线程完成任务。

渲染主线程(Main thread) 负责执行代码、样式与布局(通过优先级、百分比换算、宽高等几何信息高动态计算、相对位置等计算最终样式表)、处理图层、控制60帧刷新、执行各种回调函数等,是最忙的一个,一刻也没有停歇。

无标题-2023-10-06-19252.png

此时就有一个问题,主渲染任务都交给一个线程,效率会高吗?为什么不多线程一起渲染?

这是因为浏览器渲染和 JavaScript 执行共用一个线程,而默认情况下, JavaScript 执行又会影响 CSSOM/DOM 树的渲染和结构,所以必须阻塞渲染,他们也必须是单线程操作。在构建 DOM 时,HTML 解析器若遇到了 JavaScript,它会暂停构建DOM,将控制权移交给 JavaScript 引擎,等 JavaScript 引擎运行完毕,浏览器再从中断的地方恢复 DOM 构建。如果使用多线程处理可能会导致渲染DOM的冲突,复杂度会大幅度提升:

无标题-2023-10-06-19254.png

2. 消息循环

上面使用单线程,使得在编写代码时无需考虑复杂的线程同步和互斥问题,从而降低了开发的复杂性,但是终归效率是上不去的。为了解决这个问题,这里巧妙地引入了消息循环来统一解决这个问题。

核心思想:渲染主线程永久轮询执行任务;单独开辟一个消息队列(Message queue)空间用于存放各个线程加入的任务;渲染主线程只是简单的拿取任务执行,而不用操心这个任务是谁送过来的。在 Chrome 的源码中,使用的是一个死循环来完成的:

for(;;) {}

消息队列的无限轮询就叫消息循环,消息循环就发生在渲染主线程里

无标题-2023-09-15-1456.png

  1. 最开始,渲染主线程开始轮询
  2. 每一次呢循环检查消息队列是否有任务,有则取出第一个任务执行。
  3. 其他所有线程都可以追加任务进消息队列的末尾。

异步

在消息循环中,肯定会存在一些需要延时执行的代码,js在执行到他们时不会立即触发回调,而是需要某种计时机制或者触发机制来定时触发,他们一般是通过回调函数来返回执行的结果,大致分类如下:

  1. 前端API计时器:setTimeout、setInterval
  2. 前端异步 API:Promise等
  3. 网络通信:XHR、fetch等
  4. 用户交互回调:addEventListener

异步与同步最大的不同是,异步回调触发前的延时对用户是没有必要的。设计浏览器时,异步触发前的那段延时应该另外开辟线程(比如计时线程)单独计数,而不阻塞渲染主线程

异步的渲染过程如下:

无标题-2023-09-15-1456.png

可以看到,异步任务在执行完毕后进入消息队列末尾排队即可!主线程只管拿消息任务,而不用管计时任务是否完成。

计时线程其实使用的是操作系统底层的逻辑,这里不做深究。

异步案例

看下面的代码:

function delay(duration) { /* 死循环 duration 秒 */ }

const btn = document.querySelector('button');
const h1 = document.querySelector('h1');

btn.onclick = function() {
    h1.textContent = 'hello message queue !'
    delay(3000)
}

他执行后会有什么表现?是页面标题变为 'hello message queue !' 后假死3秒钟吗?我们用图解来分析一下:

无标题-2023-09-15-1456.png

我们来看图说话:

  1. 用户点击,点击的回调进入消息队列
  2. 这个回调事件分为两部分,同步代码 h1.textContent = 'hello message queue !' 立即执行,延时代码开始执行进入死循环
  3. 最后入队一个绘制任务,在其他代码执行完毕后开始绘制

是不是看出问题来啦?浏览器实际效果是先假死3秒钟再改变标题文字!

绘制任务侧重于渲染原理,不是本文重点,不做赘述。他出现在循环的每一次迭代之后。在每一个事件循环的迭代中,浏览器会先处理所有的同步任务(例如:解析HTML,处理DOM操作等),然后处理异步任务(例如:网络请求,定时器回调等)。在这个异步任务完成后,浏览器会进入下一个事件循环迭代。

理解异步

关于异步,我们是怎么理解的呢?

一般来说,单线程语言是没有严格意义的异步的,代码是从上至下依次执行的。上面讲的异步,是在浏览器维度来讲的,js没有多线程,但是浏览器可以开辟多个线程来维护异步的任务。

渲染主线程执行 js 任务(js线程阻塞执行),主线程有承担渲染的其他任务,如果都同步执行,主线程就会白白消耗时间,页面经常性假死。

采用消息循环+异步时,在异步任务发生时,主线程将这个任务交给其他线程来执行,跳过这个任务往后执行;其他线程执行完毕后将回调包装成任务放入消息队列末尾,等待主线程调用。

这就保证了单线程渲染时,永不阻塞渲染。最大限度保证主线程流畅。

优先级

任务本身没有优先级,渲染主线程依次从对头拿取任务执行。有优先级的是消息队列。

在最新版的 chrome 中,消息队列其实不是一个队列,而是多个,他们有优先级,这个优先级决定了谁先进入渲染主线程被执行:

  • 延时队列(Delay queue):计时器回调任务队列,优先级【中】
  • 交互队列(Interactive queue):用户交互后回调事件队列,优先级【高】
  • 微任务队列(Micro queue):用户数据最快执行队列,例如 Promise、MutationObserver 等,其优先级【最高】
  • ...

根据 W3C 最新标准,现代浏览器将宏任务队列的概念进一步做了细分。原来的宏任务主要包含:script(整体代码)、setTimeout、setInterval、I/O、UI交互事件、postMessage、MessageChannel、setImmediate(Node.js 环境)等。

优先级案例

我们用一个例子来解释:

setTimeout(function() {
  console.log(1);
}, 0);

console.log(2);

Promise.resolve().then(function() {
  console.log(3);
});

console.log(4);

我们将代码拆分几块来分析:

无标题-2023-10-11-1617.png

js 代码是同步执行的,所以这 4 块会被 js 执行引擎快速扫描到并加入响应的队列里:

无标题-2023-10-11-1617.png

然后渲染主线程会按照队列的优先级拿取任务,由于 2 和 4 号任务是同步代码,直接入渲染主线程,可直接执行,打印 2 和 4;接下来拿取优先级最高的微对列的任务 3 并执行,输出 3,接下来拿交互队列,没有任务;最后获取延时队列的任务,并依次执行。

注意,这里 1 号延时任务执行时,会开辟计时线程来计数,计数完成后,将里边的同步代码 console.log(1); 加入同步的消息队列 (这里就是直接加入渲染主线程)同步执行,就可以输出 1 了。所以输出结果是:2 4 3 1


3. 总结

概念一:消息循环又叫事件循环,是浏览器处理异步的一种调度策略。

概念二:渲染主线程会反复轮询(for循环),不断拿取消息队列的任务来执行;其他线程获取到任务后,在合适的时机将其追加在消息队列末尾。

概念三:任务队列分为 延时队列、交互队列、微任务队列;不同任务队列有不同的优先级。

Q&A

延时队列是怎么计时的?时间精确吗?

延时队列调用的是操作系统的功能,而计算机硬件并没有类似原子钟的设备(使用的是CPU寄存器计时),没有办法做到百分百精准。

按照 W3C 标准,浏览器实现计时器时,如果嵌套层级超过 5 层,则会带有 4 毫秒的最小间隔时间,这又进一步加大了延时的误差。而且计时器回调也只会在渲染主线程空闲时执行,如果延时任务之前有一个很长的 js 同步阻塞,也会造成定时不准确。

Chrome 关于最小延时的源码:

image.png


4. 练一练

使用消息队列的理念,口算一下输出是什么吧!

function test() {
  console.log(1);
  Promise.resolve().then(function() {
    console.log(2);  
  });
}

setTimeout(function() {
  console.log(3);
  Promise.resolve().then(test);
});

Promise.resolve().then(function() {
  console.log(4);
  setTimeout(() => {
    console.log(5);
  });
});

console.log(6);

Was this page helpful?