Chrome 的多进程架构
进程 & 线程
在谈浏览器多进程架构之前,我们先聊聊进程和线程的概念。
进程是系统进行资源调度和分配的的基本单位,一个进程可以认为是一个程序的运行实例。启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。
线程是依附于进程的,是操作系统可识别的最小执行和调度单位,而进程中使用多线程并行处理能提升运算效率,多线程可以并行处理任务,但是线程是不能单独存在的,它是由进程来启动和管理的。我们最熟悉的 JS 的运行就是线程这个维度。
下面是进程和线程间的一些区别:
- 一个线程依附于一个进程,一个进程可以有多个线程;
- 线程之间共享进程中的数据。
- 进程中的任意一线程执行出错,都会导致整个进程的崩溃,而进程之前不会相互影响。
- 当一个进程关闭之后,操作系统会回收进程所占用的内存。
- 进程之间的内容相互隔离,只能通过 IPC 通信。
我觉得知乎上一个用火车比喻进程和线程的例子比较形象:进程好比火车,线程好比车厢,一辆火车有多节车厢,不同的火车之间互不干扰,但是一节车厢失火会殃及多节车厢。
Chrome 架构
Chrome 浏览器包括:1 个 Browser 主进程、1 个 GPU 进程、1个 Utility 进程、多个 Renderer 进程和多个 Plugin 进程。
- Browser 主进程(浏览器进程):主要负责界面显示、用户交互、子进程管理,同时还包含网络请求和文件访问等。
- GPU 进程:与其他进程隔离处理 GPU 任务。
- Renderer 进程(渲染进程):核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
- Plugin 进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
浏览器渲染机制
渲染进程的核心工作是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页。在这个工作过程中,输入的 HTML 经过一些子阶段,最后输出像素。按照渲染的时间顺序,这些子阶段大致可分为:构建 DOM 树、计算样式、布局、分层、绘制、分块、栅格化和合成。
构建 DOM 树
当渲染进程开始接收 HTML 数据时,主线程开始解析 HTML 并将其转换为浏览器能够理解的 DOM 树结构。
在 DOM 树的解析过程中,如果遇到 img、css 或者 js 资源时,主线程会向 Browser 主进程的网络线程发送请求以获取对应的资源。
当解析的过程中遇到 标签时,主线程会暂停 HTML 的解析,从而进行 js 代码的加载、解析和执行。因为 js 代码中可能涉及对页面结构的修改,主线程必须等待 js 运行才能恢复对 HTML 文档的解析。因此我们可以通过在
标签上加上 async 或者 defer 属性来异步加载执行 js 代码,避免 js 阻塞 HTML 的解析。
样式计算
样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠两个规则,这个阶段大体可分为三步来完成:
- 把 CSS 转换为浏览器能够理解的结构:当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——StyleSheets;
- 转换样式表中的属性值,使其标准化:例如 rem 这些属性,需要将所有值转换为渲染引擎容易理解的、标准化的计算值;
- 计算出 DOM 树中每个节点的具体样式
在浏览器中,我们可以通过 Computed 面板查看当前节点的 Computed Style。
布局
有了 DOM 树和 DOM 对应的 Computed Style 之后还不足以显示页面,接下来还需要计算出 DOM 树中可见元素的几何位置,这个计算过程叫做布局。
Chrome 在布局阶段需要完成两个任务:创建布局树和布局计算。
- 创建布局树:浏览器会遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中,而不可见的节点会被布局树忽略掉,如图中 span 这个元素被设置为 dispaly:none,这个元素会被布局树忽略;
- 布局计算:有了布局树后,浏览器会计算布局节点的坐标位置;
分层
有了布局树后,对于一些简单页面已经具备绘制条件了,但是对于我们现代的页面来说,有很多复杂的效果,如一些复杂的 3D 转换、 z-index 做 z 轴排序等,对于这些场景为了页面展示的正确性,渲染引擎还会为特定的节点生成专用的图层,并生成一棵对应的图层树。
需要注意的是,并不是每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点所在的图层。最终每一个节点都会直接或者间接地从属于一个图层。
通常满足下面两点便会提升为一个单独的图层:
- 拥有层叠上下文属性的元素会被提升为单独的一层。层叠上下文
- 需要剪裁的地方也会被创建为图层(overflow)。
我们可以在浏览器的 layers 面板看到当前页面的分层情况:
绘制
在确定了 DOM 树、计算样式以及布局树仍然不足以绘制页面,这里还需要有明确的绘制顺序,在此过程中主线程会遍历布局树并创建绘制记录。
同样,我们可以在浏览器的 layers 面板看到当前页面对应图层的绘制记录:
光栅化(栅格化)
在确定了布局树并创建了图层以及对应的绘制顺序之后,主线程会将信息提交给合成线程。合成线程会去光栅化每一个图层。
由于我们浏览器的视口是有限的,但是页面的长度可能很长,有些图层可能超过视口很多,而用户对页面的感知是视口维度的,一次性渲染整个图层未免有些浪费,因此合成线程会对图层进行分块处理。
有了图块之后,合成线程会将每一个图块发送到光栅线程(Raster thread),光栅线程会光栅化每一个图块并存在 GPU 内存中。在这个过程中,合成线程会优先选择视口内的图块提交给光栅线程。
合成和展示
光栅化完成后,合成线程会创建合成帧通过 IPC 通信提交给浏览器进程。浏览器进程接收到指令后会将内容绘制在内存中并展示在屏幕上。
至此,从接收 HTML 数据到页面的展示全流程就结束了。下面我们再结合浏览器的渲染流程看下什么是重排、重绘和合成。
重排、重绘和直接合成
重排
当我们通过 js 或者 css 属性更新了元素的几何属性,例如元素的宽度、高度,此时浏览器会重新触发布局并重新执行后面全部的渲染流程,因此,重排的开销是最大的。
重绘
当我们通过 js 或者 css 更新元素的绘制属性,例如元素的背景色、文字的颜色等,此时布局和分层阶段被省略,只执行后续的流程,因此重绘的开销相比重排会小很多。
直接合成
为什么我们为了避免重排和重绘而去采用 css3 的 transform 等属性呢?因为此时整个主线程的流程会被全部跳过,执行后续的流程,而后续的流程交给了在执行线程、光栅线程和 GPU 进程上执行没有占据主线程的资源,因此效率是最高的。