在上一篇博文中,我简要介绍了 RenderingNG 架构目标和关键属性。这篇博文将介绍如何设置各个组件的组成部分,以及渲染管道如何流经这些部分。
渲染任务是从最高级别开始,然后继续深入分析,具体包括:
- 将内容渲染为屏幕上的像素。
- 为内容从一种状态到另一种状态添加动画视觉效果。
- 滚动以响应输入。
- 高效地将输入传送到正确的位置,以便开发者脚本和其他子系统能够响应。
要呈现的内容是每个浏览器标签页的框架树以及浏览器界面。 以及来自触摸屏、鼠标、键盘和其他硬件设备的原始输入事件流。
每个帧都包含:
- DOM 状态
- CSS
- 画布
- 外部资源,例如图片、视频、字体和 SVG
框架是一个 HTML 文档及其网址。在浏览器标签页中加载的网页具有顶层框架、顶层文档中包含的每个 iframe 的子框架,以及这些框架的递归 iframe 后代。
视觉效果是应用于位图的图形操作,例如滚动、转换、裁剪、滤镜、不透明度或混合。
架构组件
在 RenderingNG 中,这些任务在逻辑上分为几个阶段和代码组件。组件最终出现在各种 CPU 进程、线程以及这些线程中的子组件中。 每种类型在实现所有 Web 内容的可靠性、可扩缩的性能和可扩展性方面都发挥着重要作用。
渲染流水线结构
渲染是在流水线中进行的,在此过程中会创建许多阶段和工件。每个阶段代表在渲染中执行一项明确定义的任务的代码。工件是数据结构,即各阶段的输入或输出;在图表中的输入或输出用箭头指示。
本博文不会详细介绍工件;相关内容将在 Key Data Structures and their roles in RenderingNG 下一篇博文中讨论。
流水线阶段
在上图中,阶段用颜色进行标记,指示它们在哪个线程或进程中执行:
- 绿色:渲染进程主线程
- 黄色:渲染进程合成器
- 橙色:可视化进程
在某些情况下,它们可以在多个位置执行,具体取决于具体情况,因此有些人会有两种颜色。
这些阶段包括:
- 添加动画:根据声明性时间轴,随时间更改计算的样式并更改属性树。
- 样式:将 CSS 应用于 DOM 并创建计算样式。
- 布局:确定 DOM 元素在屏幕上的大小和位置,并创建不可变的 fragment 树。
- 预绘制:计算属性树,并视情况使任何现有的显示列表和 GPU 纹理图块失效。invalidate
- Scroll:通过改变属性树来更新文档和可滚动 DOM 元素的滚动偏移量。
- Paint:计算一个显示列表,描述如何从 DOM 对 GPU 纹理图块进行光栅化。
- 提交:将属性树和显示列表复制到合成器线程。
- 分层:将显示列表分解为复合图层列表,以实现独立的光栅化和动画效果。
- 光栅、解码和绘制 Worklet:将显示列表、编码图像和绘制 Worklet 代码分别转换为 GPU 纹理图块。
- 激活:创建一个合成器帧,它表示如何绘制 GPU 图块并将其放置到屏幕上,以及任何视觉效果。
- 聚合:将来自所有可见合成器帧的合成器帧合并为一个全局合成器帧。
- 绘制:在 GPU 上执行聚合合成器帧,以在屏幕上创建像素。
如果渲染流水线的各个阶段不需要,可以跳过。例如,视觉效果和滚动的动画可以跳过布局、预绘制和绘制。这就是为什么动画和滚动在图中标有黄点和绿点的原因。如果可以针对视觉效果跳过布局、预绘制和绘制,则这些操作可以完全在合成器线程上运行,而跳过主线程。
此处没有直接介绍浏览器界面渲染,但可以将其视为同一流水线的简化版本(实际上,其实现共享大部分代码)。视频(也未直接描绘)通常通过独立代码进行渲染,这些代码会将帧解码为 GPU 纹理图块,然后插入到合成器帧和绘制步骤中。
进程和线程结构
CPU 进程
使用多个 CPU 进程可以将网站性能和安全与浏览器状态隔离开来,同时实现稳定性和安全性与 GPU 硬件隔离。
- 渲染流程可为单个网站和标签页的组合渲染、滚动和路由输入,并呈现动画效果。渲染进程有很多。
- 浏览器进程会为浏览器界面(包括网址栏、标签页标题和图标)渲染和路由输入,并将所有其余输入路由到相应的渲染进程。只有一个浏览器进程。
- Viz 进程汇总了来自多个渲染进程和浏览器进程的合成。它使用 GPU 进行光栅化和绘制。只有一个可视化进程。
不同的网站始终以不同的渲染进程结束。 (实际上,应始终使用桌面设备;如果可能的话,在移动设备上使用)。我会在下方写上“始终” 但此注意事项始终适用)
同一网站的多个浏览器标签页或窗口通常会进入不同的渲染进程,除非这些标签页是相互关联的(一个标签页打开,另一个标签页)。 在桌面设备上存在巨大的内存压力时,Chromium 可能会将来自同一网站的多个标签页放入同一个渲染进程中,即使这些标签页无关紧要。
在单个浏览器标签页中,来自不同网站的帧始终处于彼此不同的渲染进程中,但来自同一网站的帧始终处于同一渲染进程中。从渲染的角度来看,多个渲染流程的重要优势在于,跨网站 iframe 和标签页可以实现彼此的性能隔离。此外,源站可以选择实现进一步隔离。
所有 Chromium 都只有一个 Viz 进程。 毕竟,通常只有一个 GPU 和屏幕可以绘制。面对 GPU 驱动程序或硬件中的 bug 时,将 Viz 分离到自己的进程中有助于提升稳定性。这还有助于实现安全隔离,这对 Vulkan 等 GPU API 非常重要。这对于总体安全性也很重要。
由于浏览器可以有多个标签页和窗口,并且所有标签页和窗口都有要绘制的浏览器界面像素,因此您可能会好奇:为什么只有一个浏览器进程?其原因在于,这些标签页一次只会聚焦于其中一个标签页;事实上,不可见的浏览器标签页大多会处于禁用状态并耗尽其 GPU 内存。然而,在渲染进程(称为 WebUI)中实现复杂的浏览器界面渲染功能的情况也越来越多。这并不是出于性能隔离的原因,而是为了充分利用 Chromium 的网页呈现引擎的易用性。
在旧版 Android 设备上,在 WebView 中使用时,渲染进程和浏览器进程会共享(这通常不适用于 Android 版 Chromium,仅适用于 WebView)。在 WebView 上,浏览器进程也会与嵌入应用共享,并且 WebView 只有一个渲染进程。
有时还会用到一个实用程序进程,用于对受保护的视频内容进行解码。 上述流程没有介绍。
Threads
即便任务缓慢、流水线并行化和多缓冲,线程也有助于实现性能隔离和响应速度。
- 主线程负责运行脚本、渲染事件循环、文档生命周期、命中测试、脚本事件调度以及 HTML、CSS 和其他数据格式的解析。
- 主线程帮助程序执行需要编码或解码的图像位图和 blob 等任务。
- Web Worker 会运行脚本和 OffscreenCanvas 的呈现事件循环。
- 合成器线程会处理输入事件,执行 Web 内容的滚动和动画,计算 Web 内容的最佳分层,并协调图像解码、绘制 Worklet 和光栅任务。
- 合成器线程帮助程序协调可视化光栅任务,并执行图像解码任务、绘制 Worklet 和回退光栅。
- 媒体、分路器或音频输出线程:对视频和音频流进行解码、处理和同步。 (请注意,视频与主渲染管道并行执行。)
分离主线程和合成器线程对于将动画和滚动与主线程工作进行性能隔离至关重要。
每个渲染进程只有一个主线程,即使来自同一网站的多个标签页或框架可能最终在同一个进程中也是如此。不过,性能与在各种浏览器 API 中执行的工作相隔离。例如,使用 Canvas API 生成图片位图和 blob 的操作是在主线程辅助线程中运行。
同样,每个渲染进程也只有一个合成器线程。通常只有一个 1 不是问题,因为合成器线程上所有开销非常大的操作都会委托给合成器工作器线程或可视化进程,并且这项工作可以与输入路由、滚动或动画并行完成。合成器工作器线程会协调在 Viz 进程中运行的任务,但 GPU 加速无处不在,但可能会因超出 Chromium 控制范围之外的原因而失败,例如驱动程序 bug。 在这些情况下,工作线程将在 CPU 上以回退模式执行工作。
合成器工作器线程的数量取决于设备的功能。例如,与移动设备相比,桌面设备通常会使用更多的线程,因为与移动设备相比,桌面设备的 CPU 核心更多,并且耗电量更低。这是纵向扩容和纵向缩容的示例。
还有一点值得注意,渲染进程线程架构是一种采用三种不同优化模式的应用:
- 辅助线程:将长时间运行的子任务发送到其他线程,使父线程同时响应其他请求。 主线程帮助程序和合成器帮助程序线程就是这种技术的不错示例。
- 多次缓冲:在渲染新内容的同时显示之前渲染的内容,以隐藏渲染延迟。合成器线程使用此方法。
- 流水线并行化:同时在多个位置运行渲染流水线。 这就是滚动和动画的快速运行方式,即使主线程渲染发生更新也是如此,因为滚动和动画可以并行运行。
浏览器进程
- 渲染和合成线程响应浏览器界面中的输入,将其他输入路由到正确的渲染进程;对浏览器界面进行布局和绘制。
- 渲染和合成线程帮助程序会执行图像解码任务和回退光栅或解码。
浏览器进程渲染和合成线程与渲染进程的代码和功能类似,只不过主线程和合成器线程被合二为一。 在这种情况下,只需要一个线程,因为从设计上讲,长主线程任务不需要进行性能隔离。
可视化进程
- GPU 主线程会将列表和视频帧显示在 GPU 纹理图块中,并将合成器帧绘制到屏幕上。
- 显示合成器线程会将来自每个渲染进程以及浏览器进程的合成聚合并优化到单个合成器帧中,以呈现在屏幕上。
光栅和绘制通常发生在同一线程上,因为它们都依赖于 GPU 资源,并且很难可靠地以多线程方式使用 GPU(更轻松地多线程访问 GPU 是开发新 Vulkan 标准的动力之一)。在 Android WebView 上,有一个单独的操作系统级渲染线程用于绘制,因为 WebView 如何嵌入到原生应用中。其他平台将来可能会提供此类线程。
显示合成器位于不同的线程上,因为它需要始终响应,并且不会在 GPU 主线程上阻塞任何可能的减速原因。导致 GPU 主线程运行缓慢的一个原因是调用非 Chromium 代码(例如特定于供应商的 GPU 驱动程序),这可能会以难以预测的方式运行缓慢。
组件结构
在每个渲染进程主线程或合成器线程中,有一些逻辑软件组件以结构化方式相互互动。
渲染进程主线程组件
- Blink 渲染程序:
- 本地框架树 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 渲染更改后的 DOM,请执行以下操作:
- 开发者脚本会在 foo.com 的渲染过程中更改 DOM。
- Blink 渲染程序会告知合成器它需要渲染。
- 合成器告知可视化可视化内容需要渲染。
- Viz 会向合成器发送渲染开始的信号。
- 合成器将启动信号转发给 Blink 渲染程序。
- 主线程事件循环运行程序会运行文档生命周期。
- 主线程将结果发送到合成器线程。
- 合成器事件循环运行程序会运行合成生命周期。
- 所有光栅任务都会发送到 Viz 进行光栅处理(这些任务通常有多个任务)。
- GPU 上的可视化内容光栅。
- Viz 确认光栅任务已完成。 注意:Chromium 通常不会等待光栅操作完成,而是使用称为同步令牌的令牌。在执行第 15 步之前,必须由光栅任务解析该令牌。
- 系统向 Viz 发送合成器帧。
- Viz 汇总了 foo.com 渲染进程、bar.com iframe 渲染进程和浏览器界面的合成器帧。
- Viz 安排平局。
- Viz 将聚合的合成器帧绘制到屏幕上。
要为第 2 个标签页上的 CSS 转换过渡添加动画效果,请执行以下操作:
- bar.com 渲染进程的合成器线程通过更改现有的属性树,在其合成器事件循环中使动画循环。然后,这会重新运行合成器生命周期。(可能会发生光栅和解码任务,但此处并未进行介绍。)
- 系统向 Viz 发送合成器帧。
- Viz 汇总了 foo.com 渲染进程、bar.com 渲染进程和浏览器界面的合成器帧。
- Viz 安排平局。
- Viz 将聚合的合成器帧绘制到屏幕上。
如需在第 3 个标签页上滚动浏览网页,请执行以下操作:
- 浏览器进程会进入一系列
input
事件(鼠标、触摸或键盘)。 - 每个事件都会路由到 baz.com 的渲染进程合成器线程。
- 合成器会确定主线程是否需要知道事件。
- 如有必要,事件会发送到主线程。
- 主线程会触发
input
事件监听器(pointerdown
、touchstar
、pointermove
、touchmove
或wheel
),以查看监听器是否会对事件调用preventDefault
。 - 主线程会返回是否对合成器调用了
preventDefault
。 - 否则,系统会将输入事件发送回浏览器进程。
- 浏览器进程通过将该手势与最近的其他事件相结合,将其转换为滚动手势。
- 滚动手势再次发送到 baz.com 的渲染进程合成器线程,
- 滚动会应用到那里,bar.com 渲染进程的合成器线程会在其合成器事件循环中 tick 动画。然后,这会改变属性树中的滚动偏移量,并重新运行合成器生命周期。它还告知主线程触发
scroll
事件(此处未显示)。 - 系统向 Viz 发送合成器帧。
- Viz 汇总了 foo.com 渲染进程、bar.com 渲染进程和浏览器界面的合成器帧。
- Viz 安排平局。
- Viz 将聚合的合成器帧绘制到屏幕上。
若要在 iframe #two 中的标签页 1 上通过超链接路由 click
事件,请执行以下操作:
- 浏览器进程会发生
input
事件(鼠标、触摸或键盘)。此工具会执行近似的点击测试,以确定 bar.com iframe 呈现进程应该收到点击并将其发送到此处。 - bar.com 的合成器线程会将
click
事件路由到 bar.com 的主线程,并安排渲染事件循环任务来处理该事件。 - bar.com 的主线程命中测试的输入事件处理器,用于确定用户点击了 iframe 中的哪个 DOM 元素,并触发
click
事件以便脚本进行观察。如果未听到preventDefault
,则会转到超链接。 - 加载超链接的目标页面后,系统会呈现新状态,其步骤与上述“渲染已更改的 DOM”示例类似。(此处没有介绍后续更改。)
总结
哇,这里有很多细节。 如您所见,在 Chromium 中进行渲染是相当复杂的! 您可能需要花费大量时间来记住和内化所有部分,因此如果这些内容看起来让人不堪重负,也不必担心。
最重要的结论是,有一个概念上简单的渲染管道,通过仔细的模块化和对细节的关注,它已拆分为许多独立的组件。然后,这些组件已在并行进程和线程中拆分,以最大限度地提高可扩缩性能和可扩展性机会。
在实现现代 Web 应用需要的所有性能和功能方面,其中每个组件都起着至关重要的作用。很快,我们就会深入介绍每个产品 以及它们所扮演的重要角色
但在此之前,我还将说明本博文中提到的关键数据结构(渲染流水线图表两侧以蓝色表示的数据结构)对 RenderingNG 与代码组件一样重要。
感谢阅读,敬请关注!
插图作者:Una Kravets。