1 Like
浏览器架构的前生今世

浏览器架构的前生今世

189 PV1 Like浏览器架构
这篇文章是李兵老师《浏览器工作原理与实践》中的一个章节. 其他章节由于以前文章都写过了, 所以那些章节的笔记都补充到以前写过的文章. 由于浏览器架构这块比较独立, 遂单独成文.

浏览器的多进程架构

可以做一个实验, 仅仅在 Chrome 中打开一个 tab 页面, 然后点击 Chrome 右上角更多 -> 更多工具 -> 任务管理器. 你会发现, 仅仅打开一个页面, 万恶的 Chrome 却打开了 4 个进程.

Chrome 的任务管理器
Chrome 的任务管理器

复习下进程和线程好了

先说下什么是并行, 计算机中的并行处理就是同一时刻处理多个任务, 比如我们要计算下面这三个表达式的值, 并显示出结果.

let a = 1 + 2; let b = 20 / 5; let c = 7 * 8; println!("a: {} b: {} c: {}", a, b, c);

这段代码每句话都是一个任务, 如果是单线程的话, 那就得分四步按照顺序分别执行这四个任务; 如果是多线程的话, 可用用三个线程同时执行前三个任务, 再执行第四个显示任务. 可见多线程只需要执行两步, 而单线程需要四步, 使用并行处理能大大提升性能.

接下来说下线程和进程:

多线程可以并行处理任务, 但是线程是不能单独存在的, 它是由进程来启动和管理的. 一个进程就是一个程序的运行实例. 详细解释就是, 启动一个程序的时候, 操作系统会为该程序创建一块内存, 用来存放代码, 运行中的数据和一个执行任务的主线程, 我们把这样的一个运行环境叫进程.

单线程与多线程的进程对比图
单线程与多线程的进程对比图

从图中可以看出, 线程是依附于进程的, 而进程中使用多线程并行处理能提升运算效率.

进程和线程之间的关系有以下 4 个特点:

一. 进程中的任意一线程执行出错, 都会导致整个进程的崩溃. 比如下面这个例子, 在执行到 20 / 0 时, 由于分母为 0, 线程会执行出错, 这样就会导致整个进程的崩溃, 当然另外两个线程执行的结果也没有了.

let a = 1 + 2; let b = 20 / 0; let c = 7 * 8;

二. 线程之间共享进程中的数据. 即线程之间可以对进程的公共数据进行读写操作. 从上图可以看出, 线程 1, 线程 2, 线程 3 分别把执行的结果写入 A, B, C 中, 然后线程 2 继续从 A, B, C 中读取数据, 用来显示执行结果.

线程之间共享进程中的数据示意图
线程之间共享进程中的数据示意图

三. 当一个进程关闭之后, 操作系统会回收进程所占用的内存. 当一个进程退出时, 操作系统会回收该进程所申请的所有资源; 即使其中任意线程因为操作不当导致内存泄漏, 当进程退出时, 这些内存也会被正确回收.

四. 进程之间的内容相互隔离. 进程隔离是为保护操作系统中进程互不干扰的技术, 每一个进程只能访问自己占有的数据, 也就避免出现进程 A 写入数据到进程 B 的情况. 正是因为进程之间的数据是严格隔离的, 所以一个进程如果崩溃了, 或者挂起了, 是不会影响到其他进程的. 如果进程之间需要进行数据的通信, 这时候, 就需要使用用于进程间通信(IPC, Inter Process Communication)的机制了. 比如 Electron 就有两个进程, 分别是 Main 主进程和 Renderer 渲染进程, 两者的通信也是需要用到 IPC.

单进程浏览器

在 2007 年之前, 市面上浏览器都是单进程的. 顾名思义, 单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里, 这些模块包含了网络, 插件, JavaScript 运行环境, 渲染引擎和页面等.

单进程浏览器示意图
单进程浏览器示意图

如此多的功能模块运行在一个进程里, 是导致单进程浏览器不稳定, 不流畅和不安全的一个主要因素.

一. 不稳定. 早期浏览器需要借助于插件(比如 Flash)来实现诸如 Web 视频, Web 游戏等各种强大的功能, 但是插件是最容易出问题的模块, 并且还运行在浏览器进程之中, 所以一个插件的意外崩溃会引起整个浏览器的崩溃. 除了插件之外, 渲染引擎模块也是不稳定的, 通常一些复杂的 JavaScript 代码就有可能引起渲染引擎模块的崩溃. 和插件一样, 渲染引擎的崩溃也会导致整个浏览器的崩溃.

二: 不流畅. 所有页面的渲染模块, JavaScript 执行环境以及插件都是运行在同一个线程中的, 这就意味着同一时刻只能有一个模块可以执行. 可想而知, 如果丢进去一个死循环代码, 会导致整个浏览器失去响应. 此外, 除了上述脚本或者插件会让单进程浏览器变卡顿外, 页面的内存泄漏也是单进程变慢的一个重要原因. 单线程的页面在关闭的时候, 由于它还是在这个线程呢, 就有可能导致内存没回收干净, 从而导致内存泄漏.

三: 不安全. 插件体系可以获取到操作系统的任意资源, 当你在页面运行一个插件时也就意味着这个插件能完全操作你的电脑. 如果是个恶意插件, 那么它就可以释放病毒, 窃取你的账号密码, 引发安全性问题.

早期的多进程浏览器

在 2008 年 Chrome 发布时, 从下面的图可以看出, 页面是运行在单独的渲染进程中的, 同时页面里的插件也是运行在单独的插件进程之中, 而进程之间是通过 IPC 机制进行通信(虚线部分).

早期的多进程浏览器
早期的多进程浏览器

首先, 由于进程是相互隔离的, 所以当一个页面或者插件崩溃时, 影响到的仅仅是当前的页面进程或者插件进程, 并不会影响到浏览器和其他页面, 这就完美地解决了页面或者插件的崩溃会导致整个浏览器崩溃, 也就是不稳定的问题.

其次, JavaScript 也是运行在渲染进程中的, 所以即使 JavaScript 阻塞了渲染进程, 影响到的也只是当前的渲染页面, 而并不会影响浏览器和其他页面, 因为其他页面的脚本是运行在它们自己的渲染进程中的. 所以当我们再在 Chrome 中运行上面那个死循环的脚本时, 没有响应的仅仅是当前的页面. 对于内存泄漏的解决方法那就更简单了, 因为当关闭一个页面时, 整个渲染进程也会被关闭, 之后该进程所占用的内存都会被系统回收, 这样就轻松解决了浏览器页面的内存泄漏问题.

最后, 采用多进程架构的额外好处是可以使用安全沙箱, Chrome 把插件进程和渲染进程锁在沙箱里面, 这样即使在渲染进程或者插件进程里面执行了恶意程序, 恶意程序也无法突破沙箱去获取系统权限. 而单线程浏览器只有一个主进程, 如果使用了安全沙箱, 主进程对于操作系统的权限就会受到限制, 比如不能对一些位置的文件进行读写操作. 而这些权限浏览器主进程所需要的, 所以安全沙箱是不能应用到浏览器主进程之上的.

现在的多线程浏览器

现在的多线程浏览器
现在的多线程浏览器

最新的 Chrome 浏览器包括: 1 个浏览器(Browser)主进程, 1 个 GPU 进程, 1 个网络(NetWork)进程, 多个渲染进程和多个插件进程.

  • 浏览器进程. 主要负责界面显示, 用户交互, 子进程管理, 同时提供存储等功能.
  • 渲染进程. 核心任务是将 HTML, CSS 和 JavaScript 转换为用户可以与之交互的网页, 排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中, 默认情况下, Chrome 会为每个 Tab 标签创建一个渲染进程. 出于安全考虑, 渲染进程都是运行在沙箱模式下.
  • GPU 进程. 其实, Chrome 刚开始发布的时候是没有 GPU 进程的. 而 GPU 的使用初衷是为了实现 3D CSS 的效果, 只是随后网页, Chrome 的 UI 界面都选择采用 GPU 来绘制, 这使得 GPU 成为浏览器普遍的需求. 最后, Chrome 在其多进程架构上也引入了 GPU 进程.
  • 网络进程. 主要负责页面的网络资源加载, 之前是作为一个模块运行在浏览器进程里面的, 直至最近才独立出来, 成为一个单独的进程.
  • 插件进程. 主要是负责插件的运行, 因插件易崩溃, 所以需要通过插件进程来隔离, 以保证插件进程崩溃不会对浏览器和页面造成影响.

这也就回答了开头的问题, 仅仅打开了 1 个页面, 为什么有 4 个进程?因为打开 1 个页面至少需要 1 个网络进程, 1 个浏览器进程, 1 个 GPU 进程以及 1 个渲染进程(一般每个页面就占一个渲染进程), 共 4 个; 如果打开的页面有运行插件的话, 还需要再加上 1 个插件进程. 下面是一些归纳:

  1. 如果页面里有 iframe 的话, iframe 也会运行在单独的进程中
  2. 如果页面里有插件, 同样插件也需要开启一个单独的进程
  3. 如果你装了扩展的话, 扩展也会占用进程
  4. 如果 2 个页面属于同一站点(同一站点指同一级域名)的话, 并且从 a 页面中打开的 b 页面, 那么他们会公用一个渲染进程

不过凡事都有两面性, 虽然多进程模型提升了浏览器的稳定性, 流畅性和安全性, 但同样不可避免地带来了一些问题:

  • 更高的资源占用. 因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境), 这就意味着浏览器会消耗更多的内存资源.
  • 更复杂的体系架构. 浏览器各模块之间耦合性高, 扩展性差等问题, 会导致现在的架构已经很难适应新的需求了.

未来的浏览器架构

为了解决资源占用高和复杂的体系架构问题. Chrome 官方团队使用面向服务的架构(Services Oriented Architecture, 简称 SOA)的思想设计了新的 Chrome 架构. 原来的各种模块会被重构成独立的服务(Service), 每个服务(Service)都可以在独立的进程中运行, 访问服务(Service)必须使用定义好的接口, 通过 IPC 来通信, 从而构建一个更内聚, 松耦合, 易于维护和扩展的系统, 更好实现 Chrome 简单, 稳定, 高速, 安全的目标.

未来的浏览器架构
未来的浏览器架构

同时 Chrome 还提供灵活的弹性架构, 在强大性能设备上会以多进程的方式运行基础服务, 但是如果在资源受限的设备上, Chrome 会将很多服务整合到一个进程中, 从而节省内存占用.

安全沙箱

我们知道浏览器被划分为浏览器内核和渲染内核两个核心模块, 其中浏览器内核是由网络进程,浏览器主进程和 GPU 进程组成的, 渲染内核就是渲染进程. 所有的网络资源都是通过浏览器内核来下载的, 下载后的资源会通过 IPC 将其提交给渲染进程. 然后渲染进程会对这些资源进行解析,绘制等操作, 最终生成一幅图片. 但是渲染进程并不负责将图片显示到界面上, 而是将最终生成的图片提交给浏览器内核模块, 由浏览器内核模块负责显示这张图片.

浏览器内核和渲染进程
浏览器内核和渲染进程

从上图可以看出, 渲染进程被放到了安全沙箱. 我们知道由于渲染进程需要执行 DOM 解析,CSS 解析,网络图片解码等操作, 如果渲染进程中存在系统级别的漏洞, 那么以上操作就有可能让恶意的站点获取到渲染进程的控制权限, 进而又获取操作系统的控制权限, 这对于用户来说是非常危险的.

此外, 因为网络资源的内容存在着各种可能性, 所以浏览器会默认所有的网络资源都是不可信的, 都是不安全的. 但谁也不能保证浏览器不存在漏洞, 只要出现漏洞, 黑客就可以通过网络内容对用户发起攻击.

但是呢, 如果你下载了一个恶意程序, 但是没有执行它, 那么恶意程序是不会生效的. 同理, 浏览器之于网络内容也是如此, 浏览器可以安全地下载各种网络资源, 但是如果要执行这些网络资源, 比如解析 HTML,解析 CSS,执行 JavaScript,图片编解码等操作, 就可能会产生漏洞. 因此, 浏览器将渲染进程放进了安全沙箱, 这就保证了和操作系统的隔离.

浏览器中的安全沙箱是利用操作系统提供的安全技术, 让渲染进程在执行过程中无法访问或者修改操作系统中的数据, 在渲染进程需要访问系统资源的时候, 需要通过浏览器内核来实现, 然后将访问的结果通过 IPC 转发给渲染进程.

浏览器内核和渲染进程各自职责
浏览器内核和渲染进程各自职责

上图描述了浏览器内核和渲染进程各自职责, 让我们逐一分析下:

持久存储

由于安全沙箱需要负责确保渲染进程无法直接访问用户的文件系统, 但是在渲染进程内部有访问 Cookie 的需求,有上传文件的需求, 所以现代浏览器将读写文件的操作全部放在了浏览器内核中实现, 然后通过 IPC 将操作结果转发给渲染进程.

  • 存储 Cookie 数据的读写. 通常浏览器内核会维护一个存放所有 Cookie 的 Cookie 数据库, 然后当渲染进程通过 JavaScript 来读取 Cookie 时, 渲染进程会通过 IPC 将读取 Cookie 的信息发送给浏览器内核, 浏览器内核读取 Cookie 之后再将内容返回给渲染进程.
  • 一些缓存文件的读写也是由浏览器内核实现的, 比如网络文件缓存的读取.

网络访问

在渲染进程内部也是不能直接访问网络的, 如果要访问网络, 则需要通过浏览器内核. 不过浏览器内核在处理 URL 请求之前, 会检查渲染进程是否有权限请求该 URL, 比如检查 XMLHttpRequest 或者 Fetch 是否是跨站点请求, 或者检测 HTTPS 的站点中是否包含了 HTTP 的请求.

用户交互

在现代浏览器中, 由于每个渲染进程都有安全沙箱的保护, 所以在渲染进程内部是无法直接操作窗口句柄的, 这也是为了限制渲染进程监控到用户的输入事件. 因此:

渲染进程需要渲染出位图. 为了向用户显示渲染进程渲染出来的位图, 渲染进程需要将生成好的位图发送到浏览器内核, 然后浏览器内核将位图复制到屏幕上. 操作系统没有将用户输入事件直接传递给渲染进程, 而是将这些事件传递给浏览器内核. 然后浏览器内核再根据当前浏览器界面的状态来判断如何调度这些事件, 如果当前焦点位于浏览器地址栏中, 则输入事件会在浏览器内核内部处理; 如果当前焦点在页面的区域内, 则浏览器内核会将输入事件转发给渲染进程.

站点隔离

所谓站点隔离(Site Isolation)是指 Chrome 将同一站点(包含了相同根域名和相同协议的地址)中相互关联的页面放到同一个渲染进程中执行.

最开始 Chrome 划分渲染进程是以标签页为单位, 也就是说整个标签页会被划分给某个渲染进程. 但每个标签里可能嵌入了 N 个不同的三方 iframe. 这些 iframe 又有可能来自于不同的站点, 这就导致了多个不同站点中的内容通过 iframe 同时运行在同一个渲染进程中.

目前所有操作系统都面临着两个 A 级漏洞——幽灵(Spectre)和熔毁(Meltdown), 这两个漏洞是由处理器架构导致的, 很难修补, 黑客通过这两个漏洞可以直接入侵到进程的内部, 如果入侵的进程没有安全沙箱的保护, 那么黑客还可以发起对操作系统的攻击.

所以如果一个银行站点包含了一个恶意 iframe, 然后这个恶意的 iframe 利用这两个 A 级漏洞去入侵渲染进程, 那么恶意程序就能读取银行站点渲染进程内的所有内容了, 这对于用户来说就存在很大的风险了.

因此 Chrome 几年前就开始重构代码, 将标签级的渲染进程重构为 iframe 级的渲染进程, 然后严格按照同一站点的策略来分配渲染进程, 这就是 Chrome 中的站点隔离. 实现了站点隔离, 就可以将恶意的 iframe 隔离在恶意进程内部, 使得它无法继续访问其他 iframe 进程的内容, 因此也就无法攻击其他站点了.

如下这个例子, iframe.html 和源标签页属于同一站点, 所以它会和源标签页运行在同一个渲染进程中. 其余三个 iframe 各会生成一个渲染进程.

<head> <title>站点隔离:demo</title> <style> iframe { width: 800px; height: 300px; } </style> </head> <body> <div><iframe src="iframe.html"></iframe></div> <div><iframe src="https://a.com/"></iframe></div> <div><iframe src="https://b.com/"></iframe></div> <div><iframe src="https://c.com"></iframe></div> </body> </html>

任务的动态调度策略

我们知道, 在单消息队列架构下, 存在着低优先级任务会阻塞高优先级任务的情况, 也就是说会发生单消息队列的队头阻塞问题. 因此 Chhrome 提供多条队列, 给予不同的优先级来执行.

但这种也会有一个问题, 因为大多数任务需要保持其相对执行顺序, 比如说我们将用户输入的消息或者合成消息添加进多个不同优先级的队列中, 那么这种任务的相对执行顺序就会被打乱, 甚至有可能出现还未处理输入事件, 就合成了该事件要显示的图片. 因此 Chrome 根据消息类型来实现消息队列, 比如:

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

但这种策略是静态的, 也就是在任何情况下都是输入事件优先, 其次是合成任务... 而我们知道, 页面大致的生存周期大体分为两个阶段, 加载阶段和交互阶段. 虽然在交互阶段, 采用上述这种静态优先级的策略没有什么太大问题的, 但是在页面加载阶段, 如果依然要优先执行用户输入事件和合成事件, 那么页面的解析速度将会被拖慢.

因此, 最终我们有了任务的动态调度策略, 可以看下图.

动态调度策略
动态调度策略

在页面加载阶段的场景, 用户的最高诉求是在尽可能短的时间内看到页面, 至于交互和合成并不是这个阶段的核心诉求, 因此我们需要调整策略, 在加载阶段将页面解析, JavaScript 脚本执行等任务调整为优先级最高的队列, 降低交互合成这些队列的优先级.

在交互阶段, 我们在 [HTTP 系列] 第 6 篇 —— 从输入 URL 回车到页面呈现 这篇文章聊到过在显卡中有一块叫着前缓冲区的地方, 这里存放着显示器要显示的图像, 显示器会按照一定的频率来读取这块前缓冲区, 并将前缓冲区中的图像显示在显示器上, 不同的显示器读取的频率是不同的, 通常情况下是 60HZ, 也就是说显示器会每间隔 1/60 秒就读取一次前缓冲区.

如果浏览器要更新显示的图片, 那么浏览器会将新生成的图片提交到显卡的后缓冲区中, 提交完成之后, GPU 会将后缓冲区和前缓冲区互换位置, 也就是前缓冲区变成了后缓冲区, 后缓冲区变成了前缓冲区, 这就保证了显示器下次能读取到 GPU 中最新的图片.

这时候我们会发现, 显示器从前缓冲区读取图片, 和浏览器生成新的图像到后缓冲区的过程是不同步的.

VSync 时钟周期和渲染引擎生成图片不同步问题
VSync 时钟周期和渲染引擎生成图片不同步问题

这种显示器读取图片和浏览器生成图片不同步, 容易造成众多问题.

  • 如果渲染进程生成的帧速比屏幕的刷新率慢, 那么屏幕会在两帧中显示同一个画面, 当这种断断续续的情况持续发生时, 用户将会很明显地察觉到动画卡住了.
  • 如果渲染进程生成的帧速率实际上比屏幕刷新率快, 那么也会出现一些视觉上的问题, 比如当帧速率在 100fps 而刷新率只有 60Hz 的时候, GPU 所渲染的图像并非全都被显示出来, 这就会造成丢帧现象.
  • 就算屏幕的刷新频率和 GPU 更新图片的频率一样, 由于它们是两个不同的系统, 所以屏幕生成帧的周期和 VSync 的周期也是很难同步起来的.

为了解决这些问题, 就需要将显示器的时钟同步周期和浏览器生成页面的周期绑定起来, 当显示器将一帧画面绘制完成后, 并在准备读取下一帧之前, 显示器会发出一个垂直同步信号(vertical synchronization)给 GPU, 简称 VSync. 当 GPU 接收到 VSync 信号后, 会将 VSync 信号同步给浏览器进程, 浏览器进程再将其同步到对应的渲染进程, 渲染进程接收到 VSync 信号之后, 就可以准备绘制新的一帧了,

绑定 VSync 时钟同步周期和浏览器生成页面周期
绑定 VSync 时钟同步周期和浏览器生成页面周期

说了这么多, 目的就是要引出当在执行用户交互的任务时, 将合成任务的优先级调整到最高. 接下来, 处理完成 DOM, 计算好布局和绘制, 就需要将信息提交给合成线程来合成最终图片了, 然后合成线程进入工作状态. 现在的场景是合成线程在工作了, 那么我们就可以把下个合成任务的优先级调整为最低, 并将页面解析,定时器等任务优先级提升.

在合成完成之后, 合成线程会提交给渲染主线程提交完成合成的消息, 如果当前合成操作执行的非常快, 比如从用户发出消息到完成合成操作只花了 8 毫秒, 因为 VSync 同步周期是 16.66 毫秒, 那么这个 VSync 时钟周期内就不需要再次生成新的页面了. 那么从合成结束到下个 VSync 周期内, 就进入了一个空闲时间阶段, 那么就可以在这段空闲时间内执行一些不那么紧急的任务, 比如 V8 的垃圾回收, 或者通过 window.requestIdleCallback() 设置的回调任务等, 都会在这段空闲时间内执行.

虽然动态调度策略已经很完美了, 那就是在某个状态下, 一直有新的高优先级的任务加入到队列中, 这样就会导致其他低优先级的任务得不到执行, 这称为任务饿死. Chromium 为了解决任务饿死的问题, 给每个队列设置了执行权重, 也就是如果连续执行了一定个数的高优先级的任务, 那么中间会执行一次低优先级的任务, 这样就缓解了任务饿死的情况.

所以说, 采用 setTimeout 来触发动画每帧的绘制, 那么其绘制时机是很难和 VSync 时钟保持一致的, 所以 JavaScript 中又引入了 window.requestAnimationFrame, 用来和 VSync 的时钟周期同步, rAF 的回调任务会在每一帧的开始执行.

再扯远一点, 这不就是 React Scheduler 的核心原理嘛, 有兴趣可以阅读我的另一篇文章 深入"时间管理大师" —— React Scheduler

[HTTP 系列] 第 6 篇 —— 从输入 URL 回车到页面呈现

PREVIOUS POST

[HTTP 系列] 第 6 篇 —— 从输入 URL 回车到页面呈现

前端性能面面观

NEXT POST

前端性能面面观

    Search by