RenderingNG 深入探究:BlinkNG

Stefan Zager
Stefan Zager
Chris Harrelson
Chris Harrelson

Blink 是指 Chromium 对网络平台的实现,涵盖合成前的所有渲染阶段,最终以合成器提交结束。如需详细了解 blink 渲染架构,请参阅本系列的前几篇文章

Blink 最初是 WebKit 的分支,而 WebKit 本身是 KHTML 的分支,后者可追溯到 1998 年。它包含 Chromium 中一些最古老(也是最重要的)代码,到 2014 年,它已经明显过时。那一年,我们开始了一系列雄心勃勃的项目,以我们称之为 BlinkNG 的旗号,旨在解决 Blink 代码组织和结构中长期存在的缺陷。本文将探讨 BlinkNG 及其组成项目:我们为何开展这些项目、这些项目取得了哪些成就、设计这些项目时遵循了哪些指导原则,以及这些项目为未来改进提供了哪些机会。

BlinkNG 之前和之后的渲染流水线。

在 NG 之前进行渲染

Blink 中的渲染流水线在概念上始终分为多个阶段(样式布局绘制等),但抽象屏障存在漏洞。一般来说,与渲染相关的数据由长期有效的可变对象组成。这些对象可以随时修改(并且确实会修改),并且会被后续的渲染更新频繁回收和重复使用。无法可靠地回答简单的问题,例如:

  • 样式、布局或绘制输出是否需要更新?
  • 这些数据何时会获得“最终”值?
  • 何时可以修改这些数据?
  • 此对象将于何时被删除?

这类情况有很多,包括:

样式会根据样式表生成 ComputedStyle;但 ComputedStyle 并非不可变的;在某些情况下,它会被后续流水线阶段修改。

样式会生成 LayoutObject 树,然后布局会使用尺寸和定位信息为这些对象添加注解。在某些情况下,布局甚至会修改树结构。布局的输入和输出之间没有明确的分隔。

样式会生成用于确定合成过程的辅助数据结构,这些数据结构会在 style 之后的每个阶段就地修改。

在较低级别,渲染数据类型主要由专用树(例如 DOM 树、样式树、布局树、绘制属性树)组成;渲染阶段以递归树遍历的形式实现。理想情况下,树遍历应受限:在处理给定树节点时,我们不应访问该节点为根的子树之外的任何信息。在 RenderingNG 之前,情况从来不是这样;树遍历经常会访问所处理节点的祖先的信息。这使得系统非常脆弱且容易出错。此外,除了树的根之外,无法从任何其他位置开始树木漫步。

最后,代码中散布着许多进入渲染流水线的入口点:由 JavaScript 触发的强制布局、文档加载期间触发的部分更新、为准备事件定位而进行的强制更新、显示系统请求的定期更新,以及仅向测试代码公开的专用 API,等等。渲染流水线中甚至存在一些递归递归路径(即从一个阶段的中间跳转到另一个阶段的开头)。这些入口各有各的特殊行为,在某些情况下,渲染输出将取决于渲染更新的触发方式。

我们做出的调整

BlinkNG 由许多大大小小的子项目组成,所有这些子项目都致力于消除前面所述的架构缺陷。这些项目遵循一些指导原则,旨在使渲染流水线更像实际流水线:

  • 统一的入口点:我们应始终从开头进入流水线。
  • 功能阶段:每个阶段都应具有明确定义的输入和输出,并且其行为应是功能性的,即确定性和可重复的,并且输出应仅取决于定义的输入。
  • 恒定输入:在任何阶段运行期间,该阶段的输入应有效保持不变。
  • 不可变的输出:某个阶段完成后,其输出在渲染更新的其余时间内应保持不变。
  • 检查点一致性:在每个阶段结束时,到目前为止生成的渲染数据应处于自一致状态。
  • 工作去重:只计算每项内容一次。

完整的 BlinkNG 子项目列表会让人读起来很乏味,但以下是一些特别重要的子项目。

文档生命周期

DocumentLifecycle 类会跟踪我们在渲染流水线中的进度。它允许我们执行基本检查来强制执行前面列出的不变性,例如:

  • 如果我们要修改 ComputedStyle 属性,则文档生命周期必须为 kInStyleRecalc
  • 如果 DocumentLifecycle 状态为 kStyleClean 或更高版本,则 NeedsStyleRecalc() 必须针对任何已附加的节点返回 false
  • 进入绘制生命周期阶段时,生命周期状态必须为 kPrePaintClean

在实现 BlinkNG 的过程中,我们系统地消除了违反这些不变量的代码路径,并在整个代码中添加了更多断言,以确保不会出现回归问题。

如果您曾经深入研究过低级渲染代码,可能会问自己:“我怎么会来到这里?”如前所述,有各种进入渲染流水线的入口点。以前,这包括递归和重入调用路径,以及我们在流水线中间阶段(而不是从开头)进入的位置。在 BlinkNG 的开发过程中,我们分析了这些调用路径,并确定它们都可以归结为以下两种基本场景:

  • 需要更新所有渲染数据,例如,在为显示生成新像素或执行事件定位的命中测试时。
  • 我们需要为特定查询提供最新值,而无需更新所有渲染数据即可回答该查询。这包括大多数 JavaScript 查询,例如 node.offsetTop

现在,渲染流水线只有两个入口点,分别对应这两种场景。重入代码路径已被移除或重构,因此无法再从中间阶段开始进入流水线。这消除了关于渲染更新确切时间和方式的许多疑问,使我们更轻松地推理系统行为。

流水线样式、布局和预绘制

paint 之前的渲染阶段总体负责以下事项:

  • 运行样式级联算法,以计算 DOM 节点的最终样式属性。
  • 生成表示文档盒子层次结构的布局树。
  • 确定所有框的大小和位置信息。
  • 将子像素几何图形舍入或捕获到整像素边界以进行绘制。
  • 确定合成层的属性(仿射转换、滤镜、不透明度或任何其他可 GPU 加速的属性)。
  • 确定自上次绘制阶段以来哪些内容发生了变化,以及需要绘制或重新绘制哪些内容(绘制失效)。

此列表没有更改,但在 BlinkNG 之前,此类工作中的大部分工作都是以临时方式完成的,分布在多个渲染阶段,并且存在大量重复的功能和内置效率低下的问题。例如,样式阶段一直主要负责计算节点的最终样式属性,但在某些特殊情况下,我们直到样式阶段完成后才确定最终样式属性值。在渲染过程中,我们无法在任何正式或可强制执行的点上肯定地说样式信息是完整且不可变的。

在 BlinkNG 之前遇到的问题的另一个很好的示例是绘制失效。以前,绘制失效会散布在绘制之前的所有渲染阶段中。修改样式或布局代码时,很难知道需要对绘制失效逻辑进行哪些更改,并且很容易出错,导致绘制失效过多或过少 bug。如需详细了解旧版绘制失效系统的复杂性,请参阅本系列专门介绍 LayoutNG 的文章。

将子像素布局几何图形捕获到整像素边界以进行绘制,就是一个很好的例子,它说明我们对同一功能实现了多种方式,并进行了大量重复的工作。绘制系统使用一个像素对齐代码路径,每当我们需要在绘制代码之外对像素对齐坐标进行一次性动态计算时,就会使用完全独立的代码路径。不用说,每种实现都有自己的 bug,并且其结果并不总是一致的。由于系统不会缓存此类信息,因此有时会反复执行完全相同的计算,这又会进一步影响性能。

以下是一些重要的项目,它们消除了绘制之前渲染阶段的架构缺陷。

Project Squad:将样式阶段加入流水线

该项目解决了样式阶段的两个主要缺陷,这两个缺陷导致样式无法顺利流水线处理:

样式阶段有两个主要输出:ComputedStyle,其中包含对 DOM 树运行 CSS 级联算法的结果;以及 LayoutObjects 树,用于确定布局阶段的操作顺序。从概念上讲,运行级联算法应该严格在生成布局树之前进行;但之前,这两项操作是交错进行的。Project Squad 成功将这两者拆分为两个不同的顺序阶段。

以前,ComputedStyle 在样式重新计算期间并不总是获得最终值;在某些情况下,ComputedStyle 会在较晚的流水线阶段更新。Project Squad 成功重构了这些代码路径,因此 ComputedStyle 在样式阶段后从未修改过。

LayoutNG:将布局阶段流水线化

这项宏伟的项目是 RenderingNG 的基石之一,它完全重写了布局渲染阶段。我们无法在此全面介绍整个项目,但 BlinkNG 项目整体有几个值得注意的方面:

  • 以前,布局阶段会接收样式阶段创建的 LayoutObject 树,并使用大小和位置信息为该树添加注解。因此,输入和输出之间没有明确的分离。LayoutNG 引入了 fragment 树,它是布局的主要只读输出,并用作后续渲染阶段的主要输入。
  • LayoutNG 为布局引入了容器属性:在计算给定 LayoutObject 的尺寸和位置时,我们不再查看以该对象为根的子树之外的内容。系统会预先计算更新给定对象布局所需的所有信息,并将其作为只读输入提供给算法。
  • 以前,在某些极端情况下,布局算法无法正常运行:算法结果取决于之前的最新布局更新。LayoutNG 消除了这些情况。

预绘制阶段

以前,没有正式的绘制前渲染阶段,只有一堆布局后操作。预绘制阶段的出现源于以下认识:有几个相关函数最适合在布局完成后以系统地遍历布局树的形式实现;最重要的是:

  • 发出绘制失效:当信息不完整时,在布局过程中正确执行绘制失效非常困难。如果将其拆分为两个不同的过程,则更容易正确完成,并且效率非常高:在样式和布局期间,可以使用简单的布尔标志将内容标记为“可能需要绘制失效”。在绘制前树木漫步期间,我们会检查这些标志,并根据需要发出失效通知。
  • 生成绘制属性树:稍后会对此过程进行更详细的介绍。
  • 计算和记录像素级捕获的绘制位置:绘制阶段和需要这些结果的任何下游代码都可以使用记录的结果,而无需进行任何冗余计算。

房源树:几何图形一致

属性树在 RenderingNG 的早期就已引入,用于处理滚动操作的复杂性。在 Web 上,滚动操作的结构与所有其他类型的视觉效果不同。在属性树之前,Chromium 的 compositor 使用单个“层次结构”来表示合成内容的几何关系,但随着 position:fixed 等功能的全部复杂性变得明显,这种方法很快就失效了。图层层次结构中出现了额外的非本地指针,用于指示图层的“滚动父级”或“剪裁父级”,很快,代码就变得非常难以理解。

属性树通过将内容的溢出滚动和剪裁方面与所有其他视觉效果分开表示来解决此问题。这样一来,我们就可以正确地对网站的真实视觉和滚动结构进行建模。接下来,“我们只需”在属性树上实现算法,例如复合图层的屏幕空间转换,或确定哪些图层滚动了,哪些没有滚动。

事实上,我们很快就发现,代码中还有许多其他地方也存在类似的几何问题。(“关键数据结构”博文中提供了更完整的列表。)其中有几个实现了与合成器代码执行的同一操作的代码重复;所有这些实现都存在不同的 bug;而且没有一个实现能够正确模拟真实的网站结构。解决方案随之变得清晰:将所有几何图形算法集中到一个位置,并重构所有代码以使用该算法。

这些算法反过来又都依赖于属性树,这就是为什么属性树是 RenderingNG 的关键数据结构(即在整个流水线中使用的结构)。因此,为了实现集中式几何图形代码这一目标,我们需要在流水线中更早地引入属性树的概念(在预绘制阶段),并更改目前依赖于这些 API 的所有 API,使其在执行之前都需要运行预绘制。

这个故事是 BlinkNG 重构模式的另一个方面:识别关键计算、重构以避免重复计算,并创建明确定义的流水线阶段来创建为其提供数据的数据结构。我们会在所有必要信息可用时计算属性树;并且我们会确保在后续渲染阶段运行时,属性树不会发生变化。

绘制后合成:流水线绘制和合成

分层是指确定哪些 DOM 内容会进入自己的合成层(该层反过来代表 GPU 纹理)的过程。在 RenderingNG 之前,分层是在绘制之前运行的,而不是之后(如需了解当前管道,请点击此处 - 请注意顺序的变化)。我们会先确定 DOM 的哪些部分会进入哪个合成层,然后再为这些纹理绘制显示列表。当然,这些决策取决于哪些 DOM 元素正在进行动画或滚动,或具有 3D 转换,以及哪些元素在哪些元素上绘制。

这会导致严重问题,因为它或多或少需要代码中存在循环依赖项,这对渲染流水线来说是一个大问题。下面我们通过一个示例来了解原因。假设我们需要使绘制失效(即需要重新绘制显示列表,然后再次对其进行光栅化)。需要失效可能是由于 DOM 发生变化,也可能是由于样式或布局发生变化。当然,我们希望只使实际更改的部分失效。这意味着,需要找出受影响的复合图层,然后使这些图层的部分或全部显示列表失效。

这意味着失效取决于 DOM、样式、布局和过去的分层决策(过去:指上一个渲染的帧)。但当前的分层结构也取决于所有这些因素。由于我们没有所有分层数据的两个副本,因此很难区分过去和未来的分层决策之间的差异。因此,我们最终得到了大量循环推理代码。如果不小心,这有时会导致代码不合逻辑或不正确,甚至会导致崩溃或安全问题。

为了应对这种情况,我们在早期引入了 DisableCompositingQueryAsserts 对象的概念。在大多数情况下,如果代码尝试查询过去的分层决策,则会导致断言失败,并在浏览器处于调试模式时导致浏览器崩溃。这有助于我们避免引入新的 bug。在每次代码合法地需要查询过去的分层决策时,我们都会通过分配 DisableCompositingQueryAsserts 对象来允许这样做。

我们的计划是,随着时间的推移,逐步移除所有调用点 DisableCompositingQueryAssert 对象,然后声明代码安全且正确。但我们发现,只要分层发生在绘制之前,许多调用实际上是不可能移除的。(我们直到最近才最终能够将其移除!)这是发现的第一个 Composite After Paint 项目存在的原因。我们发现,即使您为操作定义了明确的流水线阶段,如果该阶段位于流水线中的错误位置,最终也会卡住。

导致“绘制后合成”项目的第二个原因是基本合成 bug。可以这样描述此 bug:DOM 元素无法很好地 1:1 表示网页内容的高效或完整分层方案。由于合成在绘制之前,因此它在本质上或多或少地依赖于 DOM 元素,而不是显示列表或属性树。这与我们引入属性树的原因非常相似,就像属性树一样,如果您找出正确的流水线阶段、在正确的时间运行它并为其提供正确的关键数据结构,解决方案就会直接出现。与属性树一样,这是一个很好的机会,可以确保绘制阶段完成后,其输出对于所有后续流水线阶段都是不可变的。

优势

正如您所见,定义良好的渲染流水线会带来巨大的长期利益。其实,您可以使用 Google Ads 的功能远远不止这些:

  • 大大提高了可靠性:这个很简单。代码越简洁、接口越清晰易懂,就越容易理解、编写和测试。这样可以提高其可靠性。这还可以提高代码的安全性和稳定性,减少崩溃和释放后使用 bug。
  • 扩大了测试覆盖率:在 BlinkNG 的开发过程中,我们向套件中添加了大量新测试。这包括用于重点验证内部的单元测试;用于防止我们重新引入已修复的旧 bug(数量非常多!)的回归测试;以及对公共的集体维护的 Web 平台测试套件的大量添加,所有浏览器都使用该套件来衡量对 Web 标准的遵从程度。
  • 更易于扩展:如果系统被细分为清晰的组件,则无需了解任何级别的其他组件,即可推进当前组件的开发。这样一来,所有人都可以更轻松地为渲染代码增添价值,而无需成为深度专家,还可以更轻松地推理整个系统的行为。
  • 性能:优化用意大利面条代码编写的算法已经很难了,但如果没有这样的流水线,几乎不可能实现更大的功能,例如通用线程滚动和动画用于网站隔离的进程和线程。并行处理可以显著提升性能,但也非常复杂。
  • 让步和容器化:BlinkNG 提供了多项新功能,可以全新的方式运行管道。例如,如果我们只想在预算用尽之前运行渲染流水线,该怎么办?或者跳过当前已知与用户无关的子树的渲染?这就是 content-visibility CSS 属性的用途。如何让组件的样式取决于其布局?这就是容器查询

案例研究:容器查询

容器查询是一项备受期待的即将推出的 Web 平台功能(多年来一直是 CSS 开发者最希望推出的功能)。如果它如此出色,为什么还没有出现?原因在于,容器查询的实现需要非常仔细地了解和控制样式代码与布局代码之间的关系。我们来详细了解一下。

借助容器查询,应用于元素的样式可以依赖于祖先元素的布局大小。由于布局大小是在布局期间计算的,这意味着我们需要在布局后运行样式重新计算;但样式重新计算是在布局之前运行的!正是由于这个鸡生蛋、蛋生鸡的悖论,我们在 BlinkNG 之前无法实现容器查询。

如何解决此问题?这不是向后流水线依赖项,也就是说,这与 Composite After Paint 等项目解决的问题相同吗?更糟糕的是,如果新样式更改了祖先元素的大小,该怎么办?这有时会不会导致无限循环?

原则上,可以通过使用 contain CSS 属性来解决循环依赖项问题,该属性允许在元素外部进行渲染,而不会依赖于在该元素的子树内进行渲染。这意味着,容器应用的新样式无法影响容器的大小,因为容器查询需要容器

但实际上,这还不够,我们还需要引入一种比尺寸约束更弱的约束类型。这是因为,通常希望容器查询容器能够根据其内嵌尺寸仅在一个方向(通常为块)调整大小。因此,我们添加了内嵌大小限制的概念。但正如您从该部分中非常长的备注中看到的那样,很长一段时间以来,我们都完全不清楚是否可以实现内嵌尺寸限制。

用抽象规范语言描述封装是一回事,正确实现封装是另一回事。回想一下,BlinkNG 的一个目标是将封闭容器原则引入构成渲染主要逻辑的树遍历:在遍历子树时,不应需要从子树外部获取任何信息。事实证明(其实也不能说是意外),如果渲染代码遵循封闭原则,则实现 CSS 封闭会更加清晰、简单。

未来:主线程外合成… 以及更多!

此处显示的渲染流水线实际上比当前的 RenderingNG 实现略高。它显示分层已移出主线程,但目前它仍在主线程上。不过,现在“绘制后合成”已发布,分层也已在绘制后进行,因此实现这一目标只是时间问题。

为了了解这一点的重要性以及它可能带来的其他影响,我们需要从更高的角度来考虑渲染引擎的架构。阻碍 Chromium 性能提升的最持久障碍之一是,渲染程序的主线程同时处理主要应用逻辑(即运行脚本)和大部分渲染。因此,主线程经常会因工作量过多而饱和,而主线程拥塞通常是整个浏览器的瓶颈。

好消息是,情况不一定如此!Chromium 架构的这一方面可以追溯到 KHTML 时代,当时单线程执行是主流编程模型。当多核处理器在消费类设备中变得普遍时,单线程假设已完全融入 Blink(以前称为 WebKit)。我们一直希望在渲染引擎中引入更多线程,但在旧系统中根本无法实现。Rendering NG 的一个主要目标就是帮助我们摆脱这个困境,让渲染工作能够部分或全部移至其他线程。

现在,BlinkNG 即将完成,我们已经开始探索这一领域;非阻塞提交是首次尝试更改渲染程序的线程模型。合成器提交(或简称提交)是主线程和合成器线程之间的同步步骤。在提交期间,我们会复制主线程上生成的渲染数据,以供在 compositor 线程上运行的下游合成代码使用。在此同步期间,主线程执行会停止,而复制代码会在 compositor 线程上运行。这样做是为了确保主线程不会在合成器线程复制其渲染数据时修改其渲染数据。

无阻塞提交功能可让主线程无需停止并等待提交阶段结束,因为主线程会在合成器线程上并发运行提交操作时继续执行工作。非阻塞提交的最终效果是减少在主线程上专门用于渲染工作的时长,这将减少主线程上的拥塞并提升性能。在撰写本文时(2022 年 3 月),我们已经有了非阻塞提交的可行原型,并准备对其对性能的影响进行详细分析。

我们还在等待非主线程合成的到来,其目标是通过将分层从主线程移至工作器线程,使渲染引擎与插图相匹配。与非阻塞提交一样,这会通过减少主线程的渲染工作负载来减少主线程上的拥塞。如果没有对 Composite After Paint 架构的改进,我们绝不可能完成这样一个项目。

我们还在筹划更多项目(玩文字游戏)!我们终于有了基础,可以尝试重新分配渲染工作,我们非常期待看到可能取得的成效!