Skip to content

Commit

Permalink
update gc
Browse files Browse the repository at this point in the history
  • Loading branch information
tiechui1994 committed May 2, 2024
1 parent 053dfc9 commit adb7635
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 141 deletions.
139 changes: 0 additions & 139 deletions develop/memory/gc.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,145 +68,6 @@ D 三个对象, 剩余的 B, E 和 F 三个对象因为从根节点不可达,
本来不该被回收的对象却被回收了, 这在内存管理中是非常严重的错误. 我们将这种错误称为悬挂指针, 即指针没有指向特定类型的合法
对象, 影响了内存的安全性. **想要并发或者增量地标记对象还是想要屏障技术**.

### 屏障技术

内存屏障技术是一种屏障指令,它可以让 CPU 或者编译器在执行内存相关操作时遵循特定的约束, 目前的多数的现代处理器都会乱序执行
以最大化性能, 但是该技术能够保证**代码对内存操作的顺序性,在内存屏障前执行的操作一定会优先于内存屏障后执行的操作**.

想要在并发或者增量的标记中保证正确性, 需要达成以下两种三色不变性中的任意一种:

- 强三色不变性 -- 黑色对象不会指向白色对象, 只会指向灰色对象或者黑色对象;

- 弱三色不变性 -- **黑色对象指向白色对象必须包含一条 `从灰色对象经由多个(可以是0)白色对象的可达路径`**

![image](/images/gc_color_const.jpeg)


上图分别展示了遵循强三色不变性和弱三色不变性的堆内存, 遵循上述的两个不变性中的任意一个, 我们都能包装垃圾收集算法的正确性,
而屏障技术就是并发或者增量标记中保证三色不变性的重要技术.

垃圾收集中的屏障技术更像一个钩子函数, 它是在用户程序读取对象, 创建新对象以及更新对象指针时执行的一段代码, 根据操作类型的
不同, 可以将它们分成读屏障(Reader barrier) 和写屏障(Write barrier) 两种. 因为读屏障需要在读操作中加入代码片段, 对
用户程序的性能影响很大, 所以编程语言往往都会采用写屏障保证三色不变性.

这里介绍的是 Go 语言中使用的两种写屏障技术, 分别是插入写屏障 和 删除写屏障.


#### 插入写屏障

通过如下所示的插入写屏障, 用户程序和垃圾收集器可以交替工作的情况下保证程序执行的正确性:

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

> 每当执行类似 `*slot=pte` 的表达式时, 会指向上述写屏障通过 `shade` 函数尝试改变指针的颜色. 如果 `ptr` 指针是白色
> 的, 那么该函数会将该对象设置为灰色, 其他情况则保持不变.
![image](/images/gc_barrier_insert.png)

假设应用程序中使用插入写屏障, 在一个垃圾收集器和用户程序交替运行的场景中会出现如上图所示的标记过程:

1.垃圾收集器将根对象指向A对象标记为黑色并将A对象指向的对象B标记为灰色;

2.用户程序修改A对象的指针, 将原本指向 B 对象的指针指向 C 对象, 这时触发写屏障将 C 对象标记为灰色;

3.垃圾收集器依次遍历程序中其他的灰色对象, 将它们分别标记为黑色;

插入写屏障是一种相对保守的屏障技术, 它会将 **有存活可能的对象都标记成灰色** 以满足 **强三色不变性**. 在如上所示的垃圾
收集过程中, 实际上不在存活的 B 对象最后没有被回收; 而如果在第二步和第三步之间将指向 C 对象的指针改回指向 B, 垃圾收集器
仍然认为 C 对象是存活的, 这些被错误标记的垃圾只有在下一个阶段才会被回收.


插入写屏障虽然实现非常简单并且也能保证强三色不变性, 但是它也有明显的缺点. 因为栈上的对象在垃圾回收中也会被认为是根对象,
所以保证内存的安全, 插入写屏障必须为栈上的对象增加写屏障或者在标记阶段完成重新对栈上的对象进行扫描, 这两种方法各有缺点,
前者会大幅度增加写入指针的额外开销, 后者重新扫描栈对象时需要暂停程序, 垃圾收集算法的设计需要在两者之间做出权衡.


#### 删除写屏障

一旦删除写屏障开始工作, 它就会保证开启写屏障时堆上所有对象的可达, 所以也被称为快照垃圾收集(Snapshot GC)

该算法会使用如下所示的写屏障保证增量或者并发执行垃圾收集时程序的正确性:

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

上述代码会在老对象的引用被删除时, 将白色的老对象涂为灰色, **这样删除写屏障就可以保证弱三色不变性**, 老对象引用的下游对象
一定可以被灰色对象所引用.

![image](/images/gc_barrier_delete.png)

假设在应用程序中使用删除写屏障, 在一个垃圾收集器和用户程序交替运行的场景中会出现如上图所示的标记过程:

1.垃圾收集器将根对象指向 A 对象标记为黑色并将 A 对象指向的 B 对象标记为灰色;

2.**用户程序将 A 对象原本指向 B 的指针指向 C, 触发删除写屏障, 但是因为 B 对已经是灰色的, 所以不做改变**;

3.**用户将 B 对象原本指向 C 的指针删除, 触发删除写屏障, 白色的 C 对象被标记为灰色**;

4.垃圾收集器依次遍历程序中的其他灰色对象, 将它们分别标记为黑色;

上述过程中的第三步触发了删除写屏障的着色, 因为用户程序删除了 B 指向 C 对象的指针, 所以 C 和 D 两个对象会分别违法强三色
不变性和弱三色不变性:

- 强三色不变性 -- 黑色的 A 对象直接指向白色的 C 对象;

- 弱三色不变性 -- 垃圾收集器无法从某个灰色对象出发, 经过几个连续的白色对象访问白色的 C 和 D 两个对象;

删除写屏障通过对 C 对象着色, 保证 C 对象和下游的 D 对象能够在一次垃圾收集的循环中存活, 避免发生悬挂指针以避免用户程序的
正确性.


### 增量和并发

传统的垃圾收集算法会在垃圾收集的执行期间暂停应用程序, 一旦触发垃圾收集, 垃圾收集器就会抢占 CPU 的使用权占据大量的计算资
源以完成标记和清除工作, 然而很多追求实时的应用程序无法接受长时间的 STW.


为了减少应用程序暂停的最长时间和垃圾收集的总暂停的时间, 会使用以下的策略优化垃圾收集器:

- 增量垃圾收集 -- 增量地标记和清除垃圾, 降低应用程序暂停的最长时间;

- 并发垃圾收集 -- 利用多核的计算资源, 在用户程序执行时并发标记和清除垃圾;

因为增量和并发两种方式都可以与用户程序交替运行, 所以我们需要使用屏障技术保证垃圾收集的正确性; 与此同时, 应用程序也不能等
到内存溢出时触发垃圾收集, 因为当内存不足时, 应用程序已经无法分配内存, 这与直接暂停程序没有任何区别, 增量和并发的垃圾收集
需要提前触发并在内存不足前完成整个循环, 避免程序的长时间暂停.


#### 增量收集器

增量式的垃圾收集是**减少程序最长暂停时间的一种方案**. 它可以将原本时间较长的暂停时间切分成多个更小的 GC 时间片, 虽然从
垃圾收集开始到结束的时间更长了, 但是这也是减少应用程序暂停的最大时间:

![image](/images/gc_incr.png)

> 增量式的垃圾收集需要与三色标记法一起使用, 为了保证垃圾收集的正确性, 需要在垃圾收集开始前打开写屏障, 这样用户程序对内存
> 的修改都会先经过写屏障的处理, 保证堆内存中对象的关系的强三色不变性或者弱三色不变性.
>
> 虽然增量式的垃圾收集能够减少最大的程序暂停时间, 但是增量式收集也会增加一次 GC 循环的总时间, 在垃圾收集期间, 因为写屏障
> 的影响用户程序也需要承担额外的计算开销, 所以增量式的垃圾收集也不是只有优点的.

#### 并发收集器


并发的垃圾收集不仅能够减少程序的最长暂停时间, 还能减少整个垃圾收集阶段的时间, 通过开启读写屏障, 利用多核优势和用户程序并
行指向, 并发垃圾收集器确实能够减少垃圾收集对应用程序的影响.

![image](/images/gc_concur.png)

虽然并发收集器能够与用户程序一起运行, 但是并不是所有阶段都可以与用户程序一起运行, 部分阶段还是需要暂停用户程序的, 不过与
传统的算法相比, 并发的垃圾收集可以将能够并发执行的工作量尽量并发执行; 当然, 因为读写屏障的引入, 并发的垃圾收集器也一定会
带来额外开销, 不仅会增加垃圾收集的总时间, 还会影响用户程序.


### 并发垃圾收集

Go 语言的并发垃圾收集器会在扫描对象之前暂停程序做一些标记对象的准备工作, 其中包括后台标记的垃圾收集器以及开启写屏障,如果
Expand Down
142 changes: 140 additions & 2 deletions develop/memory/gc_barrier.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,146 @@ flush:
- hook 写操作后, 把赋值语句的前后的值都记录下来, 存储在 p.wbBuf 队列
- 当 p.wbBuf 满了之后, 批量刷写到扫描队列(置灰), 也就是将 mspan.gcmarkBits 相关位置进行标记


### 插入写屏障

**对象丢失的必要条件**

之前有提到三色标记法, 如果要想出现对象丢失(错误的回收) 那么必须是同时满足两个条件:

条件1: 赋值器把白色对象的引用写入给黑色对象了(即, 黑色对象指向白色对象了)

条件2: 从灰色对象出发, 最终到达该白色对象的所有路径都被赋值器破坏(换句话说, 这个已经被黑色指向的白色对象, 没有在灰色
对象的保护下)

举个例子:

![image](/images/develop_gc_lostobject.png)

1) 插入写操作: X -> Z
2) 删除写操作: Y -> null
3) 回收操作: scan Y
4) 回收操作: 回收 Z (这就是问题了)

在这个两个条件同时出现的时候, 才会出现对象被错误的回收. 如果破坏了第一个条件, 那么就会消除垃圾错误的回收了, 于是就有了
插入写屏障.

**插入写屏障**

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

> `shade(ptr)` 会将尚未变成灰色或黑色的指针 ptr 标记为灰色. 通过保守的假设 *slot 可能会变为黑色, 并确保 ptr 不会
> 在将赋值为 *slot 前变为白色, 进而确保了强三色不变性.
>
> 不足:
> 1) 由于栈上的对象无写屏障(不hook), 那么导致黑色的栈可能指向白色的堆对象, 所以必须假设赋值器是灰色赋值器, 扫描结束
> 后, 必须 STW 重新扫描栈才能确保不丢对象;
> 2) STW 重新扫描栈, 若 goroutine 量大且活跃的场景, 延迟不可控. 经验值平均 10-100ms
使用了插入写屏障后:

![image](/images/develop_gc_insertwb.png)

### 删除写屏障

三色不变式

强三色: 不允许黑色对象指向白色对象

弱三色: 允许黑色对象指向白色对象, 但必须保证一个前提, 这个白色对象必须处于灰色对象的保护下.

强三色不变式要求黑色赋值器的根只能引用灰色或者黑色对象, 不能引用白色对象(因为黑色赋值器不再被扫描, 引用白色).
弱三色不变式允许黑色赋值器的根引用白色对象, 但前提是白色对象必须处于灰色保护下.

获取赋值器的快照, 意味着回收器需要扫描其根并将其着为黑色. 必须在回收起始阶段完成赋值器快照的获取, 并保证其不持有任何
白色对象. 否则一旦赋值器持有某白色对象的唯一引用并将其写入黑色对象, 然后再删除该指针, 则会违背弱三色不变式的要求. 为
黑色对象增加写屏障可以捕捉这一内存写操作, 但如此依赖, 该方案将退化到强三色不变式的框架下. 因此, 基于其快照的解决方案
将只允许黑色赋值器的存在.

回到之前的那个问题, 如果破坏了第二个条件, 那么就会消除垃圾错误的回收了, 于是就有了删除写屏障.

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

在对象的引用(slot)被删除时, 将白色的对象的引用涂为黑色, 这样删除写屏障就可以保证弱三色不变性, 对象引用的下游对象一定
可以被灰色对象保护.

> 1) 删除写屏障也叫基于快照的写屏障, 必须在开始时, STW 扫描整个栈(注意, 是所有的goroutine栈), **保证所有堆上的在用的
> 对象处于灰色保护下, 保证弱三色不变性**
> 2) 由于起始快照的原因, 起始是执行STW, 删除写屏障不适用于栈特别大的场景, 栈越大, STW 扫描时间越长.
> 3) 删除写屏障会导致扫描进度(波面)后退, 所以扫描精度不如插入写屏障.

使用了删除写屏障后:

![image](/images/develop_gc_insertwb.png)


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

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

初始状态:

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

![image](/images/develop_gc_why_deletewb1.png)

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

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

![image](/images/develop_gc_why_deletewb2.png)

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

解决上述问题的方法:

方法1: 栈对象也 hook, 所有对象赋值(插入, 删除)都 hook. 这种方法可以解决问题, 但是对于栈, 寄存器的赋值 hook 是不现
实的.

方法2: 加入插入写屏障逻辑, C 指向 D 的时候, 将 D 置灰, 这样扫描没有问题. 也可以去掉起始 STW 扫描, 从而可以并发, 一
个一个栈扫描. **这种方式就是当前的混合写屏障 = 删除写屏障 + 插入写屏障**

```
writePointer(slot, ptr)
shade(*slot) # 删除
shade(ptr) # 插入
*slot = ptr
```

> 1) 混合写屏障继承了插入写屏障的优点, 起始无需 STW 快照, 直接并发扫描垃圾即可;
> 2) 混合写屏障继承了删除写屏障的优点, 赋值器是黑色赋值器, 扫描一遍就不需要再扫描了, 这样就消除了插入写屏障时期最后 STW
> 的重新扫描栈;
> 3) 混合写屏障扫描精度继承了删除写屏障, 比插入写屏障更低, 随着带来的是 GC 过程全程无 STW(注, 扫描某一个具体的栈的
> 时候, 是要停止 goroutine 赋值器工作的)
### GC 当中置灰

三色(白, 灰, 黑)三色是抽象出来的概念, 所谓的三色标记法, 在 Go 内部对象并没有保存颜色的属性, 三色对它们只是状态的描述,
是通过一个队列+掩码位图来实现的:

- 白色对象: 对象所在 mspan 的 gcmarkBits 中对应的 bit 为 0, 不在队列;
- 灰色对象: 对象所在 mspan 的 gcmarkBits 中对应的 bit 为 1, 且对象在扫描队列中;
- 黑色对象: 对象所在 mspan 的 gcmarkBits 中对应的 bit 为 1, 且对象已经从扫描队列中处理并被删除;


置灰的代码参考 mwbbuf.go 当中的 `wbBufFlush1` 函数.

通过 ptr 可以快速定位到 mspan(findObject), 从而快速查找到 gcmarkBits, 检查相关的 bit 位.

每个 p 持有一个 gcWork 对象, 它持有两个队列 wbuf1 和 wbuf2, 该队列当中保存了待扫描的灰色对象, 扫描线程会从队列当中
获取要扫描的对象(tryGet(), 一旦获取不到, 会从 work.full 当中获取); 一旦其中的某个队列满了, 它会将当前队列当中的对象
push 到全局的 work.full 当中. 这套机制类似 goroutine 的调度.




### 删除写屏障
Binary file added images/develop_gc_deltewb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/develop_gc_insertwb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/develop_gc_lostobject.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/develop_gc_why_deletewb1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/develop_gc_why_deletewb2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit adb7635

Please sign in to comment.