多进程架构

  • 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  • 渲染进程:负责渲染页面,Blink 和 V8 都运行在该进程的主线程上,渲染进程都是运行在沙箱模式下。
  • GPU 进程:负责和 CPU 通信,GPU 的使用初衷是为了实现 3D CSS 的效果,随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后 Chrome 在其多进程架构上也引入了 GPU 进程。
  • 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,但现在成为一个单独的进程。
  • 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

沙箱模式:沙箱模式就是让该进程对于操作系统的某些权限受到限制,例如读取文件。

Chrome 默认策略:每个标签对应一个渲染进程,但是相同站点的页面会分配同一个渲染进程。官方把这个默认策略叫 process-per-site-instance。

这种策略的好处:

  • 对于不同站点的页面,一个页面崩溃不会影响其他的页面
  • 对于相同站点的页面,使用同一个渲染进程会共享一些东西,因为是同一家的站点,所以是有这个需求的
  • 如果页面里有 iframe 的话,iframe 也会运行在单独的进程中

消息队列

页面中的大部分任务都是在主线程上执行的,这些任务包括了:

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件、V8 垃圾回收事件;
  • 网络请求完成、文件读写完成事件。

要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,浏览器通过消息队列和事件循环来实现。

消息队列是一种线程模型,可以让一个线程接收其他线程发送来的消息。

但是给主线程安排任务的不仅仅只有渲染进程中的线程,还包括其他的进程需要通知渲染主线程执行任务。

渲染进程中有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后会将这些消息组装成任务送入消息队列,主线程只需要源源不断地从消息队列中取出任务并执行即可。

事件循环

WHATWG 规范 对事件循环机制的定义:

  • 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;
  • 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;
  • 当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask;
  • 最后统计执行完成的时长等信息。

所谓的事件循环就是渲染主线程不断地从消息队列中取出任务来执行。

WHATWG 规范中定义了在主线程的循环系统中,可以有多个消息队列,比如鼠标事件的队列,IO 完成消息队列,渲染任务队列,并且可以排优先级。

系统调用栈

当循环系统在执行一个任务的时候,会为这个任务维护一个系统调用栈。类似于 JavaScript 的调用栈,完整的调用栈信息可以通过 chrome://tracing/ 抓取,也可以通过 Performance 面板抓取它核心的调用信息。

这幅图记录了一个 Parse HTML 的任务执行过程,其中黄色的条目表示执行 JavaScript 的过程,其他颜色的条目表示浏览器内部系统的执行过程。

Parse HTML 任务在执行过程中会遇到一系列的子过程,比如在解析页面的过程中遇到了 JavaScript 脚本,那么就暂停解析过程去执行该脚本,等执行完成之后,再恢复解析过程。然后又遇到了样式表,这时候又开始解析样式表……直到整个任务执行完成。

异步

页面中任务都是执行在主线程之上的,相对于页面来说,主线程就是它整个的世界,所以在执行一项耗时的任务时,比如下载网络文件任务,这些任务都会放到页面主线程之外的进程或者线程中去执行,这样就避免了耗时任务“霸占”页面主线程的情况。

当外部的进程处理完这个任务后,会将该任务添加到渲染进程的消息队列中,并排队等待循环系统的处理。排队结束之后,循环系统会取出消息队列中的任务进行处理,并触发相关的回调操作。

微任务

我们把消息队列中的任务称为宏任务,每个宏任务都会和一个微任务队列相关联。

当该宏任务执行过程中产生了微任务,就会将该微任务添加到微任务队列,在该宏任务执行完成之后会先清空微任务队列再执行下一个宏任务。

而且在清空微任务队列的过程中产生的微任务依旧会加入微任务队列。

微任务的意义:

宏任务(包括页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等)随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。

换句话来说,微任务是优先级比宏任务高的异步任务。

例如:监控 DOM 节点的变化情况,然后根据这些变化来处理相应的业务逻辑。

同步执行的话性能很低,而加入消息队列会丧失实时性。权衡效率和实时性,使用微任务队列是一个比较好的方案。

这就是 Mutation Observer API 的设计思路,也是 Mutation Event 被废弃的原因。

定时器

渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务:

  • 当接收到 HTML 文档数据,渲染引擎就会将“解析 DOM”事件添加到消息队列中
  • 当用户改变了 Web 页面的窗口大小,渲染引擎就会将“重新布局”的事件添加到消息队列中
  • 当触发了 JavaScript 引擎垃圾回收机制,渲染引擎会将“垃圾回收”任务添加到消息队列中
  • 如果要执行一段异步 JavaScript 代码,需要将执行 JavaScript 任务添加到消息队列中

通过定时器设置回调函数需要在指定的时间间隔内被调用,但普通的队列按照顺序进出,所以不能将定时器的回调函数添加到一般的队列中。

Chrome 中有一个特别的消息队列(延迟队列,HashMap 结构),其中维护了需要延迟执行的任务列表(包括定时器和内部一些需要延迟执行的任务)。

当通过调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数、当前发起时间、延迟执行时间,再将该任务添加到延迟执行队列中。

1
2
3
4
5
6
7
8
9
10
11
12
struct DelayTask{
int64 id;
CallBackFunction cbf;
int start_time;
int delay_time;
};
DelayTask timerTask;
timerTask.cbf = showName;
timerTask.start_time = getCurrentTime(); //获取当前时间
timerTask.delay_time = 200;//设置延迟执行时间

delayed_incoming_queue.push(timerTask); // 入队列

事件循环的过程中,每处理完其他队列中的一个任务之后,就会清一次延迟队列(根据发起时间和延迟时间计算是否到期,然后依次执行到期的任务)。

到期的任务执行完成之后,再继续下一个循环过程,这样一个完整的定时器就实现了。

clearTimout 的实现非常简单,直接从队列中删除 ID 对应的计时器。

1
clearTimeout(timer_id)

计时器的细节:

  1. Chrome 中定时器被嵌套调用 5 次以上且定时器时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒,系统判断该函数被阻塞
  2. 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
  3. Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值,如果设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出

XHR

xhr 的运行机制:当给 xhr 注册好回调函数,通过 xhr.send 即可发送网络请求。渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。

显示器的显示

显示器显示的每一帧可以看作是一张图片,图片来自于显卡中一个叫前缓冲区的地方,显示器每秒读取固定几次前缓冲区中的图像。

每个显示器都有固定的刷新频率,一般的屏幕刷新率为 60HZ,也就是每秒读取 60 次缓冲区。

帧: 显示器显示的一张图片就是一帧。

帧率: 每秒更新帧的数量称为帧率。

显卡的职责

显卡的职责就是合成新的图像,并将图像保存到后缓冲区中,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到最新的图像。

通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。

每一帧的渲染

如果浏览器要更新页面,需要将新生成的图片提交到显卡的后缓冲区中,后缓冲区和前缓冲区互换位置,显示器下次能读取到 GPU 中最新的图片。

浏览器需要保证每一帧的提交和显示器的读取保持同步,不然会造成动画的不流畅。

当显示器将一帧画面绘制完成后,并在准备读取下一帧之前,显示器会发出一个垂直同步信号给 GPU,简称 VSync。当 GPU 接收到 VSync 信号后,会将 VSync 信号同步给浏览器进程,浏览器进程再将其同步到对应的渲染进程,渲染进程接收到 VSync 信号之后,就可以准备绘制新的一帧了。

任务优先级

渲染进程会顺序地从消息队列头部取出任务并依次执行。最初采用这种方式没有太大的问题,随着浏览器的升级,这些变化让渲染进程所需要处理的任务变多了,对应的渲染进程的主线程也变得越拥挤。

在单消息队列架构下,存在着低优先级任务会阻塞高优先级任务的情况。

Chromium 当前所采用的任务调度策略是为不同类型的任务创建不同优先级的队列,在不同的阶段,动态调整消息队列的优先级。

  • 输入事件的消息队列,用来存放输入事件。
  • 合成任务的消息队列,用来存放合成事件。
  • 默认消息队列,用来保存如资源加载的事件和定时器回调等事件。
  • 创建一个空闲消息队列,用来存放 V8 的垃圾自动垃圾回收这一类实时性不高的事件。

页面加载阶段

在页面加载阶段的场景,需要尽可能快的看到页面,交互和合成并不是这个阶段的核心诉求,因此将页面解析、脚本执行等任务调整为优先级最高的队列,降低交互合成这些队列的优先级。

用户交互阶段

在用户交互的任务时,将合成任务的优先级调整到最高,处理完成 DOM,计算好布局让合成线程进入工作状态,就可以把下个合成任务的优先级调整为最低,并将其他任务优先级提升。

如果当前合成操作执行的非常快,那么从合成结束到下个 VSync 周期内,就进入了一个空闲时间阶段,在这段空闲时间内可以执行一些不那么紧急的任务,比如 V8 的垃圾回收,或者通过 requestIdleCallback 设置的回调任务等。