Go垃圾回收与实现

为什么需要垃圾回收

  • 减少错误和复杂性

像没有垃圾回收的语言,比如C、C++,需要手动分配,释放内存,容易出现内存泄露和野指针问题。具有垃圾回收功能的语言屏蔽了内存管理的复杂性。开发者可以更好的关注核心的业务逻辑,虽然垃圾回收不保证完全不产生内存泄露,但提供了重要的保障,即不在被引用的对象,终将被收集。

  • 解耦

手动分配内存的问题在于难以在本地模块内做出全局的决定,具有垃圾回收功能的语言将垃圾收集工作托管给具有全局观的运行时代码,开发者编写的业务模块将真正实现解耦。

经典的垃圾回收算法

RootSet

标记清除

优点:实现简单

缺点:

  • 碎片化, 会导致无数小分块散落在堆的各处
  • 分配速度不理想,每次分配都需要遍历空闲列表找到足够大的分块

标记整理

标记阶段和标记清除算法相同,但是在完成标记工作后,会移动非垃圾数据,按照内存地址次序依次排列,然后将末端地址以后的内存全部回收

优点:解决了内存碎片的问题

缺点:需要一些额外的空间来标记前对象已经移动了其他地方,在压缩阶段,如果一个对象发生了转移,必须更新所有引用该对象的对象指针,增加了实现的复杂性

复制算法

优点:解决了内存碎片问题,整理的时间比标记整理算法更短,不分阶段,扫描到对象时可以直接移动,在扫描根对象时就可以直接压缩

缺点:

  • 内存空间利用率不高
  • 如果存活对象非常多,复制将会花费很多的时间

为了提高内存使用率,通常和其他算法搭配使用,例如分代垃圾回收

分代回收

分代回收的前提是:大部分对象都在年轻时死亡

新生代对象:新创键的对象

老年代对象:经过n次GC后依然存活的对象

基于此,把数据分为新生代和老年代,没有必要每次都扫描老年代对象,降低老年代执行垃圾回收的频率,明显提升垃圾回收效率

新生代和老年代还可以采用不同的回收策略

比如Java的JDK8的 PARNEW + CMS 垃圾回收器

新生代采用复制算法,将内存划分成8:1:1, 提升了复制算法的内存使用率

老年代采用标记整理算法

引用计数

引用计数是指:一个对象被引用的次数

程序执行过程中,会更新对象的引用计数,当引用计数更新为0时,表示这个对象不再有用,可以回收

引用计数法中,垃圾识别的任务已经分摊到每次对数据对象操作了

优点:可以及时回收内存

缺点:高频率更新引用计数造成不小开销,存在循环引用情况。

暂停用户程序,只专注于垃圾回收的前提下,也就是没有STW的情况

实际上用户程序是不能接受长时间的暂停的,缩短STW的时间,是衡量很多垃圾回收器优化的一个重要指标

Go垃圾回收演进

V1.3之前

V1.3版本之前采用标记清除算法

从上图可看,全部的GC时间都在STW时间范围之内,会让用户程序出现严重卡顿,暂停时间过长

V1.3

V1.3之前GC执行过程中,一直都在STW,V1.3做出的优化是将清除垃圾对象STW时间范围摘出来

V1.5 三色并发标记

面对1.3以及之前的版本,在进行GC的时候,会暂停整个程序

所谓的三色标记通过三个阶段的标记来确定清除的对象都有哪些,GC过程可以和其他用户goroutine并行

三色的含义

  • 白色:尚未访问过。不在队列中。
  • 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
  • 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。

假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:

  1. 初始时,所有对象都在 【白色集合】中
  2. 将GC Roots 直接引用到的对象 挪到 【灰色集合】中;从灰色集合中获取对象
  3. 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
    1. 将本对象 挪到 【黑色集合】里面

重复步骤3,直至【灰色集合】为空时结束。

结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。

当进行三色标记时,如果STW, 可以完成标记,但是这样GC扫描的性能就太低了

Go如何解决标记-清除算法中STW问题?

假如在三色标记过程中,不启动STW, 那么此时用户goroutine和垃圾回收的goroutine是并发进行的,在GC扫描过程中,任意的对象都可能发生读写操作,可能会出现如下情况:

还没有扫描到F和G对象的时候,已经被标记为黑色的D对象,此时引用了G,与此同时,灰色对象E断开了对G对象的引用

然后按照正常三色标记逻辑,最后G会一直停留在白色集合中,最后被回收掉,所以就出现一个正常的对象,被无辜清楚掉了

这是不被接受的

可以看出,有两种情况,在三色标记法中,是不希望被发生的。

  • 条件1: 一个白色对象被黑色对象引用, 也就是白色被挂在黑色下
  • 条件2: 灰色对象与它之间的可达关系的白色对象遭到破坏, 灰色同时丢了该白色

如果当以上两个条件同时满足时,就会出现对象丢失现象

以上是在没有进行STW的情况下,会出现的情况,但是进行STW,对所有用户程序又有很大的影响,那么是否可以在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢?答案是可以的,我们只要使用一种机制,尝试去破坏上面的两个必要条件就可以了。

屏障机制

只要满足下面两种情况之一,基于可以保证对象不丢失

  • 强三色不变式

不存在黑色对象引用到白色对象指针,强制性的不允许黑色对象引用变色对象

  • 弱三色不变式

所有被黑色对象引用的白色对象处于保护状态,黑色对象可以引用白色对象,但是白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在存在灰色对象

为了遵循上述两种方式,GC算法演进到两种方式:插入屏障和删除屏障

屏障的本质

内存屏障只是对应一段特殊的代码,这段代码在编译期间生成

内存屏障本质上在运行期间拦截内存写操作,相当于一个hook调用

屏障的作用:通过hook内存的写操作时机,做好一些标记工作,从而保证垃圾回收的正确性

func main() {
    a := new(Tstruct)
    go funcAlloc0(a)
}
func funcAlloc0 (a *Tstruct) {
     a.base = new(BaseStruct)    // new 一个BaseStruct结构体,赋值给 a.base 字段
 }
Dump of assembler code for function main.funcAlloc0:
   0x0000000000456b10 <+0>:     mov    %fs:0xfffffffffffffff8,%rcx
   0x0000000000456b19 <+9>:     cmp    0x10(%rcx),%rsp
   0x0000000000456b1d <+13>:    jbe    0x456b6f <main.funcAlloc0+95>
   0x0000000000456b1f <+15>:    sub    $0x20,%rsp
   0x0000000000456b23 <+19>:    mov    %rbp,0x18(%rsp)
   0x0000000000456b28 <+24>:    lea    0x18(%rsp),%rbp
   0x0000000000456b2d <+29>:    lea    0x1430c(%rip),%rax        # 0x46ae40
   0x0000000000456b34 <+36>:    mov    %rax,(%rsp)
   0x0000000000456b38 <+40>:    callq  0x40b060 <runtime.newobject>
   # newobject的返回值在 0x8(%rsp) 里,golang 的参数和返回值都是通过栈传递的。这个跟 c 程序不同,c 程序是溢出才会用到栈,这里先把返回值放到寄存器 rax
   0x0000000000456b3d <+45>:    mov    0x8(%rsp),%rax           
   0x0000000000456b42 <+50>:    mov    %rax,0x10(%rsp)
   # 0x28(%rsp) 就是 a 的地址:0xc0000840b0
=> 0x0000000000456b47 <+55>:    mov    0x28(%rsp),%rdi         
   0x0000000000456b4c <+60>:    test   %al,(%rdi)
   # 这里判断是否开启了屏障(垃圾回收的扫描并发过程,才会把这个标记打开,没有打开的情况,对于堆上的赋值只是多走一次判断开销)
   0x0000000000456b4e <+62>:    cmpl   $0x0,0x960fb(%rip)        # 0x4ecc50 <runtime.writeBarrier>
   0x0000000000456b55 <+69>:    je     0x456b59 <main.funcAlloc0+73>
   0x0000000000456b57 <+71>:    jmp    0x456b68 <main.funcAlloc0+88>
   # 赋值 a.base = xxxx
   0x0000000000456b59 <+73>:    mov    %rax,(%rdi)
   0x0000000000456b5c <+76>:    jmp    0x456b5e <main.funcAlloc0+78>
   0x0000000000456b5e <+78>:    mov    0x18(%rsp),%rbp
   0x0000000000456b63 <+83>:    add    $0x20,%rsp
   0x0000000000456b67 <+87>:    retq   
   # 如果是开启了屏障,那么完成 a.base = xxx 的赋值就是在 gcWriteBarrier 函数里面了
   0x0000000000456b68 <+88>:    callq  0x44d170 <runtime.gcWriteBarrier>
   0x0000000000456b6d <+93>:    jmp    0x456b5e <main.funcAlloc0+78>
   0x0000000000456b6f <+95>:    callq  0x44b370 <runtime.morestack_noctxt>
   0x0000000000456b74 <+100>:   jmp    0x456b10 <main.funcAlloc0>
End of assembler dump.

触发写屏障之后,核心目的是为了能够把赋值前后的两个值记录下来,以便GC垃圾回收器能得到通知,从而避免错误回收,

写屏障代码就是gcWriteBarrier,用汇编写的一个函数

这个函数主要做:

  1. 检查wbBuf队列是否满了
  2. 把赋值前后的两个值入队记录,记录赋值前后的两个值(eg: *slot = value)
  3. 如果队列满了,就执行runtime.wbBufFlush函数,批量刷到全局工作队列中(灰色队列,只要在灰色队列中的对象,就是灰色) 批量优化
  4. 执行赋值操作 *slot = value
插入写屏障

操作:将B挂在A的下游,A必须被标记为灰色

满足强三色不变式,不存在黑色对象引用白色的情况

//伪代码:
writePointer(slot, ptr):
    shade(ptr) //将新的下游对象标记为灰色
    *slot = ptr //当前对象指向新的对象

栈空间的特点是容量小,但是要求相应速度快,因为函数调用弹出频繁使用, 所以“插入屏障”机制,在栈空间的对象操作中不使用. 而仅仅使用在堆空间对象的操作中

由于栈上没有写屏障会出现问题:当三色标记扫描完之后,栈上可能依然存在白色对象被引用的情况,所以在准备回收白色节点前,需要对栈重新扫描,为了对象不丢失,本次标记要启动STW,直到栈空间的三色标记结束,这次STW大约耗费时间在10-100ms之间。

删除写屏障

操作:被删除的对象,如果自身为灰色或者白色,那么被标记为灰色

满足弱三色不变式,允许黑色对象指向白色对象,但是保证灰色对象到白色对象的路径不会断

writePointer(slot, ptr)
    shade(*slot) //将当前对象标灰
    *slot = ptr //当前对象指向新的对象

删除写屏障,在起始时,STW扫描所有goroutine的栈,保证所有堆上 在用的对象都处于灰色保护下

由于起始执行STW,删除写屏障不适用于栈特别大的场景,栈越大,STW扫描时间越长,现代服务来说,栈地址空间都很大,所以删除写屏障不适用

当黑色节点需要断开一个指向白色节点的引用时,需要将白色节点染成灰色,保证本次不会被回收,在下次扫描时进行回收,导致扫描进度后退,扫描精度不如插入写屏障。

在go中是没有使用删除写屏障的,但是在混合写屏障时,借鉴了删除写屏障的思路

混合写屏障

操作:

  1. GC开始将栈上的对象全部扫描并标记为黑色,之后不再进行第二次扫描,不用STW
  2. GC期间,任何在栈上创建的对象,均为黑色
  3. 将删除的对象标记为灰色
  4. 将被添加的对象标记为灰色

满足变形的弱三色不等式

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

栈空间不启动,堆空间启动

堆上对对象的添加和删除需要触发屏障机制

标记结束后,栈在扫描后始终是黑色的,不用进行STW重新扫描

混合写屏障的精度和删除写屏障的一致,比以前插入写屏障要低;

Go垃圾回收过程

标记准备,STW

清扫上一阶段GC遗留的需要清扫的对象

为每个P创建一个mark worker协程(后台标记协程),后台的mark worker创建后进入到休眠,等到标记阶段得到调度执行

决定需要多少标记协程以及如何调度标记协程

标记阶段
  1. GC阶段表示,由_GCoff改为_GCmark、开启写屏障(writeBarrier记录是否开启写屏障:true)、调度后台标记协程、将根对象入队
  2. 恢复用户程序的运行,后台标记协程开始并发标记内存中的对象,所有新创建的对象都会被直接标记为黑色,对于任何指针写入和新的指针值,都会被写屏障覆盖
  3. GC执行根节点标记,扫描所有的栈、全局对象,扫描goroutine栈会导致goroutine停止,并对栈上找到的所有指针加置灰,继续执行goroutine
  4. 遍历灰色对象队列时,会将灰色对象变成黑色,并将该对象指向的对象置灰
  5. 如果没有可达对象了,转为标记终止
标记终止 STW

将GC阶段由_GCmark -> _GCmarktermination

gcBlackenEnabled: 置位0

关闭GC工作线程

清扫阶段

GC阶段由_GCmark -> _GCoff

关闭写屏障

恢复程序执行,从此开始新创建的对象都是白色的;

清扫工作协程,在清扫工作开始后,加入到runq,得到调度运行

垃圾回收过程中的问题

内存碎片化如何解决

堆内存的管理:

// runtime/sizeclasses.go
//  级别   每个元素大小 span大小    元素个数        浪费个数 最大浪费
// class  bytes/obj  bytes/span  objects  tail waste  max waste
//     1          8        8192     1024           0     87.50%
//     2         16        8192      512           0     43.75%
//     3         24        8192      341           8     29.24%
//     4         32        8192      256           0     21.88%
//     5         48        8192      170          32     31.52%
//     6         64        8192      128           0     23.44%
//     7         80        8192      102          32     19.07%
//     8         96        8192       85          32     15.95%
//     9        112        8192       73          16     13.56%
//    10        128        8192       64           0     11.72%
//    11        144        8192       56         128     11.82%
//    12        160        8192       51          32      9.73%
//    13        176        8192       46          96      9.59%
//    14        192        8192       42         128      9.25%
//    15        208        8192       39          80      8.12%
//    16        224        8192       36         128      8.15%
//    17        240        8192       34          32      6.62%
//    18        256        8192       32           0      5.86%
//    19        288        8192       28         128     12.16%
//    20        320        8192       25         192     11.80%
//    21        352        8192       23          96      9.88%
//    22        384        8192       21         128      9.51%
//    23        416        8192       19         288     10.71%
//    24        448        8192       18         128      8.37%
//    25        480        8192       17          32      6.82%
//    26        512        8192       16           0      6.05%
//    27        576        8192       14         128     12.33%
//    28        640        8192       12         512     15.48%
//    29        704        8192       11         448     13.93%
//    30        768        8192       10         512     13.94%
//    31        896        8192        9         128     15.52%
//    32       1024        8192        8           0     12.40%
//    33       1152        8192        7         128     12.41%
//    34       1280        8192        6         512     15.55%
//    35       1408       16384       11         896     14.00%
//    36       1536        8192        5         512     14.00%
//    37       1792       16384        9         256     15.57%
//    38       2048        8192        4           0     12.45%
//    39       2304       16384        7         256     12.46%
//    40       2688        8192        3         128     15.59%
//    41       3072       24576        8           0     12.47%
//    42       3200       16384        5         384      6.22%
//    43       3456       24576        7         384      8.83%
//    44       4096        8192        2           0     15.60%
//    45       4864       24576        5         256     16.65%
//    46       5376       16384        3         256     10.92%
//    47       6144       24576        4           0     12.48%
//    48       6528       32768        5         128      6.23%
//    49       6784       40960        6         256      4.36%
//    50       6912       49152        7         768      3.37%
//    51       8192        8192        1           0     15.61%
//    52       9472       57344        6         512     14.28%
//    53       9728       49152        5         512      3.64%
//    54      10240       40960        4           0      4.99%
//    55      10880       32768        3         128      6.24%
//    56      12288       24576        2           0     11.45%
//    57      13568       40960        3         256      9.99%
//    58      14336       57344        4           0      5.35%
//    59      16384       16384        1           0     12.49%
//    60      18432       73728        4           0     11.11%
//    61      19072       57344        3         128      3.57%
//    62      20480       40960        2           0      6.87%
//    63      21760       65536        3         256      6.25%
//    64      24576       24576        1           0     11.45%
//    65      27264       81920        3         128     10.00%
//    66      28672       57344        2           0      4.91%
//    67      32768       32768        1           0     12.50%
type mcentral struct { 
    //spanClass Id
    spanclass spanClass //span的规格
    // 空闲的span列表
    partial [2]spanSet // list of spans with a free object
    // 已经被使用的span列表
    full    [2]spanSet // list of spans with no free objects

    //分配mspan的累积计数
    nmalloc uint64
} 
type mcache struct {
    ....
    tiny uintptr //申请微小对象的起始地址
    tinyoffset uintptr //从起始地址tiny开始的偏移量
    tinyAllocs uintptr //微小对象分配的数量
    alloc [numSpanClasses]*mspan //numSpanClasses == 136
    ...
}
type mspan struct {
    // 上一个节点
    next *mspan
    // 下一个节点     
	prev *mspan     
    //span开始的地址值
	startAddr uintptr // address of first byte of span aka s.base()
    //span管理的页面数
	npages    uintptr // number of pages in span

	// freeindex is the slot index between 0 and nelems at which to begin scanning
	// for the next free object in this span.
	// Each allocation scans allocBits starting at freeindex until it encounters a 0
	// indicating a free object. freeindex is then adjusted so that subsequent scans begin
	// just past the newly discovered free object.
	//
	// If freeindex == nelem, this span has no free objects.
	//
	// allocBits is a bitmap of objects in this span.
	// If n >= freeindex and allocBits[n/8] & (1<<(n%8)) is 0
	// then object n is free;
	// otherwise, object n is allocated. Bits starting at nelem are
	// undefined and should never be referenced.
	//
	// Object n starts at address n*elemsize + (start << pageShift).
	freeindex uintptr
    //span中存放的对象的数量
	nelems uintptr
	allocCache uint64
	allocBits  *gcBits
	gcmarkBits *gcBits
}

内存分配的过程: mallocgc

// typ: 元数据信息 typ.ptrdata 含有所有指针类型前缀大小
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    //1. 辅助标记
    ....
    //2. 内存分配
    if size <= maxSmallSize { //32kb
        if noscan && size < maxTinySize {  //16byte
            //微小对象分配
        } else {
            //小对象分配
        }
    } else {
        //大对象分配
    }
    //3.位图标记: 根据对象分配的地址,找到对应的span和arena,更新对应位图标记
    ....
    //4.收尾: 1) 处于GC阶段,对新分配的对象进行标记 2) 内存分配达到了GC触发条件,触发新一轮的GC
    ....
}
  • 微小对象分配

微小对象会被放入到class2的span中,tiny分配的第一步是利用分配过的前一个元素的空间,达到节约内存的目的

查看前一个分配元素时剩余的空间,是否足以容纳新的元素,如果可以,则返回tiny + offset的地址,如果不够,从mcache中查找span中下一个可用元素,然后重新分配一个16字节大小的内存块

allocCache中的最后一个bit对应的是span中的第一个元素是否被分配,当bit为1时,代表当前对应的span中的元素已经被分配,span中的元素个数可能大于64,因此需要专门有一个字段freeindex标识span中的元素分配到了哪里

找元素时: 从allocCache找到哪一位为0 + freeIndex,就是当前span中可用元素的序号,当所有bit全部标记为1后,移动freeIndex

首先尝试从本地的mcache中获取16byte规格大小的span,获取不到向mcentral申请, mcentral向堆内存申请mspan,堆在需要是从OS提取内存

使用微小对象分配减少了12%的分配次数和20%的堆大小

  • 小对象分配

匹配预置大小的规格来分配

  • 大对象分配

Go 并不适用本地缓存来管理较大的内存空间分配。对于超过 32kb 的分配,会向上取整到页的大小,并直接从堆上分配。

如何标记一个对象
type heapArena struct {
    bitmap [heapArenaBitmapBytes]byte
    spans [pagesPerArena]*mspan //8192 对应arena中的8192个page
    pageInUse [pagesPerArena / 8]uint8 //1024
    pageMarks [pagesPerArena / 8]uint8 //1024
}

bitmap中每两个bit对应标记arena中一个指针大小的word,也就是说bitmap中一个byte可以标记arena中连续四个指针大小的内存。每个word对应的两个bit中,低位bit用于标记是否为指针,0为非指针,1为指针;高位bit用于标记是否要继续扫描,高位bit为1就代表扫描完当前word并不能完成当前数据对象的扫描。bitmap信息在分配内存时设置。

spans是一个*mspan类型的数组,用于记录当前arena中每一页对应到哪一个mspan。有可能多个页对应一个mspan。

只要知道一个对象的地址,就可以根据bitmap信息扫描它的内部是否含有指针,也可以根据对象地址算出它在那一个页,通过spans信息查到该对象存在那个span中。

每个span对应两个位图标记:mspan.allocBits和mspan.gcmarkBits。

allocBits中的每一个位用于标记一个对象存储单元是否分配

gcmarkBits中每一位用于标记一个对象是否存活。

allocBits和gcmarkBits数据结构完全一样,在进行内存回收时,将allocBits指向gcmarkBits,代表标记过的才是存活的

gcmarkBits则会在下次标记时重新分配。

总过程:

  1. 标记为灰色的操作就是把对象所在的span的gcmarkBits中对应bit置位1,并且对象在标记队列中
  2. 标记为黑色的操作就是把对象对应的gcmarkBits标位置为1,并且对象已经从标记队列中取出并处理
  3. 白色的对象在它所在的span的gcmarkBits中对应的bit为0
并发标记分工问题和写屏障记录的竞争问题
type p struct {
    ....
    mcache      *mcache
    gcMarkWorkerMode gcMarkWorkerMode //当前P的mark worker执行标记任务的模式
	gcMarkWorkerStartTime int64 //执行标记工作开始的时间
    gcw gcWork //工作队列
    wbBuf wbBuf //写屏障缓冲区
    ....
}

type gcWork struct {
    ....
    wbuf1, wbuf2 *workbuf
    ....
}

type work struct {
    full  lfstack          // lock-free list of full blocks workbuf
    empty lfstack          // lock-free list of empty blocks workbuf
    nDataRoots, nBSSRoots, nSpanRoots, nStackRoots int
    ....
}

func (w *gcWork) put(obj uintptr) {
	flushed := false
	wbuf := w.wbuf1
	// Record that this may acquire the wbufSpans or heap lock to
	// allocate a workbuf.
	lockWithRankMayAcquire(&work.wbufSpans.lock, lockRankWbufSpans)
	lockWithRankMayAcquire(&mheap_.lock, lockRankMheap)
	if wbuf == nil {
		w.init()
		wbuf = w.wbuf1
		// wbuf is empty at this point.
	} else if wbuf.nobj == len(wbuf.obj) {
		w.wbuf1, w.wbuf2 = w.wbuf2, w.wbuf1 //交换wbuf1和wbuf2
		wbuf = w.wbuf1
		if wbuf.nobj == len(wbuf.obj) {
			putfull(wbuf) //put -> work.full
			w.flushedWork = true
			wbuf = getempty()
			w.wbuf1 = wbuf
			flushed = true
		}
	}
    ....
 }
 
 //runtime/mgcmark.go
 func (w *gcWork) balance() {
	if w.wbuf1 == nil {
		return
	}
	if wbuf := w.wbuf2; wbuf.nobj != 0 { //wbuf2不为空
		putfull(wbuf)
		w.flushedWork = true
		w.wbuf2 = getempty()
	} else if wbuf := w.wbuf1; wbuf.nobj > 4 { //wbuf2为空,wbuf1中的元素个数大于4
		w.wbuf1 = handoff(wbuf) //放置一半到full中
		w.flushedWork = true // handoff did putfull
	} else {
		return
	}
	// We flushed a buffer to the full list, so wake a worker.
	if gcphase == _GCmark {
		gcController.enlistWorker()
	}
}

添加任务时从wbuf1中添加,wbuf1满了就交换wbuf1和wbuf2,如果交换之后还是满的,就把wbfuf1的工作flush到全局工作缓存中

标记协程执行GC标记工作消耗工作队列时,处理本地工作队列和全局工作缓存中工作量均衡问题

  1. 如果全局工作缓存为空,把当前p的工作分一些到全局工作队列中
    1. 如果wbuf2不为空,就把wbuf2全部flush到全局工作缓存中
    2. 如果wbuf2为空,wbuf1元素个数大于4,把wbuf1中的一半放到全局工作缓存中
  2. 如果本地工作队列为空,从全局工作缓存获取任务放到本地队列中
  3. 触发写屏障时,不会直接操作工作队列,把相关指针写入到p的写屏障缓冲区中,p.wbBuf中

当wbBuf已满或mark worker 通过工作队列获取不到任务时,把写屏障缓冲区到内容flush到工作缓存中,避免了写屏障记录的竞争问题

缓解GC过程中内存分配的压力
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ....
    // assistG is the G to charge for this allocation, or nil if
	// GC is not currently active.
	var assistG *g
	if gcBlackenEnabled != 0 { //正在进行GC标记工作
		// Charge the current user G for this allocation.
		assistG = getg() //获取当前的G
		if assistG.m.curg != nil {
			assistG = assistG.m.curg
		}
		// Charge the allocation against the G. We'll account
		// for internal fragmentation at the end of mallocgc.
        // assistG.gcAssistBytes 表示当前协程可以分配的内存数量
		assistG.gcAssistBytes -= int64(size)

		if assistG.gcAssistBytes < 0 { //负债
			// This G is in debt. Assist the GC to correct
			// this before allocating. This must happen
			// before disabling preemption.
			gcAssistAlloc(assistG) //按照分配内存的大小判断需要协助GC完成多少工作
		}
	}
     ....
}
func gcAssistAlloc(gp *g) {
    ....
 retry:
	// Compute the amount of scan work we need to do to make the
	// balance positive. When the required amount of work is low,
	// we over-assist to build up credit for future allocations
	// and amortize the cost of assisting.
    //分配一个字节,需要执行多少扫描工作
	assistWorkPerByte := float64frombits(atomic.Load64(&gcController.assistWorkPerByte))
    //assistBytesPerWork: 1/assistWorkPerByte. 每执行一个扫描工作,相当于分配了多大的内存
	assistBytesPerWork := float64frombits(atomic.Load64(&gcController.assistBytesPerWork))
	debtBytes := -gp.gcAssistBytes
	scanWork := int64(assistWorkPerByte * float64(debtBytes)) //需要执行的扫描工作
	if scanWork < gcOverAssistWork { //64MB
		scanWork = gcOverAssistWork
        //扫描scanWork大小的字节,需要分配的内存大小
		debtBytes = int64(assistBytesPerWork * float64(scanWork)) 
	}
 
	// Steal as much credit as we can from the background GC's
	// scan credit. This is racy and may drop the background
	// credit below 0 if two mutators steal at the same time. This
	// will just cause steals to fail until credit is accumulated
	// again, so in the long run it doesn't really matter, but we
	// do have to handle the negative credit case.
    // 全局的信用/资产
	bgScanCredit := atomic.Loadint64(&gcController.bgScanCredit)
	stolen := int64(0)
	if bgScanCredit > 0 {
		if bgScanCredit < scanWork {
			stolen = bgScanCredit
            //累计自己的信誉,偷了stolen个字节,可以给自己增加多少信誉值
			gp.gcAssistBytes += 1 + int64(assistBytesPerWork*float64(stolen))
		} else {
			stolen = scanWork
			gp.gcAssistBytes += debtBytes
		}
		atomic.Xaddint64(&gcController.bgScanCredit, -stolen)

		scanWork -= stolen

		if scanWork == 0 {
			// We were able to steal all of the credit we
			// needed.
			if traced {
				traceGCMarkAssistDone()
			}
			return
		}
	}
    ....
}

辅助标记

协程要分配内存,但是GC标记工作还未完成,就要负担一部分GC标记工作,要申请的内存越大,要负担的任务的就越多,对应要负担的标记任务越多,不管分配多大内存,最少要执行的扫描任务为64MB

后台的mark worker 每完成一定量的标记任务,就会在gcController里面存储一部分资产,有债务需要偿还的G可以从gcController中steal尽量多的信用来抵消自己所欠的债务。

不管是执行扫描任务挣得资产,还是从gcController中偷的资产,如果偿还这次债务还有结余,可以用于抵消下次内存分配造成的债务

懒清扫和辅助清扫

懒清扫:

执行少量的清扫工作后,通过Gosched() 来调度执行,让出自己执行权利,不需要一直执行,当触发下一阶段的垃圾回收后,可能有没有被清理的内存,需要先将它们清理完

如果在下次触发GC时,必须将上一次的GC未清扫的span全部扫描,如果剩余的未清扫的太多,将会拖后下一次GC的时间,Go使用了辅助清扫

会在两个时机判断是否需要辅助扫描

  • 需要向mcentral申请内存时
  • 大对象分配内存

这两个时机会判断,当前已经清扫的page数大于清理的目标page数,这个条件是否成立,不成立,会进行辅助清扫,直到成立为止。

控制GC时的CPU使用率

标记准备阶段会为每个P启动一个标记协程,并不是所有的标记协程都有执行的机会,在标记阶段,标记协程和正常执行用户代码的协程需要并行,减少GC给用户程序带来的影响

mark worker的数量

Go规定,后台标记协程消耗的cpu应该接近25%

//runtime/mgc.go 初始化gcControllerState
func (c *gcControllerState) startCycle() {
	c.scanWork = 0
	c.bgScanCredit = 0
	c.assistTime = 0
	c.dedicatedMarkTime = 0
	c.fractionalMarkTime = 0
	c.idleMarkTime = 0
    ....
    // Compute the background mark utilization goal. In general,
	// this may not come out exactly. We round the number of
	// dedicated workers so that the utilization is closest to
	// 25%. For small GOMAXPROCS, this would introduce too much
	// error, so we add fractional workers in that case.
	totalUtilizationGoal := float64(gomaxprocs) * gcBackgroundUtilization // const gcBackgroundUtilization = 0.25
	c.dedicatedMarkWorkersNeeded = int64(totalUtilizationGoal + 0.5) //计算结果不为整数,+0.5后,取整
	utilError := float64(c.dedicatedMarkWorkersNeeded)/totalUtilizationGoal - 1 //计算取整后和不取整时,产生的误差
	const maxUtilError = 0.3
	if utilError < -maxUtilError || utilError > maxUtilError { //取整后的误差,超过0.3
		// Rounding put us more than 30% off our goal. With
		// gcBackgroundUtilization of 25%, this happens for
		// GOMAXPROCS<=3 or GOMAXPROCS=6. Enable fractional
		// workers to compensate.
		if float64(c.dedicatedMarkWorkersNeeded) > totalUtilizationGoal {
			// Too many dedicated workers.
			c.dedicatedMarkWorkersNeeded--
		}
        //fractionalUtilizationGoal:当P = 2, 2*0.25=0.5,只能花0.5个P来执行标记任务,如果用一个P来执行标记任务,标记的CPU使用量 变为了
        //50%, 和25% 的目标使用率差距太大
        //当P=2时,fractionalUtilizationGoal = 0.25,表示在总的标记周期内,每个P需要花25%的时间来执行后台标记工作,超出时间之后,后台标记协程可以被抢占
		c.fractionalUtilizationGoal = (totalUtilizationGoal - float64(c.dedicatedMarkWorkersNeeded)) / float64(gomaxprocs)
	} else {
		c.fractionalUtilizationGoal = 0
	}
    ....
 }

标记协程的不同工作模式:

gcMarkWorkerDedicatedMode:代表这个处理器专门负责标记对象,不会被调度器抢占

gcMarkWorkerFractionalMode:协助后台标记,在标记阶段到达目标时间后,会自动退出

gcMarkWorkerIdleMode: 当处理器没有可以执行的协程时,执行垃圾收集的标记任务,直到被抢占

调度标记协程:

当关闭STW准备再次启动所有协程时,每个P都会进入一轮新的调度循环,调度循环开始,会判断是否处于GC阶段,如果是,则尝试判断当前P是否需要执行后台标记任务

func schedule() {
    ....
    if gp == nil && gcBlackenEnabled != 0 { 
        //gc标记工作开始时,寻找后台标记协程
		gp = gcController.findRunnableGCWorker(_g_.m.p.ptr())
		tryWakeP = tryWakeP || gp != nil
	}
    ....
}
// findRunnableGCWorker returns a background mark worker for _p_ if it
// should be run. This must only be called when gcBlackenEnabled != 0.
func (c *gcControllerState) findRunnableGCWorker(_p_ *p) *g {
    decIfPositive := func(ptr *int64) bool {
		for {
			v := atomic.Loadint64(ptr)
			if v <= 0 {
				return false
			}

			// TODO: having atomic.Casint64 would be more pleasant.
			if atomic.Cas64((*uint64)(unsafe.Pointer(ptr)), uint64(v), uint64(v-1)) {
				return true
			}
		}
	}

    //dedicatedMarkWorkersNeeded 不为0, 则该P的后台标记协程采用 gcMarkWorkerDedicatedMode 
	if decIfPositive(&c.dedicatedMarkWorkersNeeded) {
		// This P is now dedicated to marking until the end of
		// the concurrent mark phase.
		_p_.gcMarkWorkerMode = gcMarkWorkerDedicatedMode
	} else if c.fractionalUtilizationGoal == 0 {
		// No need for fractional workers.
		gcBgMarkWorkerPool.push(&node.node)
		return nil
	} else { //fractionalUtilizationGoal 不为0,采用gcMarkWorkerFractionalMode模式
		// Is this P behind on the fractional utilization
		// goal?
		//
		// This should be kept in sync with pollFractionalWorkerExit.
         //P会记录自己执行gcMarkWorkerFractionalMode模式的worker的时间,如果当前P累计执行该模式的时间与本轮标记工作已经执行的
         //时间的比率达到fractionalUtilizationGoal,gcMarkWorkerFractionalMode模式的标记哦协程就可以主动退出了
		delta := nanotime() - gcController.markStartTime
		if delta > 0 && float64(_p_.gcFractionalMarkTime)/float64(delta) > c.fractionalUtilizationGoal {
			// Nope. No need to run a fractional worker.
			gcBgMarkWorkerPool.push(&node.node)
			return nil
		}
		// Run a fractional worker.
		_p_.gcMarkWorkerMode = gcMarkWorkerFractionalMode
	}
}
GC触发的时机

GC在满足一定条件后会被触发, 触发条件有以下几种:

  • gcTriggerHeap: 当前分配的内存达到一定值就触发GC

mallocgc 在分配大对象或者 mspan 中所有对象都已经被用尽的时候,会进行 GC 测试,验证 heap 是否已经达到 GC 的条件。

  • gcTriggerTime: 当一定时间没有执行过GC就触发GC

forcegchelper强制触发

  • gcTriggerCycle: 要求启动新一轮的GC, 已启动则跳过, 手动触发GC的runtime.GC()会使用这个条件