浏览器架构

没有关于如何构建 Web 浏览器的标准规范,一种浏览器的方法可能与另一种完全不同。

单进程

在 2007 年之前,市面上浏览器都是单进程的,单进程浏览器的所有功能模块都运行在同一个进程的不同线程里

单进程浏览器存在的问题:

  1. 不稳定:一个线程的意外崩溃会引起整个浏览器进程的崩溃
  2. 不流畅:所有页面的渲染、JavaScript 执行以及插件都运行在同一个线程中,这就意味着同一时刻只能有一个模块可以执行
  3. 不安全:插件可以使用 C/C++ 等代码编写,通过插件可以获取到操作系统的任意资源

多进程

2008 年 Chrome 发布时的进程架构:

https://static001.geekbang.org/resource/image/cd/60/cdc9215e6c6377fc965b7fac8c3ec960.png

每个页面都运行在单独的渲染进程中的,同时页面里的插件也是运行在单独的插件进程之中,进程之间通过 IPC 机制进行通信

Chrome 最新的进程架构:

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

沙箱模式:沙箱模式就是让该进程对于操作系统的某些权限受到限制,例如读取文件,操作系统提供了一种方法来限制进程的权限

渲染进程的数量:

  1. 默认策略是一个标签页对应一个渲染进程
  2. Chrome 限制了它可以启动的进程数,当达到限制时,它会开始在一个进程中运行来自同一站点的多个选项卡
  3. 如果页面里有 iframe 的话,iframe 也会运行在单独的进程中
  4. process-per-site-instance:从当前页面打开的新页面是同一站点时,那么新页面会复用父页面的渲染进程

多进程架构的好处:

  1. 每个选项卡都有自己的渲染进程,一个页面崩溃不会影响其他页面的正常工作
  2. 可以将某些进程运行在沙箱环境下,具有更好的安全性
  3. process-per-site-instance:相同站点的页面共享一个渲染进程可以共享一些数据,同一家的站点是存在这个需求的

站点隔离:

为每个跨站点 iframe 运行单独的渲染进程,它从根本上改变了 iframe 相互通信的方式,即使运行简单的 Ctrl+F 来查找页面中的单词也意味着在不同的渲染器进程中进行搜索

消息队列

页面中的大部分任务都是在主线程上执行的,任务的来源不仅仅是渲染进程内部,很多的任务都来自于其他进程

为了让渲染主线程能够有条不紊的执行各种任务,浏览器通过消息队列事件循环来实现任务的调度

渲染进程中有一个 IO 线程专门接收其他进程/线程传递的信息,并将它们组装成任务放入消息队列

渲染主线程只需要源源不断地从消息队列中取出任务并执行即可

消息队列是用来实现事件循环的线程模型,当然消息队列可以不只有一个,为了实现任务的优先级调度现在一般会有多个

任务类型

消息队列中的任务类型有很多,执行 js 只是其中的一小部分:

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

chromium 源码中可以看到所有的任务类型

事件循环

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

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

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

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

系统调用栈

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

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

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

异步

页面中任务都是执行在主线程之上的,相对于页面来说,主线程就是它整个的世界

所以在需要执行一项耗时的任务时,这些任务都会放到页面主线程之外的进程或者线程中去执行,这样就避免了耗时任务阻塞主线程

当外部的进程处理完这个任务后,会将该任务添加到渲染进程的消息队列中,并排队等待循环系统的处理

排队结束之后,主线程会取出消息队列中的任务进行处理,并触发相关的回调操作

定时器

所有需要运行在主线程上的任务都需要先添加到消息队列,然后主线程源源不断的从消息队列中取出任务并执行:

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

通过定时器设置回调函数需要在指定的时间间隔内被调用,所以不能将定时器的回调函数放入一般的队列中,优先级太低。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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); // 入队列

事件循环的过程中,每处理完其他队列中的一个任务之后就会清一次延迟队列,依次执行到期的任务。

1
2
3
4
5
6
7
8
9
void MainTherad(){ 
where(true){
Task task = task_queue.takeTask();
//执行消息队列中的任务
ProcessTask(task);
//执行延迟队列中的任务
ProcessDelayTask();
}
}

clearTimout 的实现非常简单,直接从 HashMap 中删除 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 即可发送网络请求

  1. 渲染进程会将请求发送给网络进程,网络进程负责向服务器请求数据
  2. 网络进程接收到服务器返回的数据之后,利用 IPC 通知渲染进程
  3. 渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中
  4. 主线程事件循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数

微任务

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

  1. 当宏任务执行过程中产生了微任务,就会将该微任务添加到与之关联的微任务队列
  2. 在该宏任务执行完成之后会先清空微任务队列再执行下一个宏任务
  3. 在清空微任务队列的过程中产生的微任务依旧会加入微任务队列

微任务的意义:

宏任务随时有可能被添加到消息队列中,JavaScript 控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。

宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了

微任务是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务完全结束之前

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

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

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

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

任务优先级

在单消息队列架构下,渲染进程顺序地从消息队列头部取出任务并依次执行,最初采用这种方式没有太大的问题

随着浏览器的升级,渲染进程所需要处理的任务变多,对应的主线程也变得拥挤,也就出现了低优先级任务会阻塞高优先级任务的情况

因为在页面中不同的任务的优先级其实是不相同的,而单消息队列是不存在优先级这一回事的

比如在一些性能不高的手机上,有时候滚动页面需要等待一秒以上

优先级队列

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

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

不同阶段

因为在页面的不同阶段,用户的最高诉求是不相同的:

  • 在加载阶段,用户需要的是能短的时间内看到页面,交互并不是这个阶段的核心诉求
    • 页面解析,JavaScript 脚本执行等任务调整为优先级最高的队列
    • 降低交互合成这些队列的优先级
  • 在交互阶段,用户需要浏览器能够快速进行反馈,也就是快速更新页面、动画要流畅
    • 将合成任务的优先级调整到最高
    • 当合成线程进入工作状态,就可以把下个合成任务的优先级调整为最低,并将页面解析、定时器等任务优先级提升
  • 在空闲阶段,可以执行一些不那么紧急的任务,比如 V8 的垃圾回收、window.requestIdleCallback 的回调
    • 如果当前合成操作执行的非常快,从合成结束到下个 VSync 周期内,就进入了一个空闲阶段
    • 在空闲阶段就可以执行一些不那么紧急的任务