golang 垃圾回收(五)深入剖析混合写屏障

本文深入探讨了golang的混合写屏障,解释了其由来和解决的问题。混合写屏障结合了插入写屏障和删除写屏障的特点,避免了STW扫描,提高了性能。文章介绍了插入写屏障可能导致的问题,以及删除写屏障的限制,同时阐述了混合写屏障如何通过插入写屏障的逻辑来防止数据丢失,实现并发扫描栈而不影响整个系统。
摘要由CSDN通过智能技术生成

layout: post
title: “golang 垃圾回收(五)混合写屏障”
date: 2020-7-25 1:44:09 +0800
categories: golang GC 垃圾回收 写屏障


golang 混合写屏障

网上很多文章对 golang 实现的插入写屏障说的挺详细,但是删除写屏障原理和混合写屏障由来语焉不详,这篇文章目标是弄明白混合写屏障到底是怎么来的,解决什么问题,插入写屏障,删除写屏障 的特点分别是什么?

基础知识复习前四篇文章:

  1. golang 垃圾回收(一)概述
  2. golang 垃圾回收(二)屏障技术
  3. golang 垃圾回收(三)插入写屏障
  4. golang 垃圾回收(四)删除写屏障

首先明白一个前提,栈上,寄存器对象的赋值(插入,删除)不能 hook ,这个因为实力不允许(太复杂,做不到)。

插入写屏障

Dijkstra屏障

伪代码:

writePointer(slot, ptr):
    shade(ptr)
    *slot = ptr

总结

  1. 如果是纯粹的插入写屏障是满足强三色不变式的(永远不会出现黑色对象指向白色对象);
  2. 但是由于栈上对象无写屏障(不 hook),那么导致黑色的栈可能指向白色的堆对象,所以必须假定赋值器(mutator)是灰色赋值器,扫描结束之后,必须 STW 重新扫描栈才能确保不丢对象;
  3. STW 重新扫描栈再 goroutine 量大且活跃的场景,延迟不可控,经验值平均 10-100ms;

golang 1.5 之后实现的就是这种类型的插入写屏障。

删除写屏障

Yuasa-style 屏障

伪代码:

writePointer(slot, ptr)
    shade(*slot)
    *slot = ptr

总结

  1. 删除写屏障也叫基于快照的写屏障方案,必须在起始时,STW 扫描整个栈(注意了,是所有的 goroutine 栈),保证所有堆上在用的对象都处于灰色保护下,保证的是弱三色不变式;
  2. 由于起始快照的原因,起始也是执行 STW,删除写屏障不适用于栈特别大的场景,栈越大,STW 扫描时间越长,对于现代服务器上的程序来说,栈地址空间都很大,所以删除写屏障都不适用,一般适用于很小的栈内存,比如嵌入式,物联网的一些程序;
  3. 并且删除写屏障会导致扫描进度(波面)的后退,所以扫描精度不如插入写屏障;

思考问题:我不整机暂停 STW 栈,而是一个栈一个栈的快照,这样也没有 STW 了,是否可以满足要求?(这个就是当前 golang 混合写屏障的时候做的哈,虽然没有 STW 了,但是扫描到某一个具体的栈的时候,还是要暂停这一个 goroutine 的)

不行,纯粹的删除写屏障,起始必须整个栈打快照,要把所有的堆对象都处于灰色保护中才行。

举例:如果没有把栈完全扫黑,那么可能出现丢数据,如下:

初始状态

  1. A 是 g1 栈的一个对象,g1栈已经扫描完了,并且 C 也是扫黑了的对象;
  2. B 是 g2 栈的对象,指向了 C 和 D,g2 完全还没扫描,B 是一个灰色对象,D 是白色对象;

关注我公众号, 获取更多干货

步骤一:g2 进行赋值变更,把 C 指向 D 对象,这个时候黑色的 C 就指向了白色的 D(由于是删除屏障,这里是不会触发hook的)

步骤二:把 B 指向 C 的引用删除,由于是栈对象操作,不会触发删除写屏障;

关注我公众号, 获取更多干货

步骤三:清理,因为 C 已经是黑色对象了,所以不会再扫描,所以 D 就会被错误的清理掉。

解决办法有如下

方法一:栈上对象也 hook,所有对象赋值(插入,删除)都 hook(这个就不实际了);

所有的插入,删除如果都 hook ,那么一定都不会有问题,虽然本轮精度很差,但是下轮回收可以回收了。但是还是那句话,栈,寄存器的赋值 hook 是不现实的。

方法二:起始快照整栈跨找,扫黑,使得整个堆上的在用对象都处于灰色保护;

整栈扫黑,那么在用的堆上的对象是一定处于灰色堆对象的保护下的,之后配合堆对象删除写屏障就能保证在用对象不丢失。

方法三:加入插入写屏障的逻辑,C 指向 D 的时候,把 D 置灰,这样扫描也没问题。这样就能去掉起始 STW 扫描,从而可以并发,一个一个栈扫描。

细品下,这不就成了当前在用的混合写屏障了,所以我觉得正确的理解方式应该是:混合写屏障 = 删除写屏障 + 插入写屏障,必须先理解下删除写屏障,你才能理解混合写屏障。

混合写屏障

golang 1.5 之后已经实现了插入写屏障,但是由于栈对象赋值无法 hook 的原因,导致扫描完之后还有一次 STW 重新扫描栈的整机停顿,混合写屏障就是解决这个问题的。

论文里的伪代码:

writePointer(slot, ptr):
    shade(*slot)
    if current stack is grey:
        shade(ptr)
    *slot = ptr

golang 实际实现的伪代码:

writePointer(slot, ptr):
    shade(*slot)
    shade(ptr)
    *slot = ptr

总结

  1. 混合写屏障继承了插入写屏障的优点,起始无需 STW 打快照,直接并发扫描垃圾即可;
  2. 混合写屏障继承了删除写屏障的优点,赋值器是黑色赋值器,扫描过一次就不需要扫描了,这样就消除了插入写屏障时期最后 STW 的重新扫描栈;
  3. 混合写屏障扫描精度继承了删除写屏障,比插入写屏障更低,随着带来的是 GC 过程全程无 STW;
  4. 混合写屏障扫描栈虽然没有 STW,但是扫描某一个具体的栈的时候,还是要停止这个 goroutine 赋值器的工作的哈(针对一个 goroutine 栈来说,是暂停扫的,要么全灰,要么全黑哈,原子状态切换);

思考一个问题:混合写屏障是全面去除 STW 的一个改进,转而并发一个一个栈处理的方式(每个栈单独暂停),从而消除了整机 STW 的影响,带来了吞吐的提升。有没有想过,这种 STW 整个系统栈,而只暂停一个 goroutine 栈会不会有问题,我钻牛角尖的思考过一些奇怪的场景,比如下面:

初始状态

  1. goroutine 1 已经被扫描黑了(由于混合写屏障的场景,赋值器是黑色赋值器,之后不会在扫描了),goroutine 1 上的 A 对象 指向 nil ;
  2. B 唯一指向 C(灰色的栈对象 B,白色的 C 对象);

关注我公众号, 获取更多干货

步骤一:赋值器(g1)把 A 指向 C,赋值器(g2)把 B 指向 nil,删除 B -> C 的引用,由于A,B 都是栈上的对象,所以这里的赋值都不会触发写屏障;

关注我公众号, 获取更多干货

这样操作下来,是不是就有问题了?

黑色的 A 指向了白色的 C,并且是唯一指向。由于我们说混合写屏障这里是黑色赋值器,黑色的 goroutine 1 栈是不会再扫描了,那等扫描完了,C 岂不是被错误回收了 !!!

这里不会哈。因为这种情况不会出现,证明如下:

如果要实现如上所述场景,需要满足条件:

goroutine 1 栈上的黑色对象 A 引用白色对象 C 只有三种场景:

  1. goroutine 1 持有 goroutine 2 栈上的对象 B ;
  2. goroutine 1 对象 C;
  • g1 不能直接持有 B,不同栈的对象是无法相互访问的,否则就会溢出到堆上;
  • g1 不能直接持有 C,直接持有它就违反了假设,并且如果真是直接持有 C,C 一定是灰色的;

旁白,理解一句话,对象不是天上掉下来的,一定是有路可循的。

撇开这个上面的假设,如果 A 非要引用到 C ,可以通过 A 下面的一个对象 X ( X 指向 C)来获取,但如此一来,C 一定会处于灰色保护下;

golang 混合写屏障实现

源码文件

下面以 golang 1.13.3 版本源码演示举例。

golang 当前实际实现的是混合写屏障。这部分的代码在 src/runtime 这个下面,golang 是有运行时(runtime)的概念的,运行时是啥?就是语言层面就形成的管理逻辑,主要工作是 goroutine 创建,调度,销毁,内存分配,垃圾回收等逻辑。有了运行时,就可以保证 golang 业务代码的简单 和 语言本身的强大。

runtime 下有大量的汇编实现的代码,这部分主要是考虑极致的性能,其实大部分还是 golang 的逻辑代码,另外还有如下子目录,分别是 cgo,pprof 的支持等。

Go SDK 1.13.3
├── runtime
│   ├── cgo
│   ├── debug
│   ├── internal
│   ├── msan
│   ├── pprof
│   ├── race
│   └── trace

在 golang 源代码 mbarrier.go 里面有详细说明,但是提一点,golang 的实际实现其实做了一个简化,把 if 条件判断去掉了,这样的实现更简单,精度差一些。

怎么理解精度会差呢?

因为有了删除写屏障的加持,golang 的栈是黑色赋值器,扫描过是不需要再扫描的。所以如果栈式灰色的,才需要置灰色,否则是 pass 的。golang 处于短期实现的一个简单化处理,源码实现其实是没有这个判断的,所以自然精度就会差些,但是实际效果其实还行。

混合写屏障伪代码:

writePointer(slot, ptr):
    // 旧值 置灰
	shade(*slot)
	if current stack is grey:
        // 新值 置灰
		shade(ptr)
	*slot = ptr

关注我公众号, 获取更多干货

golang 实际实现伪代码如下:

writePointer(slot, ptr):
	shade(*slot)
    shade(ptr)
    
	*slot = ptr

也就是是说,如果在垃圾回收阶段,只要是堆上的一个赋值 *slot = ptr 那么都会被 hook 住,然后把旧值(*slot)指向的对象,和新值(ptr)指向的对象都置灰(投到扫描队列)。

这个就是混合写屏障完整的逻辑了。

写屏障代码触发点

重点

  1. 写屏障的代码在编译期间生成好,之后不会再变化;
  2. 堆上对象赋值才会生成写屏障;
  3. 哪些对象分配在栈上,哪些分配在堆上?也是编译期间由编译器决定,这个过程叫做“逃逸分析”;

举例:

main.funcAlloc0

 13 func funcAlloc0 (a *Tstruct) {
 14     a.base = new(BaseStruct)    // new 一个BaseStruct结构体,赋值给 a.base 字段
 15 }

反汇编就能看到编译出的代码,如下:

关注我公众号, 获取更多干货

在合适的位置,编译器给你程序添加的指令函数 runtime·gcWriteBarrier,这个就是入口,每次堆对象赋值,如果开启了垃圾回收开关,都会去里面转一圈。

实现伪代码如下:

if runtime.writeBarrier.enabled {
   
    r
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值