引擎V8推出“并发标记”,可节省60%-70%的GC时间

做者|V8 博客 译者|覃云 昨日,V8 官方博客宣布 V8 引擎在 GC 技术上得到重大突破,这项技术名为“并发标记( concurrent marking)”,在 GC 扫描和标记活动对象时,它容许 JavaScript 应用程序继续运行。测试显示,并发标记技术为主线程标记节省了 60%-70%的时间。并发标记是一个用新的平行和并发的 GC 替换旧的 GC 的项目,如今 Chrome 64 和 Node.js v10 已经默认启用并发标记。 背景

标记是 V8 Mark-Compact GC 工做的一个阶段。在这个阶段中,收集器发现并标记全部活动对象。标记从一组已知的活动对象开始,如全局对象和激活函数,即所谓的 roots,收集器将 roots 标记为活动的对象,并顺着指针去寻找发现更多的活动对象。收集器继续标记新发现的对象并跟随指针移动,直到没有发现更多的对象要标记为止。在标记结束时,全部没法让应用程序访问的未标记对象,均可以安全地回收。html

咱们能够将标记视为图遍历(Graph traversal)。堆内存上的对象是下图中的节点,指针从一个对象指向另外一个对象是图的边缘。给定图中的一个节点,咱们可使用该对象的隐藏类找到该节点的全部外边缘。算法

V8 使用每一个对象的两个 mark-bits 和一个标记工做表来实现标记。两个 mark-bits 编码三种颜色:白色(00),灰色(10)和黑色(11)。最初全部对象都是白色的,这意味着收集器尚未发现它们。当收集器发现它并将其推到标记工做表上时,白色对象变灰。当收集器将它从标记工做列表中弹出并访问其所有字段时,灰色对象变黑,这种方案被称为三色标记法。当没有灰色对象时,标记结束。全部剩余的白色对象均可以安全地被回收。安全

请注意,上述标记算法仅适用于在标记进行中应用程序暂停的状况。若是咱们容许应用程序在标记过程当中运行,那么应用程序能够更改图形并最终诱骗收集器释放活动对象。markdown

减小标记停顿

对大型的堆内存来讲,可能须要几百毫秒才能完成一次标记。数据结构

长时间的停顿可能会致使应用程序没法响应,并致使用户体验不佳。2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工做分解为更小的模块,并容许应用程序在模块之间运行:多线程

GC 决定每一个模块中执行多少增量标记以匹配应用程序的分配速率。通常状况下,这极大地提升了应用程序的响应速度。但对于大型堆内存来讲,收集器试图跟上应用程序分配速率的过程当中,仍然可能会有长时间的停顿。并发

再者增量标记并非免费的,应用程序必须通知 GC 关于更改对象图的全部操做。V8 使用 Dijkstra-style write-barrier 来实现通知,在每次用 JavaScript 写入 object.field = value 以后,V8 插入 write-barrier 代码:函数

// Called after `object.field = value`. write_barrier(object, field_offset, value) {  if (color(object) == black && color(value) == white) {    set_color(value, grey);    marking_worklist.push(value);  } }复制代码// Called after `object.field = value`. write_barrier(object, field_offset, value) {  if (color(object) == black && color(value) == white) {    set_color(value, grey);    marking_worklist.push(value);  } }

增量标记很好地集成了 GC 的闲置时间(idle time)。Chrome 的 Blink 任务调度程序在主线程的闲置时间内能够调度小增量标记步骤,并且不会形成混乱。若是闲置时间可用,优化效果会很是好。布局

因为 write-barrier 会有消耗,增量标记可能会下降应用程序的吞吐量。经过使用额外的 worker threads 能够提升吞吐量和暂停时间。有两种方法能够在 worker threads 上进行标记:平行标记(parallel marking)和并发标记(concurrent marking)。性能

平行标记发生在主线程和工做线程(worker threads)上,应用程序在整个平行标记阶段暂停,它是 stop-the-world 标记的多线程版本。

并发标记主要发生在工做线程上,当并发标记进行时,应用程序能够继续运行。

如下两节将讲述如何在 V8 中添加对平行标记和并行标记的支持。

平行标记

在平行期间,咱们能够假定应用程序没有运行。这大大简化了实现过程,由于咱们能够假定对象图是静态的而且不会发生变化。为了平行标记对象图,咱们须要确保 GC 数据结构是线程安全的,并找到一种方法有效地在线程之间共享标记工做。下图显示了平行标记所涉及的数据结构。箭头指示数据流的方向,为简单起见,该图省略了整理堆内存碎片所需的数据结构。

须要注意的是,线程只能从对象图中读取而且不会被更改。对象的标记位点和标记工做表必须支持读取和写入的访问。

并发标记

当工做线程正访问堆内存上的对象时,并发标记容许 JavaScript 在主线程上运行,这为许多潜在的数据竞争(data races) 打开了大门。例如,当工做线程正在读取字段时,JavaScript 可能正在写入对象字段。数据竞争可能会让 GC 错误地释放活动对象或将原始值与指针混合在一块儿。

主线程上每一个更改对象图的操做都是数据竞争的潜在来源。因为 V8 是一款高性能引擎,具备许多对象布局优化功能,所以潜在的数据竞争来源不少。如下是可能致使的部分结果:

  • 对象分配

  • 写入一个对象字段

  • 对象布局更改

  • 从 snapshot 中反序列化

  • Materialization during deoptimization of a function.

  • 在新一代 GC 中疏离(Evacuation)

  • 代码修补

主线程须要与工做线程同步,同步的成本和复杂程度取决于操做。

  Write barrier

写入对象字段致使的数据竞争,可将写入操做调整为 atomic write,并调整 write barrier 来解决:

  保释清单(Bailout worklist)

某些操做(例如代码修补)须要独家访问该对象。早期,咱们决定避免对象锁定,由于它们可能致使优先级逆转( priority inversion)问题,在这个过程当中,主线程必须等待一个由于持有锁定对象而被取消调度的工做线程。咱们不锁定对象,而是容许工做线程访问该对象。工做线程经过将对象推入保释清单来完成该工做,这个过程只能由主线程来处理:

工做线程保释了优化的代码对象、隐藏类和 weak collections,由于访问它们须要锁定或高昂的同步协议。

回顾过去,保释清单对增量开发来讲很是有用,咱们开始使用工做线程来释放全部对象类型并逐个添加并发标记。

  更改对象布局

对象的字段能够存储三种值:标记的指针、标记的小整数(也称为 Smi),或未标记的值(如拆箱的浮点数)。

经过将对象转换为另外一个隐藏类,V8 中将对象字段从标记的状态变为未标记的状态(反之亦然),这种更改对象布局的方式对并发标记来讲是不安全的。

若是在工做线程中使用旧的隐藏类访问对象时发生更改,则可能会出现两种类型的错误。首先,worker 可能会错过一个指针,认为这是一个没有标记的值。write barrier 能够防止这种错误。其次,worker 可能会将未标记的值视为指针并放弃引用它,这会致使无效的内存访问,一般会致使程序崩溃。为了处理这种状况,咱们使用在对象标记位上同步的 snapshotting 协议。协议涉及两方面:主线程将对象字段从标记变为未标记,而后工做线程访问该对象。在更改字段以前,主线程会确保该对象被标记为黑色,并将其推入保释清单中供之后访问:

atomic_color_transition(object, white, grey); if (atomic_color_transition(object, grey, black)) {  // The object will be revisited on the main thread during draining  // of the bailout worklist.  bailout_worklist.push(object); } unsafe_object_layout_change(object);复制代码atomic_color_transition(object, white, grey); if (atomic_color_transition(object, grey, black)) {  // The object will be revisited on the main thread during draining  // of the bailout worklist.  bailout_worklist.push(object); } unsafe_object_layout_change(object);

以下面的代码片断所示,工做线程首先加载对象的隐藏类,并使用 atomic relaxed 加载操做来快照(snapshots)隐藏类指定对象中的全部指针字段。而后它会尝试使用 atomic compare 和 swap 操做将对象标记为黑色。若是标记成功,则意味着快照必须与隐藏类一致,由于主线程在更改其布局以前会将对象标记为黑色。

snapshot = []; hidden_class = atomic_relaxed_load(&object.hidden_class); for (field_offset in pointer_field_offsets(hidden_class)) {  pointer = atomic_relaxed_load(object + field_offset);  snapshot.add(field_offset, pointer); } if (atomic_color_transition(object, grey, black)) {  visit_pointers(snapshot); }复制代码snapshot = []; hidden_class = atomic_relaxed_load(&object.hidden_class); for (field_offset in pointer_field_offsets(hidden_class)) {  pointer = atomic_relaxed_load(object + field_offset);  snapshot.add(field_offset, pointer); } if (atomic_color_transition(object, grey, black)) {  visit_pointers(snapshot); }
放在一块儿

咱们将并发标记整合到现有的增量标记基础设施中,主线程经过扫描 roots 并填充标记工做表来启动标记。以后,它会在工做线程上发布并发标记任务。工做线程经过合做清空(draining)标记工做表以加快主线程标记进度。主线程偶尔也会经过处理保释清单和标记工做表参与标记。标记工做表变空后,主线程完成 GC。在最终肯定以前,主线程从新扫描 roots ,可能会发现更多的白色对象,这些对象在工做线程的帮助下被平行标记。

结果

测试结果显示移动和桌面上每一个 GC 周期的主线程标记时间分别减小了 65%和 70%。

最后,咱们须要说的是 Node.js v10 现已支持并发标记。

  原文连接

https://v8project.blogspot.com/2018/06/concurrent-marking.html

相关文章
相关标签/搜索