# Write Barrier

# write barrier 如何解决误回收问题

为了排除干扰,在垃圾回收的时候,mutator 需要停止运行,但是这对一些对延迟要求比较高的程序来说是不利的。但是如果垃圾回收过程中,mutator 持续运行,如果不加以一些措施,就会导致错误回收,引发程序崩溃或运行结果出错(结果出错比程序崩溃是更严重的事情)。
为了方便描述,先引入三色模型,一个对象在扫描和标记阶段的状态可以用三色模型去表示,白色代表对象还没有被标记,也没有被扫描(可能存活),黑色表示对象已经被标记且扫描(肯定存活),灰色是中间态,表示对象已经被标记,但是还没被扫描。
接下来先看一个错误回收的例子

误回收

如上图所示,从左到右是按时间顺序排列的对象之间的关系图。左边是初始状况,此时A对象是黑色的对象,B对象是灰色的,其有一个到C对象的引用,但是B对象还没有开始扫描,在扫描B对象之前,mutator 执行了一些操作,第二个图中,把A对象中原来指向B的指针a改为指向 C,第三个图中,把B对象的b指针改为了指向nil,去除了对C对象的引用。第四个图中,B对象被扫描,但是因为此时B对象没有到C对象的引用,所以C对象还是白色,没有变成灰色,虽然A对象的c指针指着C对象,但是因为A对象已经是黑色对象了,所以collector不会再扫描A对象了,C对象被误回收了。

上述的问题可以使用写屏障解决(write barrier) (与内存屏障中的写屏障不是同一个东西),除了写屏障,还有读屏障,不过golang中并没有使用,所以就不展开了。写屏障指的是在写指针时,对被写的指针或写进去的地址做一些处理,下图列出了两种处理方。

解决误回收

在 write(A.c ,C )的时候,可以采取方式b ,把C对象直接变成灰色对象,又或者是采取c方式,把A对象从黑色会退到灰色状态,这样A对象就会被重新扫描,C对象就不会被遗漏。
Shade 和 Revert 两种方式都可以解决问题,不过Shade的方式,会比Revert更快结束垃圾回收,因为Revert需要重复扫描对象。
写屏障只需要在扫描的阶段才需要开启,其余时候处于关闭状态。Golang 编译器会在有写指针操作的地方插入这段逻辑(有些也会被优化掉)。

下边是编译器插入写屏障的例子,使用 go build -gcflags '-l' main.go编译下图代码,-l禁止内联,这样方便观察test函数。
example

test函数的plan9汇编如下图,runtime.writeBarrier就是表示写屏障是否开启的变量,gcWriteBarrier是汇编写的函数,调用时需要传入 slot (被写的指针) 和 ptr (地址)两个参数。gcWriteBarrier 会处理写屏障的逻辑,对slot 和 ptr 做一些处理,在讲处理细节之前,先看一个更复杂的情况。
assembly

在扫描的时候,栈是整个整个扫描的,也就是栈中的对象要么都是灰色,要么都是黑色。并且,golang为了提高性能,关闭了stack write barrier ,也就是当写栈上的指针变量时,不会触发写屏障(不是绝对的,如果栈上指针的地址传到了一个函数中,函数对这个地址写指针,还是会触发的写屏障的)。考虑下图的情况,起初有一个A对象其指针 c指向B,下图中的第二张图中,栈中的一个对象引用了B对象,第三张图中,A对象删除了对B对象的引用,此时,唯一一个指向B对象的指针在栈中,但是因为栈已经被扫描过了,所以B对象会一直是白色,最终被误回收
stack

这个问题可以通过给B着色解决,上图中的第二张图写的是栈中的指针,不会触发写屏障,不过上图中的第三张图会触发写屏障,执行Write(A.c ,nil ) ,Write(A.c,nil)操作中包含Shade (*A.c),把B变成灰色。此场景中,我们通过在Wirte(slot , ptr) 中添加 Shade( *slot )的方式,消灭了在已经扫描过的栈中藏匿白色对象的可能性。如果不这样做,只能重新扫描栈,但又是一大笔开销。

再看下图这个场景,第一行从左到右描述的是把一个存在未扫描栈中的指针移动到以后已经扫描的堆对象中的过程,因为写的是堆是开启写屏障的,所以会触发写屏障的逻辑,此时如果Write(C.d,A.c)还是和上一个场景一样,只执行Shade(*C.d)的话,完全不能解决问题,看仔细,在把B的地址写到C.d 之前,C.d 是nil。所以在这种情况下,要执行第二行图片中的Shade(*A.c ) 操作,这样才能确保最终B对象被标记上
pic

综合上述两个场景,写一个堆中的指针 Write(slot , ptr) ,写屏障中需要 Shade(*slot) 、Shade(ptr)

code

不过这两个并不一定是全都要的(只是从理论上而言,golang的实现中,还是全都做了Shade操作),Shade( *slot )操作保证了已经扫描过的栈中的指针指向的对象也一定是非白色的,所以Shade( ptr ) 这个操作可以按情况而定,如果栈已经全都被扫描过了,也就没有什么指向白色对象的指针了,也就不可能发生白色对象的引用被藏进已经扫描过的堆对象中的情况。

code
上图这段源码把新旧(*slot 、ptr)两个指针放到了write barrier buffer 中,前文提到的gcWriteBarrier做的事情与putFast类似,只不过是汇编形式,并且多了一个flush buffer的操作。

# write barrier buffer 和 work buffer 的实现

code

上图是 wirte barrier buffer 的结构体,buf 是一个 长度为512 的指针数组,因为每次写屏障会写入新旧两个指针,所以 256 次就会填满这个数组。next 和 end 都是指针,而不是下标,这样方便了汇编代码的实现, next 指向buf 中的下一个可用的空间。end指向的是buffer的末尾
当write barrier buffer 满了以后会执行flush 操作,把这些指针指向的对象都标记上,并且把这些对象放到work buffer。
work buffer 就是用来存放灰色对象的,work buffer 和 write barrier buffer 一样,都是per-P 的结构,每个 P 有一个write barrier buffer ,不过有两个wrok buffer。
在标记阶段,会有灰色对象被放到work buffer中,同时也会有消费者从work buffer中取出灰色对象对其扫描。如果本地的 work buffer 被生产者堆满了(当前这个 P的称作本地),就会把这个work buffer 放到全局队列中,同理,如果本地的这个work buffer 被消费者清空了,消费者会从全局队列中取一个work buffer 继续消费。
设计两个work buffer主要是为了减少对全局队列的操作,减少竞争带来的性能损耗。假设只有一个work buffer , 消费者把work buffer 装满以后放到了全局队列,并启用了一个新的work buffer 替换原来的,此时work buffer是空的,消费者就不能消费,于是他又会从全局队列中取出一个满的work buffer 替换本地的。两个work buffer 就可以解决这个问题,无论对于生产这还是消费者,都只对第一个buffer进行操作,如果第一个buffer被清空或者放满了,就交换,把第二个buffer作为第一个buffer,第一个 buffer作为第二个buffer。