RenderingNG 架构

Chris Harrelson
Chris Harrelson

在这里,您将了解如何设置 RenderingNG 的组件,以及渲染管道如何流经这些组件。

从最高级别开始,渲染任务如下:

  1. 将内容渲染为屏幕上的像素。
  2. 为内容从一种状态到另一种状态添加动画视觉效果
  3. 滚动以响应输入。
  4. 高效地将输入路由到正确的位置,以便开发者脚本和其他子系统能够做出响应。

要呈现的内容是每个浏览器标签页的帧树,以及浏览器界面。以及来自触摸屏、鼠标、键盘和其他硬件设备的原始输入事件流。

每个帧都包含:

  • DOM 状态
  • CSS
  • 画布
  • 外部资源,例如图片、视频、字体和 SVG

框架是指 HTML 文档及其网址。在浏览器标签页中加载的网页具有顶级框架、顶级文档中包含的每个 iframe 的子框架,以及它们的递归 iframe 后代。

视觉效果是应用于位图的图形操作,例如滚动、转换、裁剪、滤镜、不透明度或混合。

架构组件

在 RenderingNG 中,这些任务在逻辑上被划分到多个阶段和代码组件中。组件最终会出现在各种 CPU 进程、线程以及这些线程中的子组件中。在确保所有 Web 内容的可靠性可扩缩性能可扩展性方面,每个选项都发挥着重要作用。

渲染流水线结构

渲染流水线的示意图。
箭头表示每个阶段的输入和输出。阶段用颜色进行标记,用于展示各阶段执行的线程或进程。在某些情况下,阶段可能会在多个位置执行,具体情况视情况而定,这就是为什么有些阶段有两种颜色的原因。绿色阶段是渲染进程主线程;黄色阶段是渲染进程合成器;橙色阶段是可视化进程。

渲染会在流水线中进行,并且在此过程中会创建许多阶段和工件。每个阶段都代表在渲染中执行一个明确定义的任务的代码。工件是作为阶段的输入或输出的数据结构

这些阶段包括:

  1. 动画:更改计算的样式,并根据声明式时间轴随时间改变属性树
  2. 样式:将 CSS 应用于 DOM,并创建计算出的样式
  3. 布局:确定 DOM 元素在屏幕上的大小和位置,并创建不可变 Fragment 树
  4. 预绘制:计算属性树,并根据情况使任何现有的显示列表和 GPU 纹理图块失效
  5. 滚动:通过更改属性树来更新文档和可滚动 DOM 元素的滚动偏移量。
  6. Paint:计算显示列表,描述如何从 DOM 中光栅 GPU 纹理图块。
  7. 提交:将属性树和显示列表复制到合成器线程。
  8. 分层:将显示列表拆分为一个合成图层列表,以便分别实现光栅化和动画。
  9. 光栅、解码和绘制 Worklet:将显示列表、编码图像和绘制 Worklet 代码分别转换为 GPU 纹理图块
  10. Activate:创建一个合成器帧,表示如何绘制 GPU 图块并将其放置到屏幕上,以及任何视觉效果。
  11. 汇总:将来自所有可见的合成器帧的合成器帧合并成一个全局合成器帧。
  12. 绘制:在 GPU 上执行聚合的合成器帧,在屏幕上创建像素。

可以跳过渲染流水线的阶段(如果不需要)。例如,视觉效果和滚动的动画可以跳过布局、预绘制和绘制。正因如此,此图中动画和滚动操作都标有黄点和绿点。如果对于视觉效果可以跳过布局、预绘制和绘制,那么它们可以完全在合成器线程上运行并跳过主线程。

此处并未直接介绍浏览器界面渲染,但可将其视为同一流水线的简化版本(实际上,它的实现共享了大部分代码)。视频(也未直接描绘)通常使用独立代码进行渲染,此类代码将帧解码为 GPU 纹理块,然后将其插入合成器帧和绘制步骤。

进程和线程结构

CPU 进程

使用多个 CPU 进程可以在网站和浏览器状态之间实现性能和安全性隔离,并实现与 GPU 硬件的稳定性和安全性隔离。

CPU 进程的各个部分的示意图

  • “渲染过程”会为单个网站和标签页组合渲染、以动画形式呈现、滚动和路由输入。存在多个渲染进程。
  • 浏览器进程会渲染浏览器界面(包括地址栏、标签页标题和图标)的输入、以动画方式呈现和路由输入,然后将所有剩余输入路由到相应的渲染进程。有一个浏览器进程
  • Viz 进程可聚合来自多个渲染进程和浏览器进程的合成。使用 GPU 进行光栅和绘制。有一个 Viz 进程

不同的网站始终会经过不同的渲染进程。

同一网站的多个浏览器标签页或窗口通常会进入不同的渲染进程,除非这些标签页具有相关性(例如其中一个标签页会打开另一个标签页)。在桌面设备上的内存很紧张的情况下,Chromium 可能会将同一网站上的多个标签页置于同一渲染进程中,即使这些标签页并不相关。

在单个浏览器标签页中,来自不同网站的帧始终处于不同的呈现进程中,但来自同一网站的帧始终处于同一呈现进程中。从呈现的角度来看,多个呈现流程的重要优势在于,跨网站 iframe 和标签页可以实现彼此性能隔离。此外,源站可以选择进一步隔离

所有 Chromium 都只有一个 Viz 进程,因为通常只有一个 GPU 和屏幕可供绘制。

面对 GPU 驱动程序或硬件中的 bug 时,将 Viz 拆分到自己的进程中有助于提高稳定性。它还有利于安全隔离,这对 GPU API(如 Vulkan)和总体安全性非常重要。

由于浏览器可以有多个标签页和窗口,并且所有标签页和窗口都有可供绘制的浏览器界面像素,因此您可能想知道:为什么只有一个浏览器进程? 原因在于一次只能聚焦其中一个浏览器;事实上,不可见的浏览器标签页多数会被停用,并舍弃所有 GPU 内存。不过,复杂的浏览器界面渲染功能也越来越多地在渲染进程中实现(称为 WebUI)。这并非出于性能隔离的原因,而是为了充分利用 Chromium 的网页渲染引擎的易用性。

在旧版 Android 设备上,在 WebView 中使用时,会共享渲染和浏览器进程(这不适用于通常适用于 Android 的 Chromium,仅适用于 WebView)。在 WebView 上,浏览器进程也会与嵌入应用共享,而 WebView 只有一个渲染进程。

有时还有一个实用程序进程,用于解码受保护的视频内容。前面的图表未描述此过程。

Threads

尽管任务速度缓慢、流水线并行化和多缓冲,但线程有助于实现性能隔离和响应能力。

渲染过程的示意图。

  • 主线程运行脚本、渲染事件循环、文档生命周期、命中测试、脚本事件分派,以及解析 HTML、CSS 和其他数据格式。
    • 主线程帮助程序可执行创建图片位图和 blob 等需要编码或解码的任务。
    • Web Worker 可以运行脚本以及 OffscreenCanvas 的渲染事件循环。
  • 合成器线程可处理输入事件,对 Web 内容执行滚动和动画操作,计算 Web 内容的最佳分层,并协调图像解码、绘制 Worklet 和光栅任务。
    • 合成器线程帮助程序可协调 Viz 光栅任务,并执行图片解码任务、绘制 Worklet 和后备光栅。
  • 媒体、多路复用器或音频输出线程:用于解码、处理和同步视频和音频流。 (请记住,视频与主渲染管道并行执行。)

将主线程和合成器线程分离对于将动画和滚动与主线程工作进行性能隔离至关重要。

每个渲染进程只有一个主线程,即使来自同一网站的多个标签页或帧最终可能出现在同一进程中,也是如此。不过,在各种浏览器 API 中执行的操作会隔离性能。例如,在 Canvas API 中生成图片位图和 blob 时,会在主线程帮助程序线程中运行。

同样,每个渲染进程只有一个合成器线程。通常不存在只有一个问题,因为合成器线程上的所有开销很高的操作都委托给合成器工作器线程或 Viz 进程,并且这项工作可以与输入路由、滚动或动画并行完成。合成器工作器线程用于协调在 Viz 进程中运行的任务,但无处不在的 GPU 加速可能会因 Chromium 无法控制的原因(例如驱动程序 bug)而失败。在这些情况下,工作线程将在 CPU 的回退模式下执行工作。

合成器工作器线程的数量取决于设备的功能。例如,桌面设备通常会使用较多线程,因为与移动设备相比,桌面设备具有更多 CPU 核心且耗电量低。这是一个纵向扩容和缩容的示例。

渲染进程线程架构是一个应用,其中包含三种不同优化模式:

  • 帮助程序线程:将长时间运行的子任务发送到其他线程,使父线程能够对其他并发请求做出响应。主线程帮助程序和合成器帮助程序线程就是这种方法的好例子。
  • 多重缓冲:在渲染新内容的同时显示之前渲染的内容,以隐藏渲染延迟。合成器线程会使用此方法。
  • 流水线并行化:在多个位置同时运行渲染流水线。这就是快速滚动和动画的原因;即使正在进行主线程渲染更新,滚动和动画也可以并行运行。

浏览器进程

浏览器进程示意图,显示了渲染和合成线程与渲染和合成线程帮助程序之间的关系。

  • 渲染和合成线程响应浏览器界面中的输入,将其他输入路由到正确的渲染进程;布局和绘制浏览器界面。
  • 渲染和合成线程帮助程序可执行图片解码任务以及回退光栅或解码。

浏览器进程的渲染和合成线程与渲染进程的代码和功能类似,不同之处在于主线程和合成器线程合并为一个。在这种情况下,只需要一个线程,因为不需要与冗长的主线程任务进行性能隔离,因为设计没有这个线程。

可视化进程

Viz 进程包含 GPU 主线程和显示合成器线程。

  • GPU 主线程光栅将列表和视频帧显示在 GPU 纹理图块中,并将合成器帧绘制到屏幕上。
  • 显示合成器线程将每个渲染进程(以及浏览器进程)的合成聚合到单个合成器帧中,以便呈现到屏幕上。

光栅和绘制通常发生在同一个线程上,因为它们都依赖于 GPU 资源,并且很难可靠地多线程使用 GPU(更轻松地对 GPU 进行多线程访问是开发新的 Vulkan 标准的一个原因)。在 Android WebView 上,由于 WebView 是如何嵌入到原生应用中的,因此有一个单独的操作系统级渲染线程用于绘制。其他平台将来可能会有此类线程。

显示合成器位于不同的线程上,因为它需要始终能迅速响应,并且不会因 GPU 主线程上任何可能的减速源而阻塞。GPU 主线程运行速度减慢的一个原因是调用非 Chromium 代码(例如供应商专用的 GPU 驱动程序),这些代码在难以预测的情况下可能运行缓慢。

组件结构

在每个渲染进程的主线程或合成器线程中,存在以结构化方式相互交互的逻辑软件组件。

渲染进程主线程组件

Blink 渲染程序的示意图。

在 Blink Renderer 中:

  • 本地框架树 fragment 表示本地框架的树和框架内的 DOM。
  • DOM API 和 Canvas API 组件包含所有这些 API 的实现。
  • 文档生命周期运行程序会执行渲染流水线步骤,一直到提交步骤(包括提交步骤)。
  • 输入事件命中测试和调度组件可执行命中测试以确定事件针对的是哪个 DOM 元素,并运行输入事件调度算法和默认行为。

呈现事件循环调度器和运行程序会决定在事件循环中运行哪些内容以及何时运行。它会安排按照与设备显示屏匹配的节奏进行渲染。

帧树的示意图。

本地帧树 fragment 有点复杂。回想一下,框架树是以递归方式呈现的主页面及其子 iframe。如果某个帧在某个渲染进程中渲染,该帧对在该进程中是本地的,否则是远程的。

您可以想象根据帧的渲染过程着色。在上图中,绿色圆圈表示一个渲染进程中的所有帧;橙色圆圈位于第二个渲染进程中,蓝色圆圈位于第三个渲染进程中。

本地框架树 fragment 是框架树中具有相同颜色的连接组件。图片中有四个本地框架树:网站 A、网站 B、网站 C 各一个。 每个本地帧树都有自己的 Blink 渲染程序组件。本地帧树的 Blink 渲染程序不一定与其他本地帧树处于同一渲染进程中。它取决于渲染进程的选择方式(如前所述)。

渲染进程合成器线程结构

显示渲染进程合成器组件的示意图。

渲染进程合成器组件包括:

  • 数据处理程序,用于维护合成的图层列表、显示列表和属性树。
  • 一个生命周期运行程序,用于运行渲染流水线的各个步骤,包括添加动画、滚动、合成、光栅,以及解码和激活步骤。(请注意,动画和滚动都可以在主线程和合成器中进行。)
  • 输入和点击测试处理程序以合成层的分辨率执行输入处理和点击测试,以确定滚动手势是否可以在合成器线程上运行,以及应该针对哪个渲染进程点击测试。

实际中的架构示例

本例中有三个标签页:

标签页 1:foo.com

<html>
  <iframe id=one src="foo.com/other-url"></iframe>
  <iframe  id=two src="bar.com"></iframe>
</html>

标签页 2:bar.com

<html>
 …
</html>

标签页 3:baz.com html <html> … </html>

这些标签页的进程、线程和组件结构如下所示:

标签页的过程示意图。

我们分别通过四个主要的渲染任务举例说明。提醒:

  1. 将内容渲染为屏幕上的像素。
  2. 为内容从一种状态到另一种状态添加动画效果
  3. 滚动以响应输入。
  4. 高效地将输入路由到正确的位置,以便开发者脚本和其他子系统能够做出响应。

渲染标签页 1 更改后的 DOM,请执行以下操作:

  1. 开发者脚本会在 foo.com 的渲染进程中更改 DOM。
  2. Blink 渲染程序会告知合成器它需要执行渲染。
  3. 合成器会告知 Viz 需要渲染。
  4. Viz 会向合成器发出“开始渲染”的信号。
  5. 合成器会将启动信号转发到 Blink 渲染程序。
  6. 主线程事件循环运行程序运行文档生命周期。
  7. 主线程将结果发送到合成器线程。
  8. 合成器事件循环运行程序会运行合成生命周期。
  9. 对于光栅任务,系统会将所有光栅任务都发送到 Viz(通常会有多项任务)。
  10. Viz 可在 GPU 上光栅显示内容。
  11. Viz 确认光栅任务已完成。 注意:Chromium 通常不会等待光栅操作完成,而是使用同步令牌,该令牌必须由光栅任务在执行第 15 步之前解析。
  12. 合成器帧会发送到 Viz。
  13. Viz 会为 foo.com 渲染进程、bar.com iframe 渲染进程和浏览器界面汇总合成器帧。
  14. Viz 安排了平局。
  15. Viz 将聚合的合成器帧绘制到屏幕上。

要为第 2 个标签页上的 CSS 转换过渡添加动画效果,请执行以下操作:

  1. bar.com 渲染进程的合成器线程通过更改现有的属性树,在其合成器事件循环中触发动画。然后,系统会重新运行合成器生命周期。(可能会发生光栅和解码任务,但此处并未说明)。
  2. 合成器帧会发送到 Viz。
  3. Viz 为 foo.com 渲染进程、bar.com 渲染进程和浏览器界面汇总了合成器帧。
  4. Viz 安排了平局。
  5. Viz 将聚合的合成器帧绘制到屏幕上。

要在第 3 个标签页上滚动网页,请执行以下操作:

  1. 一系列 input 事件(鼠标、触摸或键盘)进入浏览器进程。
  2. 每个事件都会路由到 baz.com 的渲染进程合成器线程。
  3. 合成器会确定主线程是否需要了解相应事件。
  4. 如有必要,该事件会发送到主线程。
  5. 主线程会触发 input 事件监听器(pointerdowntouchstarpointermovetouchmovewheel),以了解监听器是否会对事件调用 preventDefault
  6. 主线程会返回是否向合成器调用了 preventDefault
  7. 否则,系统会将输入事件发送回浏览器进程。
  8. 浏览器进程通过将其与其他近期事件结合,将其转换为滚动手势。
  9. 该滚动手势将再次发送到 baz.com 的渲染进程合成器线程,
  10. 滚动会应用到该处,并且 bar.com 渲染进程的合成器线程在其合成器事件循环中勾选动画。然后,这将更改属性树中的滚动偏移,并重新运行合成器生命周期。 还会告知主线程触发 scroll 事件(此处未介绍)。
  11. 合成器帧会发送到 Viz。
  12. Viz 会为 foo.com 渲染进程、bar.com 渲染进程和浏览器界面汇总合成器帧。
  13. Viz 安排了平局。
  14. Viz 将聚合的合成器帧绘制到屏幕上。

要为标签页 1 的 iframe #two 中的超链接路由 click 事件,请执行以下操作:

  1. 浏览器进程会发生 input 事件(鼠标、触摸或键盘)。它会执行一次近似命中测试,确定 bar.com iframe 呈现进程应该接收点击并将其发送到此处。
  2. bar.com 的合成器线程将 click 事件路由到 bar.com 的主线程,并安排渲染事件循环任务来处理该事件。
  3. 用于 bar.com 主线程命中测试的输入事件处理器,用于确定用户点击了 iframe 中的哪个 DOM 元素,并且会触发 click 事件以供脚本观察。如果听不到 preventDefault,则会转到超链接。
  4. 加载超链接的目标网页后,系统会呈现新状态,其步骤与前面的“呈现已更改的 DOM”示例类似。(这些后续更改未在此处介绍。)

外卖

可能需要大量时间才能记住和内化渲染的运作方式。

最重要的结论是,通过仔细地模块化和关注细节,渲染流水线已拆分为许多独立的组件。然后,这些组件已拆分到并行进程和线程之间,以最大限度地提高可扩缩性能可扩展性的机会。

在实现现代 Web 应用的性能和功能方面,每个组件都发挥着关键作用。

请继续阅读关键数据结构,这对 RenderingNG 和代码组件同样重要。


插图作者:Una Kravets。