[译] Chromium 下一代渲染架构(二):RenderingNG 架构概述

1,072 阅读18分钟

本文是 RenderingNG 系列文章的第一篇:

  1. [译] Chromium 下一代渲染架构(一):RenderingNG

  2. [译] Chromium 下一代渲染架构(二):RenderingNG 架构概述

  3. [译] Chromium 下一代渲染架构(三):关键数据结构

  4. [译] Chromium 下一代渲染架构(四):VideoNG

  5. [译] Chromium 下一代渲染架构(五):LayoutNG

  6. [译] Chromium 下一代渲染架构(六):BlinkNG


在上一篇文章中,我概述了 RenderingNG 的架构目标和关键属性。这篇文章将解释 RenderingNG 的各部件是如何设置的,渲染流水线是如何使用这些部件的。

自顶向下地看,渲染的任务是:

  1. 将内容 渲染 为屏幕上的像素。
  2. 动画 将内容的视觉效果从一种状态变为另一种状态。
  3. 滚动 以响应输入。
  4. 将输入有效地 路由 到正确的位置,以便 Scripts 和其他子系统可以响应。

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

每个框架包括:

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

框架是一个 HTML 文档,加上它的 URL。在 Tab 中加载的网页具有顶级框架,顶级框架中包含每个 iframe 子框架以及每个 iframe 的递归 iframe。

视觉效果是对位图的图形操作,这些图形操作比如:滚动、变换、剪辑、过滤、不透明度或这些的混合使用。

架构组件

在 RenderingNG 中,这些任务在逻辑上被分成几个阶段和几个组件。这些组件存在于各种 CPU 进程、线程和这些线程中的子组件中。每个组件都非常重要,它们在为 Web 内容实现可靠性、可伸缩性能和可扩展性方面发挥着重要作用。

渲染流水线的结构

渲染在一个流水线中向前推进,并在过程中创建了许多工件。这个流水线有许多个阶段,每个 阶段 代表在渲染中执行一项明确定义的任务。工件 是作为每个阶段输入或输出的数据结构;图中的输入或输出用箭头表示。

这篇文章不会详细介绍这些工件。这将在下一篇文章中讨论:关键数据结构及其在 RenderingNG 中的作用。

流水线阶段

在上图中,阶段用颜色表示,指示它们在哪个线程或进程中执行:

  • 绿色: 渲染进程的主线程
  • 黄色: 渲染进程的合成器线程
  • 橙色: Viz 进程(一个中心化的负责栅格化和绘图的进程)

在某些情况下,它们可以在不同的地方执行,这就是为什么有些阶段有两种颜色。

这些阶段分别是:

  1. Animate: 根据声明的时间线改变 Computed Styles 和 属性树
  2. Style: 将 CSS 应用于 DOM,并创建 Computed Styles
  3. Layout: 确定屏幕上 DOM 元素的大小和位置,并创建 immutable fragment tree
  4. Pre-paint: 计算属性树,并酌情使任何现有的 显示列表 和 GPU 纹理图块 无效
  5. Scroll: 通过改变属性树来更新文档和可滚动 DOM 元素的滚动偏移量。
  6. Paint: 计算一个显示列表,描述如何从 DOM 中栅格化 GPU 纹理图块。
  7. Commit: 将属性树和显示列表复制到合成器线程。
  8. Layerize: 将显示列表分解为 复合层列表,用于独立的栅格化和动画。
  9. Raster、decodepaint worklets: 将显示列表、编码图像和绘制工作集代码分别转换为 GPU 纹理图块
  10. Activate: 创建一个 合成器框架,表示如何在屏幕上绘制和定位 GPU 图块,以及任何视觉效果。
  11. Aggregate: 将所有可见合成器帧中的合成器帧组合成一个单一的全局合成器帧。
  12. Draw: 在 GPU 上执行聚合合成器帧以在屏幕上创建像素。

如果某次渲染不需要渲染流水线的某个阶段,是可以跳过的。例如,视觉效果和滚动的动画可以跳过布局、预绘制和绘制。这就是为什么动画和滚动在图中用黄色和绿色点标记的原因。如果布局、预绘制和绘制可以跳过视觉效果,它们可以完全在合成器线程上运行并跳过主线程。

浏览器 UI 渲染在这里没有直接描述,但可以被认为是同一流水线的简化版本(实际上它的实现共享大部分代码)。视频(也未直接描述)通常通过独立代码进行渲染,该代码将帧解码为 GPU 纹理图块,然后插入合成器帧和绘制步骤。

进程和线程结构

Chromium 架构中的 CPU 进程和线程比此处显示的要多。这些图表仅关注那些对渲染至关重要的图表。

CPU 进程

使用多个 CPU 进程实现了站点之间以及与浏览器的性能和安全隔离,以及与 GPU 硬件的稳定性和安全隔离。

  • Render Process(渲染进程)为单个站点和 Tab 完成渲染、动画、滚动和路由输入。渲染进程可以有多个
  • Browser Process(浏览器进程)为浏览器 UI 完成渲染、动画和路由输入(包括 URL 栏、Tab 标题和图标),并将所有剩余的输入路由到合适的渲染进程。只有一个浏览器进程。
  • Viz Process (Viz 进程)聚合来自多个渲染进程和浏览器进程的合成。它使用 GPU 进行栅格化和绘图。只有一个 Viz 进程。

不同的站点有不同的渲染进程。(实际上,在桌面设备上总是如此,在移动设备上是尽可能。我会在下面写“总是”,但这个警告始终适用。)

同一站点的多个 Tab 或窗口通常有不同的渲染进程,除非它们是相关的(一个打开另一个)。如果桌面设备内存不足,Chromium 可能会将来自同一站点的多个 Tab 放入同一个渲染进程中,即使它们不相关。

在单个 Tab 中,来自不同站点的 frames 始终处于彼此不同的渲染进程中,但来自同一站点的 frames 始终处于相同的渲染进程中。从渲染的角度来看,多渲染进程的重要优势在于跨站 iframe 和 Tab 实现了性能隔离。此外,origins 可以选择更加隔离

所有 Chromium 都只有一个 Viz 进程。毕竟,通常只有一个 GPU 和一个屏幕可供绘制。面对 GPU 驱动程序或硬件中的错误,将 Viz 分离到自己的进程中有助于提高稳定性。它也有利于安全隔离,这对于像 Vulkan 这样的 GPU API 很重要。一般来说,这对于安全性也很重要。

既然浏览器可以有很多 Tab 和窗口,而且它们都有要绘制的浏览器 UI 像素,你可能想知道:为什么只有一个浏览器进程?原因是同时只有一个 Tab 获得焦点;事实上,不可见的浏览器 Tab 大多被停用并丢弃所有 GPU 内存。然而,复杂的浏览器 UI 渲染功能也越来越多地在渲染进程中实现(称为 WebUI)。这不是出于性能隔离的原因,而是为了利用 Chromium 的 Web 渲染引擎的易用性。

在较旧的 Android 设备上,当在 WebView 中使用时,渲染和浏览器进程是共享的(这通常不适用于 Android 上的 Chromium,仅适用于 WebView)。在 WebView 上,浏览器进程也是和 embedding app 共享的,WebView 只有一个渲染进程。

有时还有用于解码受保护视频内容的实用程序。上面没有描述这个过程。

线程

线程有助于实现性能隔离和提升响应能力,不管是长任务、流水线并行化,还是多重缓冲,线程都能帮上忙。

  • Main Thread(主线程)运行脚本、渲染的事件循环、文档生命周期、命中检查、脚本事件调度、HTML、CSS 等数据的解析。

    • Main Thread Helpers 执行任务,例如创建需要编码或解码的图像位图和 blobs。
    • Web Workers 运行脚本和 OffscreenCanvas 的渲染事件循环。
  • Compositor Thread(合成器线程)处理输入事件,执行网页内容的滚动和动画,计算网页内容的最佳分层,并协调图像解码、绘制工作集和栅格化任务。

    • Compositor Thread Helpers 协调 Viz 栅格化任务,并执行图像解码任务、绘制工作集和备用栅格化。
  • Media、Demuxer & Audio output Thread 解码、处理和同步视频和音频流。(请记住,视频与主渲染流水线并行执行。)

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

每个渲染进程只有一个主线程,即使来自同一站点的多个 Tab 或框架可能最终在同一个进程中。但是,在各种浏览器 API 中执行的工作存在性能隔离。例如,Canvas API 中图像位图和 blob 的生成在主线程辅助线程中运行。

同样,每个渲染进程只有一个合成器线程。只有一个通常不是问题,因为合成器线程上所有真正昂贵的操作都委托给合成器工作线程或 Viz 进程,并且这项工作可以与输入路由、滚动或动画并行完成。合成器工作线程协调在 Viz 进程中运行的任务,但由于 Chromium 无法控制的原因(例如驱动程序错误),任何地方的 GPU 加速都可能失败。在这些情况下,工作线程将以备用模式在 CPU 上完成工作。

合成器工作线程的数量取决于设备的功能。例如,台式机通常会使用更多线程,因为它们具有更多的 CPU 内核,并且比移动设备更少受电池限制。这是可伸缩的示例。

值得注意的是,渲染进程线程架构是三种不同优化模式的应用:

  • 辅助线程 将长时间运行的子任务发送到其他线程,以保持父线程响应同时发生的其他请求。主线程助手和合成器助手线程就是这种技术的好例子。
  • 多重缓冲 在渲染新内容的同时显示之前渲染的内容,以隐藏渲染的延迟。合成器线程使用这种技术。
  • 流水线并行化 同时在多个地方运行渲染流水线。这就是滚动和动画可以快速的方式,即使正在发生主线程渲染更新,因为滚动和动画可以并行运行。

浏览器进程

  • Render & Compositing Thread 响应浏览器 UI 中的输入,将其他输入路由到正确的渲染进程;布局和绘制浏览器 UI。
  • Render & Compositing Thread Helper 执行图像解码任务和备用栅格化或解码。

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

Viz 进程

  • GPU Main Thread 将显示列表和视频帧栅格化到 GPU 纹理图块中,并将合成器帧绘制到屏幕上。
  • Display Compositor Thread 将来自每个渲染进程以及浏览器进程的合成内容聚合并优化到单个合成器帧中,以呈现到屏幕上。

Raster 和 Draw 通常发生在同一个线程上,因为它们都依赖于 GPU 资源,并且很难可靠地使多线程来使用 GPU(更容易地多线程访问 GPU 是开发新 Vulkan 标准的动机之一)。在 Android WebView 上,由于 WebViews 是嵌入到 Native App 中的,因此有一个单独的 OS 级别的渲染线程用于绘图。其他平台将来可能会有这样的线程。

显示合成器位于不同的线程上,因为它需要始终响应,并且不会阻塞 GPU 主线程上任何可能的减速源。GPU 主线程速度变慢的原因之一是调用非 Chromium 代码,例如供应商特定的 GPU 驱动程序,这可能会以难以预测的方式变慢。

组件结构

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

渲染进程主线程组件

  • Blink renderer:

    • local frame tree fragment 代表本地 frame 树和 frame 内的 DOM 。
    • DOM & Canvas API 组件包含所有这些 API 的实现。
    • document lifecycle runner 执行渲染流水线步骤,执行到 commit 这一步,包括 commit。
    • input event hit testing and dispatching 组件执行命中测试以找出事件所针对的 DOM 元素,并运行输入事件分派算法和默认行为。
  • rendering event loop scheduler and runner 决定在事件循环中运行什么,以及何时运行。它以与显示设备匹配的节奏安排渲染。

Local frame tree fragments 有点复杂。回想一下,框架树是主页面及其子 iframes、子 iframes 的子 iframes,一直递归下去。对渲染进程来说,如果一个 frame 在该进程中渲染,则它是本地的,否则它是远程的

你可以想象根据渲染进程为 frame 着色。在上图中,绿色圆圈是在同一个渲染进程中渲染的 frame;红色的、蓝色的同理。

一个 Local frame tree fragment 是指 frame 树中既相互连接又有相同颜色的部分。上图中有四个 local frame trees:站点 A 两个,站点 B 一个,站点 C 一个。每个 local frame tree 都有自己的 Blink 渲染器。Local frame tree 的 Blink 渲染器可能与其他 local frame tree 在同一渲染进程中,也可能不在同一渲染进程中(如前所述,它由渲染进程的选择方式决定)。

渲染进程合成器线程结构

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

  • 一个 data handler,维护合成层列表、显示列表和属性树
  • 一个 lifecycle runner,运行渲染流水线的动画、滚动、合成、栅格化以及解码和激活的步骤。(请记住,动画和滚动可以在主线程和合成器线程中发生。)
  • 一个 input and hit test handler 执行输入处理和命中测试,以确定滚动是否可以在合成器线程上运行,以及应该针对哪个渲染进程进行命中测试。

实践中的一个例子

现在让我们通过一个例子来具体说明这个架构。在此示例中,有三个 Tab:

Tab 1: foo.com

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

Tab 2:bar.com

<html>
  …
</html>

Tab 3:baz.com

<html>
  …
</html>

这些 Tab 的进程、线程和组件结构将如下所示:

现在让我们看一下渲染的四个主要任务中的一个示例,你可能还记得:

  1. 将内容 渲染 为屏幕上的像素。
  2. 动画 将内容的视觉效果从一种状态变为另一种状态。
  3. 滚动 以响应输入。
  4. 将输入有效地 路由 到正确的位置,以便 Scripts 和其他子系统可以响应。

为 Tab 1 渲染更改后的 DOM,步骤如下:

  1. 开发人员脚本在 foo.com 的渲染进程中更改 DOM。
  2. 主线程告诉合成器线程它需要进行渲染。
  3. 合成器线程告诉 Viz 它需要进行渲染。
  4. Viz 向合成器线程发出渲染开始的信号。
  5. 合成器线程将开始信号转发到主线程。
  6. 主线程事件循环运行器运行文档生命周期。
  7. 主线程将结果发送到合成器线程。
  8. 合成器事件循环运行器运行合成生命周期。
  9. 任何栅格化任务都会发送到 Viz 进行栅格化(这些任务通常不止一项)。
  10. Viz 在 GPU 上栅格化内容。
  11. Viz 确认栅格化任务的完成。注意:Chromium 通常不等待栅格化完成,而是使用称为同步令牌的东西,必须在第 15 步执行之前完成。
  12. 合成器帧被发送到 Viz。
  13. Viz 聚合了 foo.com 渲染进程、bar.com iframe 渲染进程和浏览器 UI 的合成器帧。
  14. Viz 安排绘制。
  15. Viz 将聚合的合成器帧绘制到屏幕上。

在 Tab 2 上使用 CSS transform transition 动画,步骤如下

  1. bar.com 渲染进程的合成器线程通过改变现有属性树在其合成器事件循环中标记动画。然后重新运行合成器生命周期。(可能会发生栅格化和解码任务,但此处未描述。)
  2. 合成器帧被发送到 Viz。
  3. Viz 聚合了 foo.com 渲染进程、bar.com 渲染进程和浏览器 UI 的合成器帧。
  4. Viz 安排绘制。
  5. Viz 将聚合的合成器帧绘制到屏幕上。

在 Tab 3 上滚动网页,步骤如下:

  1. 一系列 input 事件(鼠标、触摸或键盘)进入浏览器进程。
  2. 每个事件都被路由到 baz.com 的渲染进程合成器线程。
  3. 合成器线程确定主线程是否需要知道事件。
  4. 如有必要,将事件发送到主线程。
  5. 主线程触发 input 事件监听器(pointerdowntouchstartpointermovetouchmovewheel)以查看是否会调用 preventDefault
  6. 主线程返回是否 preventDefault 到合成器线程。
  7. 如果不是,则将输入事件发送回浏览器进程。
  8. 浏览器进程通过将输入事件与其他最近事件相结合,将事件转换为滚动手势。
  9. 滚动手势再次发送到 baz.com 的渲染进程合成器线程。
  10. 在合成器线程中执行滚动,baz.com 渲染进程的合成器线程在其合成器事件循环中标记一个动画。然后,这会改变属性树中的滚动偏移并重新运行合成器生命周期。它还告诉主线程触发一个 scroll 事件(此处未描述)。
  11. 合成器帧被发送到 Viz。
  12. Viz 聚合了 foo.com 渲染进程、bar.com 渲染进程和浏览器 UI 的合成器帧。
  13. Viz 安排绘制。
  14. Viz 将聚合的合成器帧绘制到屏幕上。

请注意,第一个之后的每个输入事件都可以跳过步骤三和四,因为滚动已经开始,此时脚本可以通过 scroll 事件观察它,但不再中断。

在 Tab 1 上的 iframe #two 中的超链接上路由事件:click,步骤如下:

  1. 一个 input 事件(鼠标、触摸或键盘)进入浏览器进程。它执行近似命中测试以确定 bar.com iframe 渲染进程应该接收点击,并将其发送到那里。
  2. bar.com 的合成器线程将 click 事件路由到 bar.com 的主线程,并安排渲染事件循环任务来处理它。
  3. bar.com 的主线程的输入事件处理器执行命中测试,以确定 iframe 中的哪个 DOM 元素被单击,并触发一个 click 事件以供脚本使用。如果发现没有 preventDefault,就导航到超链接。
  4. 加载超链接的目标页面后,将呈现新状态,其步骤类似于上面的“呈现更改的 DOM”示例。(此处未描述这些后续更改)

总结

呼~我们聊了好多细节。如你所见,在 Chromium 中渲染非常复杂!记住和内化所有部分可能需要很多时间,所以如果它看起来难以承受,请不要担心。

最重要的一点是,有一个概念上简单的渲染流水线,通过仔细的模块化和对细节的关注,它被分割成许多独立的组件。然后将这些组件拆分为并行进程和线程,以最大限度地提高可伸缩性能和可扩展性的机会。

这些组件中的每一个都在实现现代 Web 应用程序所需的所有性能和功能方面发挥着关键作用。很快,我们将深入探讨它们中的每一个,以及它们所扮演的重要角色。

但在此之前,我还将解释本文中提到的关键数据结构(在渲染管线图两侧以蓝色表示的数据结构),它们对于 RenderingNG 来说与组件一样重要。

感谢阅读,敬请期待!

原文链接:developer.chrome.com/blog/render…