Chrome V8 javascript engine C++ 高性能垃圾收集器 Oilpan原创
过去我们已经写过关于 JavaScript 的垃圾收集、文档对象模型 (DOM) 以及所有这些如何在 V8 中实现和优化的文章。 不过,Chromium 中并非所有内容都是 JavaScript,因为大多数浏览器及其嵌入 V8 的 Blink 渲染引擎都是用 C++ 编写的。 JavaScript 可用于与 DOM 交互,然后由渲染管道处理。
由于 DOM 周围的 C++ 对象图与 Javascript 对象严重纠缠在一起,因此 Chromium 团队在几年前切换到称为 Oilpan 的垃圾收集器来管理这种内存。 Oilpan 是一个用 C++ 编写的垃圾收集器,用于管理可以连接到 V8 的 C++ 内存,使用跨组件跟踪将纠结的 C++/JavaScript 对象图视为一个堆。
这篇文章是 Oilpan 系列文章,它将概述 Oilpan 的核心原理及其 C++ API。 在这篇文章中,我们将介绍一些支持的特性,解释它们如何与垃圾收集器的各种子系统交互,并深入探讨在清扫器中并发回收对象。
最令人兴奋的是,Oilpan 目前在 Blink 中实现,但以垃圾收集库的形式迁移到 V8。 目标是让所有 V8 嵌入者和更多 C++ 开发人员都能轻松使用 C++ 垃圾收集。
背景知识
Oilpan 实现了一个 Mark-Sweep 垃圾收集器,其中垃圾收集分为两个阶段:标记托管堆中扫描活动对象的位置,以及清理托管堆上的死对象被回收的位置。
在 V8 中引入并发标记时,我们已经介绍了标记的基础知识。 回顾一下,扫描所有对象以查找活动对象可以看作是图遍历,其中对象是节点,对象之间的指针是边。 遍历从寄存器、本机执行堆栈(我们从现在开始称为堆栈)和其他全局变量的根开始,如此处所述。
C++ 在这方面与 JavaScript 没有什么不同。 与 JavaScript 相比,C++ 对象是静态类型的,因此无法在运行时更改它们的表示。 使用 Oilpan 管理的 C++ 对象利用了这一事实,并通过访问者模式提供了指向其他对象(图中的边)的指针的描述。 描述 Oilpan 对象的基本模式如下:
class LinkedNode final : public GarbageCollected<LinkedNode> {
public:
LinkedNode(LinkedNode* next, int value) : next_(next), value_(value) {}
void Trace(Visitor* visitor) const {
visitor->Trace(next_);
}
private:
Member<LinkedNode> next_;
int value_;
};
LinkedNode* CreateNodes() {
LinkedNode* first_node = MakeGarbageCollected<LinkedNode>(nullptr, 1);
LinkedNode* second_node = MakeGarbageCollected<LinkedNode>(first_node, 2);
return second_node;
}
在上面的示例中,LinkedNode 由 Oilpan 管理,如继承自 GarbageCollected<LinkedNode> 所示。 当垃圾收集器处理一个对象时,它通过调用对象的 Trace 方法来发现传出指针。 类型 Member 是一个智能指针,在语法上类似于例如 std::shared_ptr,由 Oilpan 提供,用于在标记期间遍历图形时保持一致的状态。 所有这些都使 Oilpan 能够准确地知道指针驻留在其托管对象中的位置。
热衷的读者可能已经注意到并且可能会害怕 first_node 和 second_node 在上面的示例中作为原始 C++ 指针保存在堆栈上。 Oilpan 没有添加使用堆栈的抽象,仅依靠保守的堆栈扫描来在处理根时找到指向其托管堆的指针。 这通过逐字迭代堆栈并将这些字解释为指向托管堆的指针来工作。 这意味着 Oilpan 不会对访问堆栈分配的对象施加性能损失。 相反,它将成本转移到垃圾收集时间,在该时间它保守地扫描堆栈。 集成在渲染器中的 Oilpan 尝试延迟垃圾收集,直到它达到保证没有有趣堆栈的状态。 由于 Web 是基于事件的,并且执行是由事件循环中的处理任务驱动的,因此这样的机会很多。
Oilpan 用于 Blink 中,这是一个大型 C++ 代码库,拥有大量成熟代码,因此还支持:
- 通过混入和对此类混入(内部指针)的引用进行多重继承
- 在执行构造函数期间触发垃圾回收
- 通过被视为根的持久智能指针使对象从非托管内存中保持活动状态
- 集合涵盖顺序(例如向量)和关联(例如集合和映射)容器以及集合支持的压缩
- 弱引用、弱回调和Ephemeron
- 在回收单个对象之前执行的终结器回调
C++扫描
我们假设标记已完成,并且 Oilpan 已借助其 Trace 方法发现了所有可到达的对象。 在标记所有可达对象后,它们的标记位被设置。
清除现在是回收死对象(标记期间无法访问的对象)并将其底层内存返回给操作系统或可供后续分配使用的阶段。 在下文中,我们从使用和约束的角度展示了 Oilpan 的清扫机如何工作,以及它如何实现高回收吞吐量。
清扫器通过迭代堆内存并检查标记位来找到死对象。 为了保留 C++ 语义,清扫器必须在释放其内存之前调用每个死对象的析构函数。 非平凡的析构函数被实现为终结器。
从程序员的角度来看,没有定义析构函数的执行顺序,因为清扫器使用的迭代不考虑构造顺序。 这施加了一个限制,即不允许终结器接触其他堆上对象。 这是编写需要终结顺序的用户代码的常见挑战,因为托管语言通常不支持其终结语义中的顺序(例如 Java)。 Oilpan 使用 Clang 插件静态验证,除其他外,在对象销毁期间没有访问堆对象:
对于好奇:Oilpan 为需要在对象被销毁之前访问堆的复杂用例提供预终结回调。 尽管这样的回调在每个垃圾回收周期上比析构函数施加了更多的开销,并且仅在 Blink 中很少使用。
增量和并发扫描
现在我们已经介绍了托管 C++ 环境中析构函数的限制,是时候更详细地了解 Oilpan 如何实现和优化扫描阶段了。
在深入了解细节之前,重要的是要回顾一下程序通常是如何在 Web 上执行的。 任何执行,例如 JavaScript 程序以及垃圾收集,都是通过在事件循环中调度任务从主线程驱动的。 渲染器与其他应用程序环境非常相似,支持与主线程并发运行的后台任务,以帮助处理任何主线程工作。
开始很简单,Oilpan 最初实现了 stop-the-world 扫描,它作为垃圾收集终结暂停的一部分运行,中断了主线程上应用程序的执行:
Stop-the-world 扫描
对于具有软实时约束的应用程序,处理垃圾收集时的决定因素是延迟。 Stop-the-world 扫描可能会导致显着的暂停时间,从而导致用户可见的应用程序延迟。 作为减少延迟的下一步,扫描是增量的:
使用增量方法,扫描被拆分并委托给额外的主线程任务。 在最好的情况下,此类任务完全在空闲时间执行,避免干扰任何常规应用程序执行。 在内部,清扫器根据页面的概念将工作分成更小的单元。 页面可以处于两种有趣的状态:清扫器仍需要处理的待扫描页面,以及清扫器已经处理的已扫描页面。 分配仅考虑已扫描的页面,并将从维护可用内存块列表的空闲列表中重新填充本地分配缓冲区 (LAB)。 为了从空闲列表中获取内存,应用程序将首先尝试在已扫描页面中查找内存,然后尝试通过将扫描算法内联到分配中来帮助处理待扫描页面,并且仅在没有内存的情况下从操作系统请求新内存。
Oilpan 多年来一直使用增量扫描,但随着应用程序及其生成的对象图变得越来越大,扫描开始影响应用程序性能。 为了改进增量扫描,我们开始利用后台任务来并发回收内存。 有两个基本不变量用于排除执行清扫器的后台任务和分配新对象的应用程序之间的任何数据竞争:
清扫器只处理应用程序无法访问的死内存。
该应用程序仅分配已清扫的页面,根据定义,清扫器不再处理这些页面。
这两个不变量都确保不存在对象及其内存的竞争者。 不幸的是,C++ 严重依赖作为终结器实现的析构函数。 Oilpan 强制终结器在主线程上运行,以帮助开发人员并排除应用程序代码本身内的数据竞争。 为了解决这个问题,Oilpan 将对象终结推迟到主线程。 更具体地说,每当并发清扫器遇到具有终结器(析构函数)的对象时,它会将其推送到终结队列中,该终结队列将在单独的终结阶段进行处理,该阶段始终在运行应用程序的主线程上执行。 并发扫描的整体工作流程如下所示:
由于终结器可能需要访问所有对象的有效负载,因此将相应的内存添加到空闲列表会延迟到执行终结器之后。 如果没有执行终结器,则在后台线程上运行的清扫器会立即将回收的内存添加到空闲列表中。
结果
后台扫描已在 Chrome M78 中提供。 我们的实际基准测试框架显示主线程扫描时间减少了 25%-50%(平均 42%)。 请参阅下面一组选定的数据项。
在主线程上花费的剩余时间用于执行终结器。 正在减少 Blink 中大量实例化对象类型的终结器。 这里令人兴奋的部分是所有这些优化都是在应用程序代码中完成的,因为在没有终结器的情况下,扫描会自动调整。
相关阅读