golang-design / under-the-hood

📚 Go: Under The Hood | Go 语言原本 | https://golang.design/under-the-hood

Home Page:https://golang.design/under-the-hood

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

ch08gc/barrier: writePointer 疑问

yangxikun opened this issue · comments

混合写屏障的算法**伪代码:

// 混合写屏障
func writePointer(slot, ptr unsafe.Pointer) {
    shade(*slot)                // 对正在被覆盖的对象进行着色,通过将唯一指针从堆移动到栈来防止赋值器隐藏对象。
    if current stack is grey {  // 如果当前 goroutine 栈还未被扫描为黑色
        shade(ptr)              // 则对引用进行着色,通过将唯一指针从栈移动到堆中的黑色对象来防止赋值器隐藏对象
    }
    *slot = ptr
}

如果当前 goroutine 栈已扫描为黑色,而 ptr 为白色对象,此时如果不对 ptr 着色,是否就误回收了对象?

你好,这个问题不太好解释。这段伪代码其实在实际实现中一直都没有被源码所实现,需要理解整个问题的来龙去脉。

目前书中的内容也还相当不完善。我这里贴一段最近写下的关于写屏障的简单解释,会在日后进一步详细优化后更新到书中。


要讲清楚写屏障,就需要理解三色标记清扫算法中的强弱不变性以及赋值器的颜色
理解他们需要一定的抽象思维。

写屏障是一个在并发垃圾回收器中才会出现的概念,垃圾回收器的正确性体现在:
不应出现对象的丢失,也不应错误的回收还不需要回收的对象。

可以证明,当以下两个条件同时满足时会破坏垃圾回收器的正确性:

  • 条件 1: 赋值器修改对象图,导致某一黑色对象引用白色对象;
  • 条件 2: 从灰色对象出发,到达白色对象的、未经访问过的路径被赋值器破坏。

因此只要能够避免其中任何一个条件,则不会出现对象丢失的情况,因为:

  • 如果条件 1 被避免,则所有白色对象均被灰色对象引用,没有白色对象会被遗漏
  • 如果条件 2 被避免,即便白色对象的指针写入到黑色对象中,但从灰色对象出发,总存在一条没有访问过的路径,从而找到到达白色对象的路径,白色对象最终不会被遗漏

我们不妨将三色不变性所定义的波面根据这两个条件进行削弱:

  • 当满足原有的三色不变性定义(或上面的两个条件都不满足时)的情况称为强三色不变性(strong tricolor invariant)
  • 当赋值器令黑色对象引用白色对象时(满足条件 1 时)的情况称为弱三色不变性(weak tricolor invariant)

当赋值器进一步破坏灰色对象到达白色对象的路径时(进一步满足条件 2 时),即打破弱三色不变性,
则也就破坏了回收器的正确性;或者说,在破坏强弱三色不变性时必须引入额外的辅助操作。
弱三色不变形的好处在于:只要存在未访问的能够到达白色对象的路径,就可以将黑色对象指向白色对象。

如果我们考虑并发的用户态代码,回收器不允许同时停止所有赋值器,
就是涉及了存在的多个不同状态的赋值器。为了对概念加一明确,还需要换一个角度,把回收器视为对象,
赋值器视为影响回收器这一对象的实际行为(即影响 GC 周期的长短),从而还可以引入赋值器的颜色:

  • 黑色赋值器:已经由回收器扫描过,不会再次对其进行扫描
  • 灰色赋值器:尚未被回收器扫描过,或尽管已经扫描过但仍需要重新扫描

赋值器的颜色对回收周期的结束产生影响:

  • 如果某种并发回收器允许灰色赋值器的存在,则必须在回收结束之前重新扫描对象图。
  • 如果重新扫描过程中发现了新的灰色或白色对象,回收器还需要对新发现的对象进行追踪,但是在新追踪的过程中,赋值器仍然可能在其根中插入新的非黑色的引用,如此往复,直到重新扫描过程中没有发现新的白色或灰色对象。

从而在允许灰色赋值器存在的算法,最坏的情况下,
回收器只能将所有赋值器线程停止才能完成其跟对象的完整扫描,也就是我们所说的 STW。
为了确保强弱三色不变性的并发指针更新操作需要通过赋值器屏障技术来保证指针的读写操作一致。
因此我们所说的 Go 中的写屏障、混合写屏障,其实是指赋值器的写屏障,赋值器的写屏障用来
保证赋值器在进行指针写操作时,不会破坏弱三色不变性。

有两种非常经典的写屏障:Dijkstra 插入屏障和 Yuasa 删除屏障。

灰色赋值器的 Dijkstra 插入屏障的基本**是避免满足条件 1:

// 灰色赋值器 Dijkstra 插入屏障
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(ptr)
    *slot = ptr
}

为了防止黑色对象指向白色对象,应该假设 *slot 可能会变为黑色,为了确保 ptr 不会在将赋值为 *slot 前变为白色,
shade(ptr) 会先将指针 ptr 标记为灰色,进而避免了条件 1。
但是,由于并不清楚赋值器以后会不会将这个引用删除,因此还需要重新扫描来重新确定关系图,这时需要 STW,如图所示。

gc-wb-dijkstra

Dijkstra 插入屏障的好处在于可以立刻开始并发标记,但由于产生了灰色赋值器,缺陷是需要标记终止阶段 STW 时进行重新扫描。

黑色赋值器的 Yuasa 删除屏障的基本**是避免满足条件 2:

// 黑色赋值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot)
    *slot = ptr
}

为了防止丢失从灰色对象到白色对象的路径,应该假设 *slot 可能会变为黑色,为了确保 ptr 不会在将赋值为 *slot 前变为白色,shade(*slot) 会先将 *slot 标记为灰色,进而该写操作总是创造了一条灰色到灰色或者灰色到白色对象的路径,进而避免了条件 2。

Yuasa 删除屏障的优势则在于不需要标记结束阶段的重新扫描,缺陷依然会产生丢失的对象,需要在标记开始前对整个对象图进行快照。

gc-wb-yuasa

Go 在 1.8 的时候为了简化 GC 的流程,同时减少标记终止阶段的重扫成本,
将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合,形成混合写屏障。该屏障提出时的基本**是:
对正在被覆盖的对象进行着色,且如果当前栈未扫描完成,则同样对指针进行着色。
但在最终实现时原提案中对 ptr 的着色还额外包含对执行栈的着色检查,但由于
时间有限,并未完整实现过,所以混合写屏障在目前(Go 1.14)的实现伪代码是:

// 混合写屏障
func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) {
	shade(*slot)
	shade(ptr)
	*slot = ptr
}

在这个实现中,如果无条件对引用双方进行着色,自然结合了 Dijkstra 和 Yuasa 写屏障的优势,
但缺点也非常明显,因为着色成本是双倍的,而且编译器需要插入的代码也成倍增加,随之带来的
结果就是编译后的二进制文件大小也进一步增加。为了针对写屏障的性能进行优化,Go 1.10 前后
Go 团队随后实现了批量写屏障机制。其基本想法是将需要着色的指针同一写入一个缓存,
每当缓存满时统一对缓存中的所有 ptr 指针进行着色。

Dijkstra 插入屏障 的图示中:“此时A已经错误的着色,必须结束后的重新扫描”,这里不重新扫描也可以吧?因为可以在下一轮GC,把A回收掉
Yuasa 删除屏障 的图示中:DijkstraWritePointer(A.ref3, B)应该为YuasaWritePointer(C.ref3, B),最后说“此时A已经发生丢失,必须依赖扫描开始前对整个堆栈的备份”,这里A已经没有其他对象引用它了,算是白色对象要被回收的,为何说A丢失了呢?

在上面2个图示中,是不是基于这样一个前提:对象C是栈上的根对象或堆上的从其他灰色对象可达的对象,而A,B都为堆上的对象?

如果Go中目前实现的混合写屏障对被修改对象和被引用对象都标记为灰色,那确实不会出现存活对象丢失的情况了。

因为可以在下一轮GC,把A回收掉

这种说法是不对的,如果每次都是依赖下一轮的 GC 把对象回收掉,则最坏情况下可能对象永远都不会被回收掉,这是 GC 的正确性不满足。

这里A已经没有其他对象引用它了,算是白色对象要被回收的,为何说A丢失了呢?

回收不是平白无故的就能回收,回收对象时,回收器需要知道

  1. 对象是否在这一轮回收中为白色
  2. 能不能找到该对象

Yuasa 的整个例子中整个过程中,A 至始至终都没有被回收器访问到,那么仅从根对象出发,回收器是不可能知道 A 的存在的,也就是 A 的丢失。

Yuasa 删除屏障 的图示中:DijkstraWritePointer(A.ref3, B)应该为YuasaWritePointer(C.ref3, B)

多谢提醒。

这种说法是不对的,如果每次都是依赖下一轮的 GC 把对象回收掉,则最坏情况下可能对象永远都不会被回收掉,这是 GC 的正确性不满足。

当前轮的黑色对象没有被其他对象引用,在下一轮应该就会是白色对象了,怎么会出现 “最坏情况下可能对象永远都不会被回收掉”?

Yuasa 的整个例子中整个过程中,A 至始至终都没有被回收器访问到,那么仅从根对象出发,回收器是不可能知道 A 的存在的,也就是 A 的丢失。

没有被访问到说明是白色对象,回收器可以从内存分配器知道A的存在吧?

在下一轮应该就会是白色对象了

如果下一轮在赋值器的作用下又变为黑色了,就是最坏情况。对于回收器而言,赋值器的操作是不可知的,总是需要考虑最坏情况。

回收器可以从内存分配器知道A的存在吧?

不可以。回收器分配的时候仅仅只是返回可以使用的内存地址以及可以使用的内存大小,如果需要记录分配的所有对象的位置相当于对整个堆的情况进行备份,需要消耗更多的内存空间。

Go 的做法是在垃圾回收时,通过追踪的方式将所有需要回收的对象追踪到,而后将内存返还给内存分配器。

如果下一轮在赋值器的作用下又变为黑色了,就是最坏情况。对于回收器而言,赋值器的操作是不可知的,总是需要考虑最坏情况。

黑色对象已经不可达了,赋值器怎么修改到它呢?

不可以。回收器分配的时候仅仅只是返回可以使用的内存地址以及可以使用的内存大小,如果需要记录分配的所有对象的位置相当于对整个堆的情况进行备份,需要消耗更多的内存空间。

我看Golang源码通过 span 管理对象分配,span 有个 allocBits 负责记录其管理的对象是否分配出去了。释放白色对象,是直接通过 span. allocBits = span.gcmarkBits 实现的。

黑色对象已经不可达了,赋值器怎么修改到它呢?

下一轮 GC 时,所有对象又回到白色状态了,回收器和赋值器之间并不能保证下一轮是否又回到黑色着色。

span 有个 allocBits 负责记录其管理的对象是否分配出去了

没错,所以引入 Yuasa 屏障时就需要在标记开始前对整个对象图进行快照。

closing due to no action to take.

你好,看了你们的讨论,有几个问题:

  1. 如果是 GC 时根据追踪找到对象的话,如果我在 gc 不在执行的时候,直接在堆上分配一个对象,然后马上设为 nil,这时候岂不是无法找到我新分配的对象了?新分配的对象不就丢失了?
  2. Dijkstra 写屏障如果不进行 STW 扫描,根据我的理解,那么在第二轮开始时,A 对象变成了白色,理论上来说这时候无论如何赋值器都无法使 A 回到黑色的(uintptr 直接赋值一个内存地址然后强转 unsafe.Pointer 这种*操作除外),这时候带来的问题应该是 A 的丢失吧?因为这时候没有任何一条路径能够访问到 A,A 又不属于根对象。应该不是由于 A 会一直回到黑色导致的无法回收吧?不知道我的理解对不对?

@yangxikun @PureWhiteWu 前面的回复中由于个人的理解错误,固执的持续回复一些错误的言论,首先对错误的关闭 Issue 表示抱歉,并对提出如此重要的问题表示感谢。


@yangxikun 关于你的提出的质疑,确实是我此前理解的错误,我这里重新给出修改后的描述:

灰色赋值器的 Dijkstra 插入屏障的基本**是避免满足条件 1:

// 灰色赋值器 Dijkstra 插入屏障
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(ptr)
    *slot = ptr
}

为了防止黑色对象指向白色对象,应该假设 *slot 可能会变为黑色,为了确保 ptr 不会在被赋值到 *slot 前变为白色,shade(ptr) 会先将指针 ptr 标记为灰色,进而避免了条件 1。如图所示:

gc-wb-dijkstra

Dijkstra 插入屏障的好处在于可以立刻开始并发标记。但存在两个缺点:

  1. 由于 Dijkstra 插入屏障的“保守”,在一次回收过程中可能会残留一部分对象没有回收成功,只有在下一个回收过程中才会被回收;
  2. 在标记阶段中,每次进行指针赋值操作时,都需要引入写屏障,这无疑会增加大量性能开销;为了避免造成性能问题,Go 团队在最终实现时,没有为所有栈上的指针写操作,启用写屏障,而是当发生栈上的写操作时,将栈标记为灰色,但此举产生了灰色赋值器,将会需要标记终止阶段 STW 时对这些栈进行重新扫描。

另一种比较经典的写屏障是黑色赋值器的 Yuasa 删除屏障。其基本**是避免满足条件 2:

// 黑色赋值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot)
    *slot = ptr
}

为了防止丢失从灰色对象到白色对象的路径,应该假设 *slot 可能会变为黑色,为了确保 ptr 不会在被赋值到 *slot 前变为白色,shade(*slot) 会先将 *slot 标记为灰色,进而该写操作总是创造了一条灰色到灰色或者灰色到白色对象的路径,进而避免了条件 2。

Yuasa 删除屏障的优势则在于不需要标记结束阶段的重新扫描,结束时候能够准确的回收所有需要回收的白色对象。缺陷是 Yuasa 删除屏障会拦截写操作,进而导致波面的退后,产生“冗余”的扫描:

gc-wb-yuasa

Go 在 1.8 的时候为了简化 GC 的流程,同时减少标记终止阶段的重扫成本,将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合,形成混合写屏障。该屏障提出时的基本**是:对正在被覆盖的对象进行着色,且如果当前栈未扫描完成,则同样对指针进行着色。


@PureWhiteWu

  1. 请忽视我此前的错误言论。针对你的问题来回答:被设置为 nil 的对象由于始终保持白色,在一轮 GC 结束后就被回收掉了,你的质疑是正确的。
  2. 请忽视我此前的错误言论。针对你的问题来回答:Dijkstra 屏障需要进行重扫的原因并不是因为其本身就需要重扫,而是由于实际实现中对写屏障调用条件的弱化导致灰色赋值器的产生从而才产生的重扫。你的质疑是正确的。完整的修正参见上面贴出的内容。

@changkun 非常感谢你的回复!让我受益良多!
我仍然有几个疑问,希望能够不吝赐教:

  1. 如果采用 Dijkstra 写屏障,那么带来的问题是:
    a. 有可能部分对象需要下一轮 GC 才能释放,这个我觉得可能不是特别大的问题?(在我所遇到的大部分业务实践中,内存通常不是一个紧张的资源)
    b. 如果对栈上的指针修改也进行写屏障,不考虑性能问题,是否可以不需要标记终止阶段的 STW 重扫?
  2. 如果采用 Yuasa 屏障,问题是:
    a. 是否在极端情况之下,标记将永远无法终止(波面永远在后退)?如果是,这个问题是怎么解决的?
    b. 仍然不太了解为什么 Yuasa 屏障需要在扫描前进行 snapshot,希望能够解答一二。
    c. 不考虑问题 a,是否由于可能导致重复扫描的性能原因,Go 团队没有直接采用 Yuasa 屏障,而是使用了混合写屏障?
  3. 根据描述,目前的混合写屏障没有实现完整,那么如果完整实现了混合写屏障,是否就不需要 STW 了?
  4. 目前的 GC,在扫描开始时和扫描终止阶段的 STW 具体是在做什么?是否能够移除?如果是,那这是一个工程实现复杂度的妥协还是其它原因导致目前存在 STW?
  5. Yuasa 屏障图示中:
    image
    初始状态的 C 是否应该是灰色的?

再次感谢你的分享和解答!

@PureWhiteWu

1a. 这个不是问题,在 On-the-fly Garbage Collection 这篇论文就有明确指出:"new garbage could have been created between an appending phase and the preceding marking phase. We do require: however, that such garbage, existing at the beginning of an appending phase but not identified as such by the collector, will be appended in the next major cycle of the collector. "

1b. 是的,这就是原始的 Dijkstra 写屏障的描述,参见On-the-fly Garbage Collection。Go 的实现只是削弱了其条件

2a. Yuasa 屏障有终止性证明的,具体可以研读其论文:https://core.ac.uk/download/pdf/39218501.pdf

2b. 我目前认为 snapshot 可能一种误解,因为在 Wilson 的论文(Uniprocessor Garbage Collection Techniques https://www.cs.cmu.edu/~fp/courses/15411-f14/misc/wilson94-gc.pdf) 中把插入屏障和删除屏障分别成为增量更新(incremental update)和起始快照(snapshot-at-the-beginning)屏障。
所谓 snapshot,原意指在删除白色对象的指针时将结果通知给回收器。这个过程很像是对什么都还没有发生的对象图(纯白)进行了一次快照。

2c. Yuasa 最大的问题在于其精度太低,这一点在 Write Barrier Elision for Concurrent Garbage Collectors 这篇论文里面起始有相关数据说明,可以细读一下。但是我目前认为 Go 选择混合屏障引入的主要目的是为了在未来支持 ROC(当时 ROC 还没有被证明性能不行),以及原提案中提到的并发标记终止(消除 STW)等几个未来的演进方向。

  1. 完整实现与否与 STW 并无直接联系,目前从标记终止阶段来看,STW 还承载了许多统计功能。

  2. 扫描开始时一方面需要确保上一个阶段的垃圾已经全部回收,另一方面需要等待赋值器代码的全部停止,进行一些统计工作;能否移除是一个 Open Problem,取决于未来 Go 的回收器的发展方向
    对于追踪式的垃圾回收来说,STW 是必然的,因为恶意的用户代码可能对回收器的行为造成伤害;STW 是防止 OOM 的底线。从分代GC的研究进展可以看出,消除 STW 是一个工程问题,并不是说能够彻底的不再执行 STW,而是尽可能的通过一些参数控制、用户代码的行为预测,避免 Full GC,降低触发 STW 的概率。

  3. 嗯,灰色应该更加严谨,展示一个黑色对象指向白色对象的初始状态是一个错误的示范。

十分感谢你抽空进行解答!实在是受益良多!

commented

hi,想请教您一个问题,Yuasa 删除屏障是保证弱三色不变式,他是在指针删除时候才会触发去执行吗?这样子插入行为(假设我直接在黑色对象插入了白色对象)会直接破坏了强三色不变式。是我哪里理解有问题吗?望指教