内存分配与GC | 豆包MarsCode AI刷题

57 阅读1小时+

内存管理

1.内存模型

1.1 操作系统存储模型

操作系统经典的多级存储模型设计

多级模型:利用各级存储的特点,使得最频繁使用的数据存储在速度最快的存储器中

动态切换:不同存储层次之间的数据可以根据使用情况和访问频率高效地进行移动

1.2 虚拟内存与物理内存

操作系统内存管理中,另一个重要概念是虚拟内存,其作用如下:

  • 在用户与硬件间添加中间代理层(没有什么是加一个中间层解决不了的)
  • 优化用户体验(进程感知到获得的内存空间是“连续”的)
  • “放大”可用内存(虚拟内存可以由物理内存+磁盘补足,并根据冷热动态置换,用户无感知)
  • 解决内存隔离问题

1.3 分页管理

操作系统中通常会将虚拟内存和物理内存切割成固定的尺寸,于虚拟内存而言叫作“页”,于物理内存而言叫作“帧”,原因及要点如下:

  • 提高内存空间利用(以页为粒度后,消灭了不稳定的外部碎片,取而代之的是相对可控的内部碎片)

  • 提高内外存交换效率(更细的粒度带来了更高的灵活度)

  • 与虚拟内存机制呼应,便于建立虚拟地址->物理地址的映射关系(聚合映射关系的数据结构,称为页表)

  • linux 页/帧的大小固定,为 4KB(这实际是由实践推动的经验值,太粗会增加碎片率,太细会增加分配频率影响效率)固定大小的页和帧便于操作系统和硬件共同实现高效的地址转换

  • 方便进程管理 分页允许进程拥有一个大于实际物理内存的虚拟地址空间。进程使用的虚拟地址空间是连续的,而物理内存中的帧可以是分散的,这为多进程并发运行提供了更多的灵活性。

1.4 go中的内存模型

Golang 的内存模型设计的几个核心要点:

Golang 中的堆 mheap 正是基于该思想,产生的数据结构. 我们可以从两个视角来解决 Golang 运行时的堆:

I 对操作系统而言,这是用户进程中缓存的内存

II 对于 Go 进程内部,堆是所有对象的内存起源

多级缓存,实现无/细锁化

堆是 Go 运行时中最大的临界共享资源,这意味着每次存取都要加锁,在性能层面是一件很可怕的事情.

在解决这个问题,Golang 在堆 mheap 之上,依次细化粒度,建立了 mcentral、mcache 的模型,下面对三者作个梳理:

  • mheap:全局的内存起源,访问要加全局锁
  • mcentral:每种对象大小规格(全局共划分为 68 种)对应的缓存,锁的粒度也仅限于同一种规格以内
  • mcache:每个 P(正是 GMP 中的 P)持有一份的内存缓存,访问时无锁
多级规格,提高利用率

首先理下 page 和 mspan 两个概念:

(1)page:最小的存储单元.

Golang 借鉴操作系统分页管理的思想,每个最小的存储单元也称之为页 page,但大小为 8 KB

(2)mspan:最小的管理单元.

mspan 大小为 page 的整数倍,且从 8B 到 80 KB 被划分为 67 种不同的规格,分配对象时,会根据大小映射到不同规格的 mspan,从中获取空间.

于是,我们回头小节多规格 mspan 下产生的特点:

I 根据规格大小,产生了等级的制度

II 消除了外部碎片,但不可避免会有内部碎片(6kb的对象被分配到了一个 8 KB 的 page 或 MSpan 中)

III 宏观上能提高整体空间利用率

IV 正是因为有了规格等级的概念,才支持 mcentral 实现细锁化

全局总览

上图是 Thread-Caching Malloc 的整体架构图,Golang 正是借鉴了该内存模型.

内存碎片

内存碎片是指在动态内存分配过程中,由于内存的频繁分配和释放,导致空闲的内存块无法被有效利用的现象。内存碎片通常分为两类:

  1. 外部碎片:当程序释放内存时,空闲的内存块被分散在整个内存空间中。这种情况下,尽管总的空闲内存足够大,但由于这些空闲内存块分散在不同的区域,无法为程序分配一个大块的连续内存,导致内存利用率下降。

  2. 内部碎片:这是指程序分配的内存块比实际需要的内存要大,导致一些内存被分配但未使用。例如,当操作系统按固定大小分配内存块时,程序可能只使用了其中一部分,其余部分则成为内部碎片。

2核心概念梳理

2.1内存单元mspan

分点阐述 mspan 的特质:

  • mspan 是 Golang 内存管理的最小单元
  • mspan 大小是 page 的整数倍(Go 中的 page 大小为 8KB),且内部的页是连续的(至少在虚拟内存的视角中是这样)
  • 每个 mspan 根据空间大小以及面向分配对象的大小,会被划分为不同的等级(2.2小节展开)
  • 同等级的 mspan 会从属同一个 mcentral,最终会被组织成链表,因此带有前后指针(prev、next)
  • 由于同等级的 mspan 内聚于同一个 mcentral,所以会基于同一把互斥锁管理
  • mspan 会基于 bitMap 辅助快速找到空闲内存块(块大小为对应等级下的 object 大小),此时需要使用到 Ctz64 算法(会返回从最低位到第一个 1 之前的连续 0 的数量,这样 Go 就知道哪个块是空闲的并可以分配给新的对象).

//runtime/mheap.go 文件中:
type mspan struct {
    // 标识前后节点的指针 
    next *mspan     
    prev *mspan    
    // ...
    // 起始地址
    startAddr uintptr 
    // 包含几页,页是连续的
    npages    uintptr 


    // 标识此前的位置都已被占用 
    freeindex uintptr
    // 最多可以存放多少个 object
    nelems uintptr // number of object in the span.


    // bitmap 每个 bit 对应一个 object 块,标识该块是否已被占用
    allocCache uint64
    // ...
    // 标识 mspan 等级,包含 class 和 noscan 两部分信息
    spanclass             spanClass    
    // ...
}

2.2 内存单元等级 spanClass

mspan 根据空间大小和面向分配对象的大小,被划分为 67 种等级(1-67,实际上还有一种隐藏的 0 级,用于处理更大的对象,上不封顶)

下表展示了部分的 mspan 等级列表,数据取自 runtime/sizeclasses.go 文件中:

classbytes/objbytes/spanobjectstail wastemax waste
1881921024087.50%
2168192512043.75%
3248192341829.24%
4328192256021.88%
...
662867257344204.91%
6732768327681012.50%

对上表各列进行解释:

(1)class:mspan 等级标识,1-67

(2)bytes/obj:该大小规格的对象会从这一 mspan 中获取空间. 创建对象过程中,大小会向上取整为 8B 的整数倍,因此该表可以直接实现 object 到 mspan 等级 的映射

(3)bytes/span:该等级的 mspan 的总空间大小

(4)object:该等级的 mspan 最多可以 new 多少个对象,结果等于 (3)/(2)

(5)tail waste:(3)/(2)可能除不尽,于是该项值为(3)%(2)

(6)max waste:通过下面示例解释:

以 class 3 的 mspan 为例,class 分配的 object 大小统一为 24B,由于 object 大小 <= 16B 的会被分配到 class 2 及之前的 class 中,因此只有 17B-24B 大小的 object 会被分配到 class 3.

最不利的情况是,当 object 大小为 17B,会产生浪费空间比例如下

((24-17)*341 + 8)/8192 = 0.29235829.24%

除了上面谈及的根据大小确定的 mspan 等级外,每个 object 还有一个重要的属性叫做 nocan,标识了 object 是否包含指针,在 gc 时是否需要展开标记.

在 Golang 中,会将 span class + nocan 两部分信息组装成一个 uint8,形成完整的 spanClass 标识. 8 个 bit 中,高 7 位表示了上表的 span 等级(总共 67 + 1 个等级,8 个 bit 足够用了),最低位表示 nocan 信息.

//runtime/mheap.go
type spanClass uint8


// uint8 左 7 位为 mspan 等级,最右一位标识是否为 noscan
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
    return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}


func (sc spanClass) sizeclass() int8 {
    return int8(sc >> 1)
}


func (sc spanClass) noscan() bool {
    return sc&1 != 0
}

2.3 线程缓存 mcache

要点:

(1)mcache 是每个 P(GMP中的P) 独有的缓存(一对一),因此交互无锁

(2)mcache 将每种 spanClass 等级的 mspan 各缓存了一个,总数为 2(nocan 维度) * 68(大小维度)= 136

(3)mcache 中还有一个为对象分配器 tiny allocator,用于处理小于 16B 对象的内存分配,在 3.3 小节中详细展开.

//runtime/mcache.go
const numSpanClasses = 136
type mcache struct {
    // 微对象分配器相关
    tiny       uintptr
    tinyoffset uintptr
    tinyAllocs uintptr
    
    // mcache 中缓存的 mspan,每种 spanClass 各一个
    alloc [numSpanClasses]*mspan 
    // ...
}

2.4 中心缓存 mcentral

要点:

(1)每个 mcentral 对应一种 spanClass

(2)每个 mcentral 下聚合了该 spanClass 下的 mspan

(3)mcentral 下的 mspan 分为两个链表,分别为有空间 mspan 链表 partial 和满空间 mspan 链表 full

(4)每个 mcentral 一把锁

//runtime/mcentral.go
type mcentral struct {
    //表明某些内存对象不需要GC
    _         sys.NotInHeap
    // 对应的 spanClass
    spanclass spanClass
    // 有空位的 mspan 集合,数组长度为 2 是用于抗一轮 GC
    partial [2]spanSet 
    // 无空位的 mspan 集合
    full    [2]spanSet 
}

2.5 全局堆缓存 mheap

要点:

  • 对于 Golang 上层应用而言,堆是操作系统虚拟内存的抽象
  • 以页(8KB)为单位,作为最小内存存储单元
  • 负责将连续页组装成 mspan
  • 全局内存基于 bitMap 标识其使用情况,每个 bit 对应一页,为 0 则自由,为 1 则已被 mspan 组装
  • 通过 heapArena 聚合页,记录了页到 mspan 的映射信息(2.7小节展开)
  • 建立空闲页基数树索引 radix tree index,辅助快速寻找空闲页(2.6小节展开)
  • 是 mcentral 的持有者,持有所有 spanClass 下的 mcentral,作为自身的缓存
  • 内存不够时,向操作系统申请,申请单位为 heapArena(64M)
//runtime/mheap.go
type mheap struct {
    // 堆的全局锁
    lock mutex


    // 空闲页分配器,底层是多棵基数树组成的索引,每棵树对应 16 GB 内存空间
    pages pageAlloc 


    // 记录了所有的 mspan. 需要知道,所有 mspan 都是经由 mheap,使用连续空闲页组装生成的
    allspans []*mspan


    // heapAreana 数组,64 位系统下,二维数组容量为 [1][2^22]
    // 每个 heapArena 大小 64M,因此理论上,Golang 堆上限为 2^22*64M = 256T
    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena


    // ...
    // 多个 mcentral,总个数为 spanClass 的个数
    central [numSpanClasses]struct {
        mcentral mcentral
        // 用于内存地址对齐
        pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
    }


    // ...
}

2.6 空闲页索引 pageAlloc

与 mheap 中,与空闲页寻址分配的基数树索引有关的内容较为晦涩难懂. 网上能把这个问题真正讲清楚的文章几乎没有.

所幸我最后找到这个数据结构的作者发布的笔记,终于对方案的原貌有了大概的了解,这里粘贴链接,供大家自取:go.googlesource.com/proposal/+/…

要理清这棵技术树,首先需要明白以下几点:

(1)数据结构背后的含义:

I 2.5 小节有提及,mheap 会基于 bitMap 标识内存中各页的使用情况,bit 位为 0 代表该页是空闲的,为 1 代表该页已被 mspan 占用.

II 每棵基数树聚合了 16 GB 内存空间中各页使用情况的索引信息,用于帮助 mheap 快速找到指定长度的连续空闲页的所在位置

III mheap 持有 2^14 棵基数树,因此索引全面覆盖到 2^14 * 16 GB = 256 T 的内存空间.

(2)基数树节点设定

基数树中,每个节点称之为 PallocSum,是一个 uint64 类型,体现了索引的聚合信息,包含以下四部分:

  • start:最右侧 21 个 bit,标识了当前节点映射的 bitMap 范围中首端有多少个连续的 0 bit(空闲页),称之为 start;
  • max:中间 21 个 bit,标识了当前节点映射的 bitMap 范围中最多有多少个连续的 0 bit(空闲页),称之为 max;
  • end:左侧 21 个 bit,标识了当前节点映射的 bitMap 范围中最末端有多少个连续的 0 bit(空闲页),称之为 end.
  • 最左侧一个 bit,弃置不用

(3)父子关系

  • 每个父 pallocSum 有 8 个子 pallocSum
  • 根 pallocSum 总览全局,映射的 bitMap 范围为全局的 16 GB 空间(其 max 最大值为 2^21,因此总空间大小为 2^21*8KB=16GB);
  • 从首层向下是一个依次八等分的过程,每一个 pallocSum 映射其父节点 bitMap 范围的八分之一,因此第二层 pallocSum 的 bitMap 范围为 16GB/8 = 2GB,以此类推,第五层节点的范围为 16GB / (8^4) = 4 MB,已经很小
  • 聚合信息时,自底向上. 每个父 pallocSum 聚合 8 个子 pallocSum 的 start、max、end 信息,形成自己的信息,直到根 pallocSum,坐拥全局 16 GB 的 start、max、end 信息
  • mheap 寻页时,自顶向下. 对于遍历到的每个 pallocSum,先看起 start 是否符合,是则寻页成功;再看 max 是否符合,是则进入其下层孩子 pallocSum 中进一步寻访;最后看 end 和下一个同辈 pallocSum 的 start 聚合后是否满足,是则寻页成功.

//runtime/mpagealloc.go
const summaryLevels = 5


type pageAlloc struct {
    // 共有五层基数树,第一层有 2^14 个节点,因此共用 2^14棵基数树
    // 总空间大小为 2^14*16GB = 256T
    // 接下来每层的节点数为上层的 8 倍
    summary [summaryLevels][]pallocSum
    
    // ...
    // 类似于 tiny offset,小于此值的地址无锁检索,必然没有空间可用
    searchAddr offAddr


    // ...
}

基数树节点

const(
    logMaxPackedValue = 21
    maxPackedValue    = 1 << logMaxPackedValue
)


type pallocSum uint64


// 基于 start、max、end 组装成一个基数树节点 pallocSum
func packPallocSum(start, max, end uint) pallocSum {
    // ...
    return pallocSum((uint64(start) & (maxPackedValue - 1)) |
        ((uint64(max) & (maxPackedValue - 1)) << logMaxPackedValue) |
        ((uint64(end) & (maxPackedValue - 1)) << (2 * logMaxPackedValue)))
}


// 当前节点对应区域内,首部连续空闲页的长度
// 通过 uint64 最右侧 21 个 bit 标识
func (p pallocSum) start() uint {
    // ...
    return uint(uint64(p) & (maxPackedValue - 1))
}


// 当前节点对应区域内,连续空闲页的最大长度
// 通过 uint64 左数 23~43 个 bit 标识
func (p pallocSum) max() uint {
    // ...
    return uint((uint64(p) >> logMaxPackedValue) & (maxPackedValue - 1))
}


// 当前节点对应区域内,尾部连续空闲页的长度
// 通过 uint64 左数 2~22 个 bit 标识
func (p pallocSum) end() uint {
    return uint((uint64(p) >> (2 * logMaxPackedValue)) & (maxPackedValue - 1))
}

2.7 heapArena

  • 每个 heapArena 包含 8192 个页,大小为 8192 * 8KB = 64 MB
  • heapArena 记录了页到 mspan 的映射. 因为 GC 时,通过地址偏移找到页很方便,但找到其所属的 mspan 不容易. 因此需要通过这个映射信息进行辅助.
  • heapArena 是 mheap 向操作系统申请内存的单位(64MB)
//runtime/mheap.go
const pagesPerArena = 8192


type heapArena struct {
    // ...
    // 实现 page 到 mspan 的映射
    spans [pagesPerArena]*mspan


    // ...
}

3对象分配流程

下面来串联 Golang 中分配对象的流程,不论是以下哪种方式,最终都会殊途同归步入 mallocgc 方法中,并且根据 3.1 小节中的策略执行分配流程:

  • new(T)
  • &T{}
  • make(xxxx)

3.1 分配流程总览

Golang 中,依据 object 的大小,会将其分为下述三类:

不同类型的对象,会有着不同的分配策略,这些内容在 mallocgc 方法中都有体现.

核心流程类似于读多级缓存的过程,由上而下,每一步只要成功则直接返回. 若失败,则由下层方法兜底.

对于微对象的分配流程:

(1)从 P 专属 mcache 的 tiny 分配器取内存(无锁)

(2)根据所属的 spanClass,从 P 专属 mcache 缓存的 mspan 中取内存(无锁)

(3)根据所属的 spanClass 从对应的 mcentral 中取 mspan 填充到 mcache,然后从 mspan 中取内存(spanClass 粒度锁)

(4)根据所属的 spanClass,从 mheap 的页分配器 pageAlloc 取得足够数量空闲页组装成 mspan 填充到 mcache,然后从 mspan 中取内存(全局锁)

(5)mheap 向操作系统申请内存,更新页分配器的索引信息,然后重复(4).

对于小对象的分配流程是跳过(1)步,执行上述流程的(2)-(5)步;

对于大对象的分配流程是跳过(1)-(3)步,执行上述流程的(4)-(5)步.

3.2 步骤(1):tiny 分配

每个 P 独有的 mache 会有个微对象分配器,基于 offset 线性移动的方式对微对象进行分配,每 16B 成块,对象依据其大小,会向上取整为 2 的整数次幂进行空间补齐,然后进入分配流程.

 noscan := typ == nil || typ.ptrdata == 0
    // ...
        if noscan && size < maxTinySize {
        // tiny 内存块中,从 offset 往后有空闲位置
          off := c.tinyoffset
          // ...
            // 如果当前 tiny 内存块空间还够用,则直接分配并返回
            if off+size <= maxTinySize && c.tiny != 0 {
            // 分配空间
                x = unsafe.Pointer(c.tiny + off)
                c.tinyoffset = off + size
                c.tinyAllocs++
                mp.mallocing = 0
                releasem(mp)
                return x
            }
           // ...
        }
offset 线性移动

通过偏移量(Offset) ,进行元素或数据的顺序访问或位移操作

3.3 步骤(2):mcache 分配

 // 根据对象大小,映射到其所属的 span 的等级(0~66)
          var sizeclass uint8
          // get size class ....     
          // 对应 span 等级下,分配给每个对象的空间大小(0~32KB)
          // get span class
          spc := makeSpanClass(sizeclass, noscan) 
          // 获取 mcache 中的 span
          span = c.alloc[spc]  
          // 从 mcache 的 span 中尝试获取空间        
          v := nextFreeFast(span)
          if v == 0 {
          // mcache 分配空间失败,则通过 mcentral、mheap 兜底            
             v, span, shouldhelpgc = c.nextFree(spc)
          }     
          // 分配空间  
          x = unsafe.Pointer(v)

在 mspan 中,基于 Ctz64 算法,根据 mspan.allocCache 的 bitMap 信息快速检索到空闲的 object 块,进行返回.

代码位于 runtime/malloc.go 文件中:

func nextFreeFast(s *mspan) gclinkptr {
    // 通过 ctz64 算法,在 bit map 上寻找到首个 object 空位
    theBit := sys.Ctz64(s.allocCache) 
    if theBit < 64 {
        result := s.freeindex + uintptr(theBit)
        if result < s.nelems {
            freeidx := result + 1
            if freeidx%64 == 0 && freeidx != s.nelems {
                return 0
            }
            s.allocCache >>= uint(theBit + 1)
            // 偏移 freeindex 
            s.freeindex = freeidx
            s.allocCount++
            // 返回获取 object 空位的内存地址 
            return gclinkptr(result*s.elemsize + s.base())
        }
    }
    return 0
}

3.4 步骤(3):mcentral 分配

当 mspan 无可用的 object 内存块时,会步入 mcache.nextFree 方法进行兜底.

代码位于 runtime/mcache.go 文件中:

func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
    s = c.alloc[spc]
    // ...
    // 从 mcache 的 span 中获取 object 空位的偏移量
    freeIndex := s.nextFreeIndex()
    if freeIndex == s.nelems {
        // ...
        // 倘若 mcache 中 span 已经没有空位,则调用 refill 方法从 mcentral 或者 mheap 中获取新的 span    
        c.refill(spc)
        // ...
        // 再次从替换后的 span 中获取 object 空位的偏移量
        s = c.alloc[spc]
        freeIndex = s.nextFreeIndex()
    }
    // ...
    v = gclinkptr(freeIndex*s.elemsize + s.base())
    s.allocCount++
    // ...
    return
}    

倘若 mcache 中,对应的 mspan 空间不足,则会在 mcache.refill 方法中,向更上层的 mcentral 乃至 mheap 获取 mspan,填充到 mache 中:

代码位于 runtime/mcache.go 文件中:

func (c *mcache) refill(spc spanClass) {  
    s := c.alloc[spc]
    // ...
    // 从 mcentral 当中获取对应等级的 span
    s = mheap_.central[spc].mcentral.cacheSpan()
    // ...
    // 将新的 span 添加到 mcahe 当中
    c.alloc[spc] = s
}

mcentral.cacheSpan 方法中,会加锁(spanClass 级别的 sweepLocker,),分别从 partial 和 full 中尝试获取有空间的 mspan:

代码位于 runtime/mcentral.go 文件中:

func (c *mcentral) cacheSpan() *mspan {
    // ...
    var sl sweepLocker    
    // ...
    sl = sweep.active.begin()
    if sl.valid {
        for ; spanBudget >= 0; spanBudget-- {
            s = c.partialUnswept(sg).pop()
            // ...
            if s, ok := sl.tryAcquire(s); ok {
                // ...
                sweep.active.end(sl)
                goto havespan
            }
            
        // 通过 sweepLock,加锁尝试从 mcentral 的非空链表 full 中获取 mspan
        for ; spanBudget >= 0; spanBudget-- {
            s = c.fullUnswept(sg).pop()
           // ...
            if s, ok := sl.tryAcquire(s); ok {
                // ...
                sweep.active.end(sl)
                goto havespan
                }
                // ...
            }
        }
        // ...
    }
    // ...


    // 执行到此处时,s 已经指向一个存在 object 空位的 mspan 了
havespan:
    // ...
    return
}

3.5 步骤(4):mheap 分配

在 mcentral.cacheSpan 方法中,倘若从 partial 和 full 中都找不到合适的 mspan 了,则会调用 mcentral 的 grow 方法,将事态继续升级:

func (c *mcentral) cacheSpan() *mspan {
    // ...
    // mcentral 中也没有可用的 mspan 了,则需要从 mheap 中获取,最终会调用 mheap_.alloc 方法
    s = c.grow()
   // ...


    // 执行到此处时,s 已经指向一个存在 object 空位的 mspan 了
havespan:
    // ...
    return
}

经由 mcentral.grow 方法和 mheap.alloc 方法的周转,最终会步入 mheap.allocSpan 方法中:

func (c *mcentral) grow() *mspan {
    npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
    size := uintptr(class_to_size[c.spanclass.sizeclass()])


    s := mheap_.alloc(npages, c.spanclass)
    // ...


    // ...
    return s
}

代码位于 runtime/mheap.go

func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
    var s *mspan
    systemstack(func() {
        // ...
        s = h.allocSpan(npages, spanAllocHeap, spanclass)
    })
    return s
}

代码位于 runtime/mheap.go

func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
    gp := getg()
    base, scav := uintptr(0), uintptr(0)
    
    // ...此处实际上还有一阶缓存,是从每个 P 的页缓存 pageCache 中获取空闲页组装 mspan,此处先略去了...
    
    // 加上堆全局锁
    lock(&h.lock)
    if base == 0 {
        // 通过基数树索引快速寻找满足条件的连续空闲页
        base, scav = h.pages.alloc(npages)
        // ...
    }
    
    // ...
    unlock(&h.lock)


HaveSpan:
    // 把空闲页组装成 mspan
    s.init(base, npages)
    
    // 将这批页添加到 heapArena 中,建立由页指向 mspan 的映射
    h.setSpans(s.base(), npages, s)
    // ...
    return s
}

倘若对 mheap 空闲页分配器基数树 pageAlloc 分配空闲页的源码感兴趣,莫慌,3.8 小节见.

3.6 步骤(5):向操作系统申请

倘若 mheap 中没有足够多的空闲页了,会发起 mmap 系统调用,向操作系统申请额外的内存空间.

代码位于 runtime/mheap.go 文件的 mheap.grow 方法中:

func (h *mheap) grow(npage uintptr) (uintptr, bool) {
    av, asize := h.sysAlloc(ask)
}
func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {
       v = sysReserve(unsafe.Pointer(p), n)
}
func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer {
    return sysReserveOS(v, n)
}
func sysReserveOS(v unsafe.Pointer, n uintptr) unsafe.Pointer {
    p, err := mmap(v, n, _PROT_NONE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
    if err != 0 {
        return nil
    }
    return p
}

3.7 malloc全流程

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // ...    
    // 获取 m
    mp := acquirem()
    // 获取当前 p 对应的 mcache
    c := getMCache(mp)
    var span *mspan
    var x unsafe.Pointer
    // 根据当前对象是否包含指针,标识 gc 时是否需要展开扫描
    noscan := typ == nil || typ.ptrdata == 0
    // 是否是小于 32KB 的微、小对象
    if size <= maxSmallSize {
    // 小于 16 B 且无指针,则视为微对象
        if noscan && size < maxTinySize {
        // tiny 内存块中,从 offset 往后有空闲位置
          off := c.tinyoffset
          // 如果大小为 5 ~ 8 B,size 会被调整为 8 B,此时 8 & 7 == 0,会走进此分支
          if size&7 == 0 {
                // 将 offset 补齐到 8 B 倍数的位置
                off = alignUp(off, 8)
                // 如果大小为 3 ~ 4 B,size 会被调整为 4 B,此时 4 & 3 == 0,会走进此分支  
           } else if size&3 == 0 {
           // 将 offset 补齐到 4 B 倍数的位置
                off = alignUp(off, 4)
                // 如果大小为 1 ~ 2 B,size 会被调整为 2 B,此时 2 & 1 == 0,会走进此分支  
           } else if size&1 == 0 {
            // 将 offset 补齐到 2 B 倍数的位置
                off = alignUp(off, 2)
           }
// 如果当前 tiny 内存块空间还够用,则直接分配并返回
            if off+size <= maxTinySize && c.tiny != 0 {
            // 分配空间
                x = unsafe.Pointer(c.tiny + off)
                c.tinyoffset = off + size
                c.tinyAllocs++
                mp.mallocing = 0
                releasem(mp)  
                return x
            } 
            // 分配一个新的 tiny 内存块
            span = c.alloc[tinySpanClass]    
            // 从 mCache 中获取
            v := nextFreeFast(span)        
            if v == 0 {
            // 从 mCache 中获取失败,则从 mCentral 或者 mHeap 中获取进行兜底
                v, span, shouldhelpgc = c.nextFree(tinySpanClass)
            }   
// 分配空间      
            x = unsafe.Pointer(v)
           (*[2]uint64)(x)[0] = 0
           (*[2]uint64)(x)[1] = 0
           size = maxTinySize
        } else {
          // 根据对象大小,映射到其所属的 span 的等级(0~66)
          var sizeclass uint8
          if size <= smallSizeMax-8 {
              sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]
          } else {
              sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]
          }        
          // 对应 span 等级下,分配给每个对象的空间大小(0~32KB)
          size = uintptr(class_to_size[sizeclass])
          // 创建 spanClass 标识,其中前 7 位对应为 span 的等级(0~66),最后标识表示了这个对象 gc 时是否需要扫描
          spc := makeSpanClass(sizeclass, noscan) 
          // 获取 mcache 中的 span
          span = c.alloc[spc]  
          // 从 mcache 的 span 中尝试获取空间        
          v := nextFreeFast(span)
          if v == 0 {
          // mcache 分配空间失败,则通过 mcentral、mheap 兜底            
             v, span, shouldhelpgc = c.nextFree(spc)
          }     
          // 分配空间  
          x = unsafe.Pointer(v)
          // ...
       }      
       // 大于 32KB 的大对象      
   } else {
       // 从 mheap 中获取 0 号 span
       span = c.allocLarge(size, noscan)
       span.freeindex = 1
       span.allocCount = 1
       size = span.elemsize         
       // 分配空间   
        x = unsafe.Pointer(span.base())
   }  
   // ...
   return x
}                           

3.8 步骤(4)拓展:基数树寻页

核心源码位于 runtime/pagealloc.go 的 pageAlloc 方法中,要点都以在代码中给出注释:

func (p *pageAlloc) find(npages uintptr) (uintptr, offAddr) {
    // 必须持有堆锁
    assertLockHeld(p.mheapLock)


    // current level.
    i := 0


    // ...
    lastSum := packPallocSum(0, 0, 0)
    lastSumIdx := -1


nextLevel:
    // 1 ~ 5 层依次遍历
    for l := 0; l < len(p.summary); l++ {
        // ...
        // 根据上一层的 index,映射到下一层的 index.
        // 映射关系示例:上层 0 -> 下层 [0~7]
        //             上层 1 -> 下层 [8~15]
        //             以此类推
        i <<= levelBits[l]
        entries := p.summary[l][i : i+entriesPerBlock]
        // ...
        // var levelBits = [summaryLevels]uint{
        //   14,3,3,3,3
        // }
        // 除第一层有 2^14 个节点外,接下来每层都只要关心 8 个 节点.
        // 由于第一层有 2^14 个节点,所以 heap 内存上限为 2^14 * 16G = 256T
        var base, size uint
        for j := j0; j < len(entries); j++ {
            sum := entries[j]
            // ...
            // 倘若当前节点对应内存空间首部即满足,直接返回结果
            s := sum.start()
            if size+s >= uint(npages) {               
                if size == 0 {
                    base = uint(j) << logMaxPages
                }             
                size += s
                break
            }
            // 倘若当前节点对应内存空间首部不满足,但是内部最长连续页满足,则到下一层节点展开搜索
            if sum.max() >= uint(npages) {               
                i += j
                lastSumIdx = i
                lastSum = sum
                continue nextLevel
            }
            // 即便内部最长连续页不满足,还可以尝试将尾部与下个节点的首部叠加,看是否满足
            if size == 0 || s < 1<<logMaxPages {
                                size = sum.end()
                base = uint(j+1)<<logMaxPages - size
                continue
            }
            // The entry is completely free, so continue the run.
            size += 1 << logMaxPages
        }
    
    // 根据 i 和 j 可以推导得到对应的内存地址,进行返回
    ci := chunkIdx(i)
    addr := chunkBase(ci) + uintptr(j)*pageSize
    // ...
    return addr, p.findMappedAddr(firstFree.base)
}

GC(垃圾回收)

1 垃圾回收算法

1.1 背景介绍

垃圾回收(Garbage Collection,简称 GC)是一种内存管理策略,由垃圾收集器以类似守护协程的方式在后台运作,按照既定的策略为用户回收那些不再被使用的对象,释放对应的内存空间.

(1)GC 带来的优势包括:

  • 屏蔽内存回收的细节

拥有 GC 能力的语言能够为用户屏蔽复杂的内存管理工作,使用户更好地聚焦于核心的业务逻辑.

  • 以全局视野执行任务

现代软件工程项目体量与日剧增,一个项目通常由团体协作完成,研发人员负责各自模块的同时,不可避免会涉及到临界资源的使用. 此时由于缺乏全局的视野,手动对内存进行管理无疑会增加开发者的心智负担. 因此,将这部分工作委托给拥有全局视野的垃圾回收模块来完成,方为上上之策.

(2)GC 带来的劣势:

  • 提高了下限但降低了上限

将释放内存的工作委托给垃圾回收模块,研发人员得到了减负,但同时也失去了控制主权.Go提供了手动股那里内存的方法,也可以调整GC频率,但无法完全绕过Go自带的GC对内存进行管理

  • 增加了额外的成本

全局的垃圾回收模块化零为整,会需要额外的状态信息用以存储全局的内存使用情况. 且部分时间需要中断整个程序用以支持垃圾回收工作的执行,这些都是GC额外产生的成本.

(3)GC 的总体评价

除开少量追求极致速度的特殊小规模项目之外,在绝大多数高并发项目中,GC模块都为我们带来了极大的裨益,已经成为一项不可或缺的能力.

下面几类经典的垃圾回收算法进行介绍,算是一些比较老生常谈的内容.

1.2 标记清扫

标记清扫(Mark-Sweep)算法,分为两步走:

  • 标记:标记出当前还存活的对象
  • 清扫:清扫掉未被标记到的垃圾对象

这是一种类似于排除法的间接处理思路,不直接查找垃圾对象,而是标记存活对象,从而取补集推断出垃圾对象.

至于标记清扫算法的不足之处,通过上图也得以窥见一二,那就是会产生内存碎片. 经过几轮标记清扫之后,空闲的内存块可能零星碎片化分布,此时倘若有大对象需要分配内存,可能会因为内存空间无法化零为整从而导致分配失败.

1.3 标记压缩

标记压缩(Mark-Compact)算法,是在标记清扫算法的基础上做了升级,在第二步”清扫“的同时还会对存活对象进行压缩整合,使得整体空间更为紧凑,从而解决内存碎片问题.

标记压缩算法在功能性上呈现得很出色,而其存在的缺陷也很简单,就是实现时会有很高的复杂度且开销较大.

1.4 半空间复制

半空间复制(Semispace Copy)算法的核心点如下:

  • 分配两片相等大小的空间,称为 fromspace 和 tospace
  • 每轮只使用 fromspace 空间,以GC作为分水岭划分轮次
  • GC时,将fromspace存活对象转移到tospace中,并以此为契机对空间进行压缩整合
  • GC后,交换fromspace和tospace,开启新的轮次

显然,半空间复制算法应用了以空间换取时间的优化策略,解决了内存碎片的问题,也在一定程度上降低了压缩空间的复杂度. 但其缺点也同样很明显——比较浪费空间.

1.5 引用计数

引用计数(Reference Counting)算法是很简单高效的:

  • 对象每被引用一次,计数器加1
  • 对象每被删除引用一次,计数器减1
  • GC时,把计数器等于 0 的对象删除

然而,这个朴素的算法存在一个致命的缺陷:无法解决循环引用或者自引用问题.

本章对垃圾回收算法的背景知识做了补充介绍,下一章我们来看看,在此基础上,Golang的GC模块做了怎样的设计与取舍.

2 Golang 中的垃圾回收

抛开漫长的进化史不谈,Golang 在 1.8版本之后,GC策略框架已经奠定,就是并发三色标记法+混合写屏障机制,下面我们逐一展开介绍.

2.1 三色标记法

Golang GC 中用到的三色标记法属于标记清扫-算法下的一种实现,由荷兰的计算机科学家 Dijkstra 提出,下面阐述要点:

  • 对象分为三种颜色标记:黑、灰、白
  • 黑对象代表,对象自身存活,且其指向对象都已标记完成
  • 灰对象代表,对象自身存活,但其指向对象还未标记完成
  • 白对象代表,对象尙未被标记到,可能是垃圾对象
  • 标记开始前,将根对象(全局对象、栈上局部变量等)置黑,将其所指向的对象置灰
  • 标记规则是,从灰对象出发,将其所指向的对象都置灰. 所有指向对象都置灰后,当前灰对象置黑
  • 标记结束后,白色对象就是不可达的垃圾对象,需要进行清扫.

2.2 并发垃圾回收

Golang 1.5 版本是个分水岭,在此之前,GC时需要停止全局的用户协程,专注完成GC工作后,再恢复用户协程,这样做在实现上简单直观,但是会对用户造成不好的体验.

自1.5版本以来,Golang引入了并发垃圾回收机制,允许用户协程和后台的GC协程并发运行,大大地提高了用户体验. 但“并发”是一个值得大家警惕的字眼. 用户协程运行时可能对对象间的引用关系进行调整,这会严重打乱GC三色标记时的标记秩序. 这些问题,我们将在2.3小节展开介绍.

2.3 几个问题

首先在(1)(2)两个问题中,讨论一下并发垃圾回收机制下,可能产生的问题.

(1)Golang 并发垃圾回收可能存在漏标问题

漏标问题指的是在用户协程与GC协程并发执行的场景下,部分存活对象未被标记从而被误删的情况. 这一问题产生的过程如下:

  • 条件:初始时刻,对象B持有对象C的引用
  • moment1:GC协程下,对象A被扫描完成,置黑;此时对象B是灰色,还未完成扫描
  • momen2:用户协程下,对象A建立指向对象C的引用
  • moment3:用户协程下,对象B删除指向对象C的引用
  • moment4:GC协程下,开始执行对对象B的扫描

在上述场景中,由于GC协程在B删除C的引用后才开始扫描B,因此无法到达C. 又因为A已经被置黑,不会再重复扫描,因此从扫描结果上看,C是不可达的.

然而事实上,C应该是存活的(被A引用),而GC结束后会因为C仍为白色,因此被GC误删.

漏标问题是无法接受,其引起的误删现象可能会导致程序出现致命的错误. 针对漏标问题,Golang 给出的解决方案是屏障机制的使用,这部分内容将在本文第3章进一步展开.

(2)Golang 并发垃圾回收可能存在多标问题

多标问题指的是在用户协程与GC协程并发执行的场景下,部分垃圾对象被误标记从而导致GC未按时将其回收的问题. 这一问题产生的过程如下:

  • 条件:初始时刻,对象A持有对象B的引用
  • moment1:GC协程下,对象A被扫描完成,置黑;对象B被对象A引用,因此被置灰
  • momen2:用户协程下,对象A删除指向对象B的引用

上述场景引发的问题是,在事实上,B在被A删除引用后,已成为垃圾对象,但由于其事先已被置灰,因此最终会更新为黑色,不会被GC删除.

错标问题对比于漏标问题而言,是相对可以接受的. 其导致本该被删但仍侥幸存活的对象被称为“浮动垃圾”,至多到下一轮GC,这部分对象就会被GC回收,因此错误可以得到弥补.

(3)Golang 垃圾回收如何解决内存碎片问题?

1.2 小节中有提及,标记清扫算法会存在产生“内存碎片”的缺陷. 那么采用标记清扫算法的Golang GC模块要如何化解这一问题呢?

在有关内存分配的部分讲到. Golang采用 TCMalloc 机制,依据对象的大小将其归属为到事先划分好的spanClass当中,这样能够消解外部碎片的问题,将问题限制在相对可控的内部碎片当中.

基于此,Golang选择采用实现上更为简单的标记清扫算法,避免使用复杂度更高的标记压缩算法,因为在 TCMalloc 框架下,后者带来的优势已经不再明显.

(4)Golang为什么不选择分代垃圾回收机制?

分代算法指的是,将对象分为年轻代和老年代两部分(或者更多),采用不同的GC策略进行分类管理. 分代GC算法有效的前提是,绝大多数年轻代对象都是朝生夕死,拥有更高的GC回收率,因此适合采用特别的策略进行处理.

然而Golang中存在内存逃逸机制,会在编译过程中将生命周期更长的对象转移到堆中,将生命周期短的对象分配在栈上,并以栈为单位对这部分对象进行回收.

综上,内存逃逸机制减弱了分代算法对Golang GC所带来的优势,考虑分代算法需要产生额外的成本(如不同年代的规则映射、状态管理以及额外的写屏障),Golang 选择不采用分代GC算法.

3 屏障机制

本章介绍屏障有关的内容,主要是为了解决2.3小节中提及的并发GC下的漏标问题.

3.1 强弱三色不变式

漏标问题的本质就是,一个已经扫描完成的黑对象指向了一个被灰\白对象删除引用的白色对象. 构成这一场景的要素拆分如下:

(1)黑色对象指向了白色对象

(2)灰、白对象删除了白色对象

(3)(1)、(2)步中谈及的白色对象是同一个对象

(4)(1)发生在(2)之前

一套用于解决漏标问题的方法论称之为强弱三色不变式:

  • 强三色不变式:白色对象不能被黑色对象直接引用(直接破坏(1))
  • 弱三色不变式:白色对象可以被黑色对象引用,但要从某个灰对象出发仍然可达该白对象(间接破坏了(1)、(2)的联动)

3.2 插入写屏障

屏障机制类似于一个回调保护机制,指的是在完成某个特定动作前,会先完成屏障成设置的内容.

插入写屏障(Dijkstra)的目标是实现强三色不变式,保证当一个黑色对象指向一个白色对象前,会先触发屏障将白色对象置为灰色,再建立引用.

如果所有流程都能保证做到这一点,那么 3.1 小节中的(1)就会被破坏,漏标问题得以解决.

3.3 删除写屏障

删除写屏障(Yuasa barrier)的目标是实现弱三色不变式,保证当一个白色对象即将被上游删除引用前,会触发屏障将其置灰,之后再删除上游指向其的引用.

这一流程中,3.1小节的步骤(2)会被破坏,漏标问题得以解决.

3.4 混合写屏障

结合3.2 3.3小节来看,插入写屏障、删除写屏障二者择其一,即可解决并发GC的漏标问题,至于错标问题,则采用容忍态度,放到下一轮GC中进行延后处理即可.

然而真实场景中,需要补充一个新的设定——屏障机制无法作用于栈对象.

这是因为栈对象可能涉及频繁的轻量操作,倘若这些高频度操作都需要一一触发屏障机制,那么所带来的成本将是无法接受的.

在这一背景下,单独看插入写屏障或删除写屏障,都无法真正解决漏标问题,除非我们引入额外的Stop the world(STW)阶段,对栈对象的处理进行兜底。

为了消除这个额外的 STW 成本,Golang 1.8 引入了混合写屏障机制,可以视为糅合了插入写屏障+删除写屏障的加强版本,要点如下:

  • GC 开始前,以栈为单位分批扫描,将栈中所有对象置黑
  • GC 期间,栈上新创建对象直接置黑
  • 堆对象正常启用插入写屏障
  • 堆对象正常启用删除写屏障

下面我们通过 3.5 小节的几个 show case,来论证混合写屏障机制是否真的能解决并发GC下的各种极端场景问题.

STW

STW 期间,整个程序的执行会暂停,直到该阶段完成,程序才会恢复正常运行。这种暂停主要是为了确保在垃圾回收的关键操作中,所有的 Goroutine 和内存分配状态是一致的,从而避免内存管理过程中的混乱和数据不一致。

尽管 STW 时间已经大幅缩短,但在高并发、高实时性要求的场景下,STW 依然可能会引发性能抖动问题。这种问题尤其在需要非常短的延迟(如实时系统)的应用中可能会显现

STW是必须的

3.5 show case

(1)case 1:堆对象删除引用,栈对象建立引用

  • 背景:存在栈上对象A,黑色(扫描完);

存在堆上对象B,白色(未被扫描);

存在堆上对象C,被堆上对象B引用,白色(未被扫描)

  • moment1:A建立对C的引用,由于栈无屏障机制,因此正常建立引用,无额外操作
  • moment2:B尝试删除对C的引用,删除写屏障被触发,C被置灰,因此不会漏标

(2)case 2:一个堆对象删除引用,成为另一个堆对象下游。

  • 背景:存在堆上对象A,白色(未被扫描);

存在堆上对象B,黑色(已完成扫描);

存在堆上对象C,被堆上对象B引用,白色(未被扫描)

  • moment1:B尝试建立对C的引用,插入写屏障被触发,C被置灰
  • moment2:A删除对C的引用,此时C已置灰,因此不会漏标

(3)case 3:栈对象删除引用,成为堆对象下游

  • 背景:存在栈上对象A,白色(未完成扫描,说明对应的栈未扫描);

存在堆上对象B,黑色(已完成扫描);

存在堆上对象C,被栈上对象A引用,白色(未被扫描)

  • moment1:B尝试建立对C的引用,插入写屏障被触发,C被置灰
  • moment2:A删除对C的引用,此时C已置灰,因此不会漏标

(4)case 4:一个栈中对象删除引用,另一个栈中对象建立引用

  • 背景:存在栈上对象A,白色(未扫描,这是因为对应的栈还未开始扫描);

存在栈上对象B,黑色(已完成扫描,说明对应的栈均已完成扫描);

存在堆上对象C,被栈上对象A引用,白色(未被扫描)

  • moment1:B建立对C的引用;
  • moment2:A删除对C的引用.
  • 结论:这种场景下,C要么已然被置灰,要么从某个灰对象触发仍然可达C.
  • 原因在于,对象的引用不是从天而降,一定要有个来处. 当前 case 中,对象B能建立指向C的引用,至少需要满足如下三个条件之一:

I 栈对象B原先就持有C的引用,如若如此,C就必然已处于置灰状态(因为B已是黑色)

II 栈对象B持有A的引用,通过A间接找到C. 然而这也是不可能的,因为倘若A能同时被另一个栈上的B引用到,那样A必然会升级到堆中,不再满足作为一个栈对象的前提;

III B同栈内存在其他对象X可达C,此时从X出发,必然存在一个灰色对象,从其出发存在可达C的路线.

综上,我们得以证明混合写屏障是能够胜任并发GC场景的解决方案,并且满足栈无须添加屏障的前提。

GC源码

1 源码导读

1.1 源码框架

首先给出整体的源码走读框架,供大家总览全局,避免晕车.

1.2 文件位置

GC中各子流程聚焦于不同源码文件中,目录供大家一览,感兴趣可以连贯阅读.

流程文件
标记准备runtime/mgc.go
调步策略runtime/mgcpacer.go
并发标记runtime/mgcmark.go
清扫流程runtime/msweep.go
位图标识runtime/mbitmap.go
触发屏障runtime/mbwbuf.go
内存回收runtime/mgcscavenge.go

2 触发GC

下面顺沿源码框架,开启走读流程. 本章首先聊聊,GC阶段是如何被触发启动的.

2.1 触发GC类型

触发 GC 的事件类型可以分为如下三种:

类型触发事件校验条件
gcTriggerHeap分配对象时触发堆已分配内存达到阈值
gcTriggerTime由 forcegchelper 守护协程定时触发每2分钟触发一次
gcTriggerCycle用户调用 runtime.GC 方法上一轮 GC 已结束

在触发GC时,会通过 gcTrigger.test 方法,结合具体的触发事件类型进行触发条件的校验,校验条件展示于上表,对应的源码如下:

type gcTriggerKind int


const (
    // 根据堆分配内存情况,判断是否触发GC
    gcTriggerHeap gcTriggerKind = iota
    // 定时触发GC
    gcTriggerTime
    // 手动触发GC
    gcTriggerCycle
}


func (t gcTrigger) test() bool {
    // ...
    switch t.kind {
    case gcTriggerHeap:
        // ...
        trigger, _ := gcController.trigger()
        return atomic.Load64(&gcController.heapLive) >= trigger
    case gcTriggerTime:
        if gcController.gcPercent.Load() < 0 {
            return false
        }
        lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
        return lastgc != 0 && t.now-lastgc > forcegcperiod
    case gcTriggerCycle:
        // ...
        return int32(t.n-work.cycles) > 0
    }
    return true
}

2.2 定时触发GC

定时触发 GC 的源码方法及文件如下表所示:

方法文件作用
initruntime/proc.go开启一个 forcegchelper 协程
forcegchelperruntime/proc.go循环阻塞挂起+定时触发 gc
mainruntime/proc.go调用 sysmon 方法
sysmonruntime/proc.go定时唤醒 forcegchelper,从而触发 gc
gcTrigger.testruntime/mgc.go校验是否满足 gc 触发条件
gcStartruntime/mgc.go标记准备阶段主流程方法

(1)启动定时触发协程并阻塞等待

runtime 包初始化的时候,即会异步开启一个守护协程,通过 for 循环 + park 的方式,循环阻塞等待被唤醒.

当被唤醒后,则会调用 gcStart 方法进入标记准备阶段,尝试开启新一轮 GC,此时触发 GC 的事件类型正是 gcTriggerTime(定时触发).

在 gcStart 方法内部,还会通过 gcTrigger.test 方法进一步校验触发GC的条件是否满足,留待第3章再作展开.

// runtime 包下的全局变量
var  forcegc   forcegcstate


type forcegcstate struct {
    lock mutex
    g    *g
    idle uint3


func init() {
    go forcegchelper()
}


func forcegchelper() {
    forcegc.g = getg()
    lockInit(&forcegc.lock, lockRankForcegc)
    for {
        lock(&forcegc.lock)
        // ...
        atomic.Store(&forcegc.idle, 1)
        // 令 forcegc.g 陷入被动阻塞,g 的状态会设置为 waiting,当达成 gc 条件时,g 的状态会被切换至 runnable,方法才会向下执行
        goparkunlock(&forcegc.lock, waitReasonForceGCIdle, traceEvGoBlock, 1)
        // g 被唤醒了,则调用 gcStart 方法真正开启 gc 主流程
        gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
    }
}

(2)唤醒定时触发协程

runtime 包下的 main 函数会通过 systemstack 操作切换至 g0,并调用 sysmon 方法,轮询尝试将 forcegchelper 协程添加到 gList 中,并在 injectglist 方法内将其唤醒:

func main() {
    // ...
    systemstack(func() {
        newm(sysmon, nil, -1)
    })   
    // ...
}
func sysmon() {
    // ...
    for { 
        // 通过 gcTrigger.test 方法检查是否需要发起 gc,触发类型为 gcTriggerTime:定时触发
        if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {     
            lock(&forcegc.lock)
            forcegc.idle = 0
            var list gList
            // 需要发起 gc,则将 forcegc.g 注入 list 中, injectglist 方法内部会执行唤醒操作
            list.push(forcegc.g)
            injectglist(&list)
            unlock(&forcegc.lock)
        }
        // ...
    }
}

(3)定时触发GC条件校验

在 gcTrigger.test 方法中,针对 gcTriggerTime 类型的触发事件,其校验条件则是触发时间间隔达到 2分钟以上.

// 单位 nano,因此实际值为 120s = 2min
var forcegcperiod int64 = 2 * 60 * 1e9


func (t gcTrigger) test() bool {
    // ...
    switch t.kind {
    // ...
    // 每 2 min 发起一轮 gc
    case gcTriggerTime:
        // ...
        lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
        return lastgc != 0 && t.now-lastgc > forcegcperiod
    // ...
    }
    return true
}

2.3 对象分配触发GC

该流程源码方法及文件如下表所示:

方法文件作用
mallocgcruntime/malloc.go分配对象主流程方法
gcTrigger.testruntime/mgc.go校验是否满足 gc 触发条件
gcStartruntime/mgc.go标记准备阶段主流程方法

在分配对象的malloc方法中,倘若满足如下两个条件之一,都会发起一次触发GC的尝试:

  • 需要初始化一个大小超过32KB的大对象
  • 待初始化对象在mcache中对应spanClass的mspan空间已用尽

此时触发事件类型为gcTriggerHeap,并在调用gcStart方法的内部执行gcTrigger.test进行条件检查.

(1)对象分配触发GC

mallocgc 是分配对象的主流程方法:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // ...
    shouldhelpgc := false
    // ...
    if size <= maxSmallSize {
        if noscan && size < maxTinySize {
            // ...
            if v == 0 {
                // 倘若 mcache 中对应 spanClass 的 mspan 已满,置 true
                v, span, shouldhelpgc = c.nextFree(tinySpanClass)
            }
            // ...
        } else {
            // ...
            if v == 0 {
                // 倘若 mcache 中对应 spanClass 的 mspan 已满,置 true
                v, span, shouldhelpgc = c.nextFree(spc)
            }
            // ...
        }
    } else {
        // 申请大小大于 32KB 的大对象,直接置为 true
        shouldhelpgc = true
        // ...
    }


    // ...
    // 尝试触发 gc,类型为 gcTriggerHeap,触发校验逻辑同样位于 gcTrigger.test 方法中
    if shouldhelpgc {
        if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
            gcStart(t)
        }
    }


   // ...
}

(2)校验GC触发条件

在 gcTrigger.test 方法中,针对 gcTriggerHeap 类型的触发事件,其校验条件是判断当前堆已使用内存是否达到阈值. 此处的堆内存阈值会在上一轮GC结束时进行设定,具体内容将在本文6.4小节详细讨论.

func (t gcTrigger) test() bool {
    // ...
    switch t.kind {
    case gcTriggerHeap:      
        trigger, _ := gcController.trigger()
        // 倘若堆中已使用的内存大小达到了阈值,则会真正执行 gc
        return atomic.Load64(&gcController.heapLive) >= trigger
    // ...
    }
    return true
}

2.3 手动触发GC

最后一种触发的GC形式是手动触发,入口位于 runtime 包的公共方法:runtime.GC

方法文件作用
GCruntime/mgc.go手动触发GC主流程方法
gcStartruntime/mgc.go标记准备阶段主流程方法

用户手动触发 GC时,事件类型为 gcTriggerCycle.

func GC() {
    // ...
    gcStart(gcTrigger{kind: gcTriggerCycle, n: n + 1})
    // ...
}

针对这种类型的校验条件是,上一轮GC已经完成,此时能够开启新一轮GC任务.

func (t gcTrigger) test() bool {
    // ...
    switch t.kind {
    // ...
    case gcTriggerCycle:
        return int32(t.n-work.cycles) > 0
    }
    return true
}

3 标记准备

本章开始步入标记准备阶段的内容探讨中,本章会揭秘屏障机制以及 STW 的底层实现,所涉及的源码方法及文件位置如下表所示:

方法文件作用
gcStartruntime/mgc.go标记准备阶段主流程方法
gcBgMarkStartWorkersruntime/mgc.go批量启动标记协程 ,数量对应于 P 的个数
gcBgMarkWorkerruntime/mgc.go标记协程主流程方法,启动之初会先阻塞挂起,待被唤醒后真正执行任务
stopTheWorldWithSemaruntime/mgc.go即STW,停止P.
gcControllerState.startCycleruntime/mgcspacer.go限制标记协程执行频率,目标是令标记协程对CPU的占用率趋近于 25%
setGCPhaseruntime/mgc.go更新GC阶段. 当为标记阶段(GCMark)时会启用混合写屏障
gcMarkTinyAllocsruntime/mgc.go标记 mcache 中的 tiny 对象
startTheWorldWithSemaruntime/mgc.go与STW相反,会重新唤醒各个P

3.1 主流程

gcStart 是标记准备阶段的主流程方法,方法中完成的工作包括:

  • 再次检查GC触发条件是否达成
  • 异步启动对应于P数量的标记协程
  • Stop the world
  • 控制标记协程数量和执行时长,使得CPU占用率趋近25%
  • 设置GC阶段为GCMark,开启混合混合写屏障
  • 标记mcache中的tiny对象
  • Start the world
func gcStart(trigger gcTrigger) {
    // ...
    // 检查是否达到 GC 条件,会根据 trigger 类型作 dispatch,常见的包括堆内存大小、GC 时间间隔、手动触发的类型
    for trigger.test() && sweepone() != ^uintptr(0) {
        sweep.nbgsweep++
    }
    
    // 上锁
    semacquire(&work.startSema)
    // 加锁 double check
    if !trigger.test() {
        semrelease(&work.startSema)
        return
    }
    
    // ...
    // 由于进入了 GC 模式,会根据 P 的数量启动多个 GC 并发标记协程,但是会先阻塞挂起,等待被唤醒
    gcBgMarkStartWorkers()
    
    // ...
    // 切换到 g0,执行 Stop the world 操作
    systemstack(stopTheWorldWithSema)
    // ...
    
    // 限制标记协程占用 CPU 时间片的比例为趋近 25%
    gcController.startCycle(now, int(gomaxprocs), trigger)
     
    // 设置GC阶段为_GCmark,启用混合写屏障
    setGCPhase(_GCmark)


    // ...
    // 对 mcache 中的 tiny 对象进行标记
    gcMarkTinyAllocs()


    // 切换至 g0,重新 start the world
    systemstack(func() {
        now = startTheWorldWithSema(trace.enabled)
       // ...
    })
    // ...
}

3.2 启动标记协程

gcBgMarkStartWorkers方法中启动了对应于 P 数量的并发标记协程,并且通过notetsleepg的机制,使得for循环与gcBgMarkWorker内部形成联动节奏,确保每个P都能分得一个gcBgMarkWorker标记协程.

func gcBgMarkStartWorkers() {
    // 开启对应于 P 个数标记协程,但是内部将 g 添加到全局的 pool 中,并通过 gopark 阻塞挂起
    for gcBgMarkWorkerCount < gomaxprocs {
        go gcBgMarkWorker()
        // 挂起,等待 gcBgMarkWorker 方法中完成标记协程与 P 的绑定后唤醒
        notetsleepg(&work.bgMarkReady, -1)
        noteclear(&work.bgMarkReady)
        
        gcBgMarkWorkerCount++
    }
}

gcBgMarkWorker 方法中将g包装成一个node添加到全局的gcBgMarkWorkerPool中,保证标记协程与P的一对一关联,并调用 gopark 方法将当前 g 挂起,等待被唤醒.

func gcBgMarkWorker() {
    gp := getg()
    node := new(gcBgMarkWorkerNode)
    gp.m.preemptoff = ""
    node.gp.set(gp)
    node.m.set(acquirem())
    // 唤醒外部的 for 循环
    notewakeup(&work.bgMarkReady)
    
    for {
        // 当前 g 阻塞至此,直到 gcController.findRunnableGCWorker 方法被调用,会将当前 g 唤醒
        gopark(func(g *g, nodep unsafe.Pointer) bool {
            node := (*gcBgMarkWorkerNode)(nodep)
            // ...
            // 将当前 g 包装成一个 node 添加到 gcBgMarkWorkerPool 中
            gcBgMarkWorkerPool.push(&node.node)          
            return true
        }, unsafe.Pointer(node), waitReasonGCWorkerIdle, traceEvGoBlock, 0)
        // ...
    }
}

3.3 Stop the world

gcStart 方法在调用gcBgMarkStartWorkers方法异步启动标记协程后,会执行STW操作停止所有用户协程,其实现位于 stopTheWorldWithSema 方法,核心点如下:

  • 取锁:sched.lock
  • 将 sched.gcwaiting 标识置为 1,后续的调度流程见其标识,都会阻塞挂起
  • 抢占所有g,并将 P 的状态置为 syscall
  • 将所有P的状态改为 stop
  • 倘若部分任务无法抢占,则等待其完成后再进行抢占
  • 调用方法worldStopped收尾,世界停止了
func stopTheWorldWithSema() {
    _g_ := getg()


    // 全局调度锁
    lock(&sched.lock)
    sched.stopwait = gomaxprocs
    // 此标识置 1,之后所有的调度都会阻塞等待
    atomic.Store(&sched.gcwaiting, 1)
    // 发送抢占信息抢占所有 G,后将 p 状态置为 syscall
    preemptall()
    // 将当前 p 的状态置为 stop
    _g_.m.p.ptr().status = _Pgcstop // Pgcstop is only diagnostic.
    sched.stopwait--
    // 把所有 p 的状态置为 stop
    for _, p := range allp {
        s := p.status
        if s == _Psyscall && atomic.Cas(&p.status, s, _Pgcstop) {
            // ...
            p.syscalltick++
            sched.stopwait--
        }
    }
    // 把空闲 p 的状态置为 stop
    now := nanotime()
    for {
        p, _ := pidleget(now)
        if p == nil {
            break
        }
        p.status = _Pgcstop
        sched.stopwait--
    }
    wait := sched.stopwait > 0
    unlock(&sched.lock)




    // 倘若有 p 无法被抢占,则阻塞直到将其统统抢占完成
    if wait {
        for {
            // wait for 100us, then try to re-preempt in case of any races
            if notetsleep(&sched.stopnote, 100*1000) {
                noteclear(&sched.stopnote)
                break
            }
            preemptall()
        }
    }


    // native 方法,stop the world
    worldStopped()
}

3.4 控制标记协程频率

gcStart方法中,还会通过gcController.startCycle方法,将标记协程对CPU的占用率控制在 25% 左右. 此时,根据P的数量是否能被4整除,分为两种处理方式:

  • 倘若P的个数能被4整除,则简单将标记协程的数量设置为P/4
  • 倘若P的个数不能被4整除,则通过控制标记协程执行时长的方式,来使全局标记协程对CPU的使用率趋近于25%
// 目标:标记协程对CPU的使用率维持在25%的水平
const gcBackgroundUtilization = 0.25


func (c *gcControllerState) startCycle(markStartTime int64, procs int, trigger gcTrigger) {
    // ...
    // P 的个数 * 0.25
    totalUtilizationGoal := float64(procs) * gcBackgroundUtilization
    // P 的个数 * 0.25 后四舍五入取整
    c.dedicatedMarkWorkersNeeded = int64(totalUtilizationGoal + 0.5)
    utilError := float64(c.dedicatedMarkWorkersNeeded)/totalUtilizationGoal - 1
    const maxUtilError = 0.3
    // 倘若 P 的个数不能被 4 整除
    if utilError < -maxUtilError || utilError > maxUtilError {        
        if float64(c.dedicatedMarkWorkersNeeded) > totalUtilizationGoal {    
            c.dedicatedMarkWorkersNeeded--
        }
        // 计算出每个 P 需要额外执行标记任务的时间片比例
        c.fractionalUtilizationGoal = (totalUtilizationGoal - float64(c.dedicatedMarkWorkersNeeded)) / float64(procs)
    // 倘若 P 的个数可以被 4 整除,则无需控制执行时长
    } else {
        c.fractionalUtilizationGoal = 0
    }
    // ...
}

3.5 设置写屏障

随后,gcStart方法会调用setGCPhase方法,标志GC正式进入并发标记(GCmark)阶段. 我们观察该方法代码实现,可以注意到,在GCMark和GCMarkTermination阶段中,会启用混合写屏障.

func setGCPhase(x uint32) {
    atomic.Store(&gcphase, x)
    writeBarrier.needed = gcphase == _GCmark || gcphase == _GCmarktermination
    writeBarrier.enabled = writeBarrier.needed || writeBarrier.cgo
}

在混合写屏障机制中,核心是会将需要置灰的对象添加到当前P的wbBuf缓存中. 随后在并发标记缺灰、标记终止前置检查等时机会执行wbBufFlush1方法,批量地将wbBuf中的对象释放出来进行置灰,保证达到预期的效果.

func wbBufFlush(dst *uintptr, src uintptr) {
    // ...
    systemstack(func() {
        wbBufFlush1(getg().m.p.ptr())
    })
}

wbBufFlush1方法中涉及了对象置灰操作,其包含了在对应mspan的bitmap中打点标记以及将对象添加到gcw队列两步.此处先不细究,后文4.3小节中,我们再作详细介绍.

func wbBufFlush1(_p_ *p) {
    // 获取当前 P 通过屏障机制缓存的指针
    start := uintptr(unsafe.Pointer(&_p_.wbBuf.buf[0]))
    n := (_p_.wbBuf.next - start) / unsafe.Sizeof(_p_.wbBuf.buf[0])
    ptrs := _p_.wbBuf.buf[:n]


    // 将缓存的指针作标记,添加到 gcw 队列
    gcw := &_p_.gcw
    pos := 0
    for _, ptr := range ptrs {
        // ...
        obj, span, objIndex := findObject(ptr, 0, 0)
        if obj == 0 {
            continue
        }
        // 打标
        mbits := span.markBitsForIndex(objIndex)
        if mbits.isMarked() {
            continue
        }
        mbits.setMarked()
        // ...
    }


    // 所有缓存对象入队
    gcw.putBatch(ptrs[:pos])
    _p_.wbBuf.reset()
}

3.6 Tiny 对象标记

gcStart方法随后还会调用gcMarkTinyAllocs方法中,遍历所有的P,对mcache中的Tiny对象分别调用greyobject方法进行置灰.

func gcMarkTinyAllocs() {
    assertWorldStopped()


    for _, p := range allp {
        c := p.mcache
        if c == nil || c.tiny == 0 {
            continue
        }
        // 获取 tiny 对象
        _, span, objIndex := findObject(c.tiny, 0, 0)
        gcw := &p.gcw
        // tiny 对象置灰(标记 + 添加入队)
        greyobject(c.tiny, 0, 0, span, gcw, objIndex)
    }
}

3.7 Start the world

startTheWorldWithSema与stopTheWorldWithSema形成对偶. 该方法会重新恢复世界的生机,将所有P唤醒. 倘若缺少M,则构造新的M为P补齐.

func startTheWorldWithSema(emitTraceEvent bool) int64 {
    assertWorldStopped()
   
    // ...   
    p1 := procresize(procs)
    // 重启世界
    worldStarted()


    // 遍历所有 p,将其唤醒
    for p1 != nil {
        p := p1
        p1 = p1.link.ptr()
        if p.m != 0 {
            mp := p.m.ptr()
            p.m = 0
            if mp.nextp != 0 {
                throw("startTheWorld: inconsistent mp->nextp")
            }
            mp.nextp.set(p)
            notewakeup(&mp.park)
        } else {           
            newm(nil, p, -1)
        }
    }


    // ...
    return startTime
}

4 并发标记

下面比如难度曲线最陡峭的并发标记部分. 这部分内容承接上文3.2小节,讲述标记协程在被唤醒后,需要执行的任务细节.

首先,我们先来理一下,这些标记协程是如何被唤醒的.

4.1 调度标记协程

方法文件作用
scheduleruntime/proc.go调度协程
findRunnableruntime/proc.go获取可执行的协程
gcControllerState.findRunnableGCWorkerruntime/mgcspacer.go获取可执行的标记协程,同时将该协程唤醒
executeruntime/proc.go执行协程

在GMP调度的主干方法schedule中,会通过g0调用findRunnable方法P寻找下一个可执行的协程,找到后会调用execute方法,内部完成由g0->g的切换,真正执行用户协程中的任务.

func schedule() {
    // ...
    gp, inheritTime, tryWakeP := findRunnable()
    // ...
    execute(gp, inheritTime)
}

在findRunnable方法中,当通过全局标识gcBlackenEnabled发现当前开启GC模式时,会调用 gcControllerState.findRunnableGCWorker唤醒并取得标记协程.

func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
    // ...
    if gcBlackenEnabled != 0 {
        gp, now = gcController.findRunnableGCWorker(_p_, now)
        if gp != nil {
            return gp, false, true
        }
    }
    // ...
}

在gcControllerState.findRunnableGCWorker方法中,会从全局的标记协程池 gcBgMarkWorkerPool获取到一个封装了标记协程的node. 并通过gcControllerState中 dedicatedMarkWorkersNeeded、fractionalUtilizationGoal等字段标识判定标记协程的标记模式,然后将标记协程状态由Gwaiting唤醒为Grunnable,并返回给 g0 用于执行.

这里谈到的标记模式对应了上文3.4小节的内容,并将在下文4.2小节详细介绍.

func (c *gcControllerState) findRunnableGCWorker(_p_ *p, now int64) (*g, int64) {
    // ...
    // 保证当前 _p_ 是可以调度标记协程的,每个 p 只能执行一个标记协程
    if !gcMarkWorkAvailable(_p_) {
        return nil, now
    }


    // 从全局标记协程池子 gcBgMarkWorkerPool 中取出 g
    node := (*gcBgMarkWorkerNode)(gcBgMarkWorkerPool.pop())
    // ...


    decIfPositive := func(ptr *int64) bool {
        for {
            v := atomic.Loadint64(ptr)
            if v <= 0 {
                return false
            }


            if atomic.Casint64(ptr, v, v-1) {
                return true
            }
        }
    }


    // 确认标记的模式
    if decIfPositive(&c.dedicatedMarkWorkersNeeded) {      
        _p_.gcMarkWorkerMode = gcMarkWorkerDedicatedMode
    } else if c.fractionalUtilizationGoal == 0 {
                gcBgMarkWorkerPool.push(&node.node)
        return nil, now
    } else {
        delta := now - c.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, now
        }
        // Run a fractional worker.
        _p_.gcMarkWorkerMode = gcMarkWorkerFractionalMode
    }


   // 将标记协程的状态置为 runnable,填了 gcBgMarkWorker 方法中 gopark 操作留下的坑
    gp := node.gp.ptr()
    casgstatus(gp, _Gwaiting, _Grunnable)
    return gp, n
}

4.2 并发标记启动

方法文件作用
gcBgMarkWorkerruntime/mgc.go标记协程主方法
gcDrainruntime/mgcmark.go循环处理gcw队列主方法
markrootruntime/mgcmark.go标记根对象
scanobjectruntime/mgcmark.go扫描一个对象,将其指向对象分别置灰
greyobjectruntime/mgcmark.go将一个对象置灰
markBits.setMarkedruntime/mbitmap.go标记一个对象
gcWork.putFast/putruntime/mgcwork.go将一个对象加入gcw队列

标记协程被唤醒后,主线又重新拉回到gcBgMarkWorker方法中,此时会根据3.4小节中预设的标记模式,调用gcDrain方法开始执行并发标记工作.

标记模式包含以下三种:

  • gcMarkWorkerDedicatedMode:专一模式. 需要完整执行完标记任务,不可被抢占
  • gcMarkWorkerFractionalMode:分时模式. 当标记协程执行时长达到一定比例后,可以被抢占
  • gcMarkWorkerIdleMode: 空闲模式. 随时可以被抢占.

值得一提的是,在执行专一模式时,会先以可被抢占的模式尝试执行,倘若真的被用户协程抢占,则会先将当前P本地队列的用户协程投放到全局g队列中,再将标记模式改为不可抢占模式. 这样设计的优势是,通过负载均衡的方式,减少当前P下用户协程的等待时长,提高用户体验.

在gcDrain方法中,有两个核心的gcDrainFlags控制着标记协程的运行风格:

  • gcDrainIdle:空闲模式,随时可被抢占
  • gcDrainFractional:分时模式,执行一定比例的时长后可被抢占
type gcDrainFlags int
const (
    gcDrainUntilPreempt gcDrainFlags = 1 << iota
    gcDrainFlushBgCredit
    gcDrainIdle
    gcDrainFractional
)
func gcBgMarkWorker() {
        // ...


        node.m.set(acquirem())
        pp := gp.m.p.ptr() // P can't change with preemption disabled.


       // ...
        
       // 根据不同的运作模式,执行 gcDrain 方法:
        systemstack(func() {
          
            casgstatus(gp, _Grunning, _Gwaiting)
            switch pp.gcMarkWorkerMode {
            default:
                throw("gcBgMarkWorker: unexpected gcMarkWorkerMode")
            case gcMarkWorkerDedicatedMode:
               // 先按照可抢占模式执行标记协程,倘若被抢占,则将抢占协程添加到全局队列中,之后再以不可抢占模式执行标记协程
                gcDrain(&pp.gcw, gcDrainUntilPreempt|gcDrainFlushBgCredit)
                if gp.preempt {
                    // 将 p 本地队列中的 g 添加到全局队列
                    if drainQ, n := runqdrain(pp); n > 0 {
                        lock(&sched.lock)
                        globrunqputbatch(&drainQ, int32(n))
                        unlock(&sched.lock)
                    }
                }
                // Go back to draining, this time
                // without preemption.
                gcDrain(&pp.gcw, gcDrainFlushBgCredit)
            case gcMarkWorkerFractionalMode:
                gcDrain(&pp.gcw, gcDrainFractional|gcDrainUntilPreempt|gcDrainFlushBgCredit)
            case gcMarkWorkerIdleMode:
                gcDrain(&pp.gcw, gcDrainIdle|gcDrainUntilPreempt|gcDrainFlushBgCredit)
            }
            casgstatus(gp, _Gwaiting, _Grunning)
        })


        // ...
    }
}

4.3 标记主流程

gcDrain 方法是并发标记阶段的核心方法:

  • 在空闲模式(idle)和分时模式(fractional)下,会提前设好 check 函数(pollWork 和 pollFractionalWorkerExit)
  • 标记根对象
  • 循环从gcw缓存队列中取出灰色对象,执行scanObject方法进行扫描标记
  • 定期检查check 函数,判断标记流程是否应该被打断
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    // ...
    
    gp := getg().m.curg
    // 模式标记
    preemptible := flags&gcDrainUntilPreempt != 0
    flushBgCredit := flags&gcDrainFlushBgCredit != 0
    idle := flags&gcDrainIdle != 0


    // ...
    var check func() bool
    if flags&(gcDrainIdle|gcDrainFractional) != 0 {
        // ...
        if idle {
            check = pollWork
        } else if flags&gcDrainFractional != 0 {
            check = pollFractionalWorkerExit
        }
    }


    // 倘若根对象还未标记完成,则先进行根对象标记
    if work.markrootNext < work.markrootJobs {
        // Stop if we're preemptible or if someone wants to STW.
        for !(gp.preempt && (preemptible || atomic.Load(&sched.gcwaiting) != 0)) {
            job := atomic.Xadd(&work.markrootNext, +1) - 1
            if job >= work.markrootJobs {
                break
            }
            // 标记根对象
            markroot(gcw, job, flushBgCredit)
            // ...
        }
    }


    // 遍历队列,进行对象标记
    for !(gp.preempt && (preemptible || atomic.Load(&sched.gcwaiting) != 0)) {
        // work balance
        if work.full == 0 {
            gcw.balance()
        }


        // 尝试从 p 本地队列中获取灰色对象,无锁
        b := gcw.tryGetFast()
        if b == 0 {
            // 尝试从全局队列中获取灰色对象,加锁
            b = gcw.tryGet()
            if b == 0 {
                // 刷新写屏障缓存
                wbBufFlush(nil, 0)
                b = gcw.tryGet()
            }
        }
        if b == 0 {
            // 已无对象需要标记
            break
        }
        // 进行对象的标记,并顺延指针进行后续对象的扫描
        scanobject(b, gcw)


        // ...
        
        if gcw.heapScanWork >= gcCreditSlack {
            gcController.heapScanWork.Add(gcw.heapScanWork)
            // ...
            if checkWork <= 0 {
                // ...
                if check != nil && check() {
                    break
                }
            }
        }
    }


done:
    // 
}

4.4 灰对象缓存队列

4.3小节的源码中,涉及到一个重要的数据结构:gcw,这是灰色对象的存储代理和载体,在标记过程中需要持续不断地从从队列中取出灰色对象,进行扫描,并将新的灰色对象通过gcw添加到缓存队列.

灰对象缓存队列分为两层:

  • 每个P私有的gcWork,实现上由两条单向链表构成,采用轮换机制使用
  • 全局队列workType.full,底层是一个通过CAS操作维护的栈结构,由所有P共享

(1)gcWork

gcWork数据结构源代码如下所示.

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


type workbuf struct {
    workbufhdr
    obj [(_WorkbufSize - unsafe.Sizeof(workbufhdr{})) / goarch.PtrSize]uintptr
}




type workbufhdr struct {
    node lfnode 
    nobj int
}




type lfnode struct {
    next    uint64
    pushcnt uint
}

在gcDrain方法中,会持续不断地从当前P的gcw中获取灰色对象,在调用策略上,会先尝试取私有部分,再通过gcw代理取全局共享部分:

        // 尝试从 p 本地队列中获取灰色对象,无锁
        b := gcw.tryGetFast()
        if b == 0 {
            // 尝试从全局队列中获取灰色对象,加锁
            b = gcw.tryGet()
            if b == 0 {
                // 因为缺灰,会释放写屏障缓存,进行补灰操作
                wbBufFlush(nil, 0)
                b = gcw.tryGet()
            }
       }

gcWork.tryGetFast方法中,会先尝试从gcWork.wbuf1 中获取灰色对象.

func (w *gcWork) tryGetFast() uintptr {
    wbuf := w.wbuf1
    if wbuf == nil || wbuf.nobj == 0 {
        return 0
    }


    wbuf.nobj--
    return wbuf.obj[wbuf.nobj]
}

倘若gcWork.wbuf1缺灰,则会在gcWork.tryGet方法中交换wbuf1和wbuf2,再尝试获取一次. 倘若仍然缺灰,则会调用 trygetfull 方法,从全局缓存队列中获取.

func (w *gcWork) tryGet() uintptr {
    wbuf := w.wbuf1
    if wbuf == nil {
        w.init()
        wbuf = w.wbuf1
        // wbuf is empty at this point.
    }
    if wbuf.nobj == 0 {
        w.wbuf1, w.wbuf2 = w.wbuf2, w.wbuf1
        wbuf = w.wbuf1
        if wbuf.nobj == 0 {
            owbuf := wbuf
            wbuf = trygetfull()
            if wbuf == nil {
                return 0
            }
            putempty(owbuf)
            w.wbuf1 = wbuf
        }
    }


    wbuf.nobj--
    return wbuf.obj[wbuf.nobj]
}

(2)workType.full

灰色对象的全局缓存队列是一个栈结构,调用pop方法时,会通过CAS方式依次从栈顶取出一个缓存链表.

var work workType
type workType struct {
    full lfstack
    // ...
}
type lfstack uint64


func (head *lfstack) push(node *lfnode) {
    // ...
}


func (head *lfstack) pop() unsafe.Pointer {
    for {
        old := atomic.Load64((*uint64)(head))
        if old == 0 {
            return nil
        }
        node := lfstackUnpack(old)
        next := atomic.Load64(&node.next)
        if atomic.Cas64((*uint64)(head), old, next) {
            return unsafe.Pointer(node)
        }
    }
}
func trygetfull() *workbuf {
    b := (*workbuf)(work.full.pop())
    if b != nil {
        b.checknonempty()
        return b
    }
    return b
}

4.5 三色标记实现

Golang GC的标记流程基于三色标记法实现. 此时在将理论落地实践前,我们需要先搞清楚一个细节,那就是在代码层面,黑、灰、白这三种颜色如何实现.

在前文 Golang内存模型与分配机制中聊过,每个对象会有其从属的mspan,在mspan中,有着两个bitmap存储着每个对象大小的内存的状态信息:

  • allocBits:标识内存的闲忙状态,一个bit位对应一个object大小的内存块,值为1代表已使用;值为0代表未使用
  • gcmakrBits:只在GC期间使用. 值为1代表占用该内存块的对象被标记存活.

在垃圾清扫的过程中,并不会真正地将内存进行回收,而是在每个mspan中使用gcmakrBits对allocBits进行覆盖. 在分配新对象时,当感知到mspan的allocBits中,某个对象槽位bit位值为0,则会将其视为空闲内存进行使用,其本质上可能是一个覆盖操作.

type mspan struct {
    // ...
    allocBits  *gcBits
    gcmarkBits *gcBits
    // ...
}


type gcBits uint8

介绍完了bitmap设定之后,下面回到三种标记色的实现当中:

  • 黑色:对象在mspan.gcmarkBits中bit位值为1,且对象已经离开灰对象缓存队列(4.4小节谈及)
  • 灰色:对象在mspan.gcmarkBits中bit位值为1,且对象仍处于灰对象缓存队列中
  • 白色:对象在mspan.gcmarkBits中bit位值位0.

有了以上的基础设定之后,我们已经可以在脑海中搭建三色标记法的实现框架:

  • 扫描根对象,将gcmarkBits中的bit位置1,并添加到灰对象缓存队列
  • 依次从灰对象缓存队列中取出灰对象,将其指向对象的gcmarkBits 中的bit位置1并添加到会对象缓存队列

4.6 中止标记协程

gcDrain方法中,针对空闲模式idle和分时模式fractional,会设定check函数,在循环扫描的过程中检测是否需要中断当前标记协程.

func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    // ...
    // ...
    idle := flags&gcDrainIdle != 0


    // ...
    var check func() bool
    if flags&(gcDrainIdle|gcDrainFractional) != 0 {
        // ...
        if idle {
            check = pollWork
        } else if flags&gcDrainFractional != 0 {
            check = pollFractionalWorkerExit
        }
    }
    // ...
    // 遍历队列,进行对象标记
    for !(gp.preempt && (preemptible || atomic.Load(&sched.gcwaiting) != 0)) {
        // ...   
        if gcw.heapScanWork >= gcCreditSlack {
            gcController.heapScanWork.Add(gcw.heapScanWork)
            // ...
            if checkWork <= 0 {
                // ...
                if check != nil && check() {
                    break
                }
            }
        }
    }




done:
    //
}

对应于idle模式的check函数是pollwork,方法中判断P本地队列存在就绪的g或者存在就绪的网络写成,就会对当前标记协程进行中断:

func pollWork() bool {
    if sched.runqsize != 0 {
        return true
    }
    p := getg().m.p.ptr()
    if !runqempty(p) {
        return true
    }
    if netpollinited() && atomic.Load(&netpollWaiters) > 0 && sched.lastpoll != 0 {
        if list := netpoll(0); !list.empty() {
            injectglist(&list)
            return true
        }
    }
    return false
}

对应于 fractional 模式的check函数是pollFractionalWorkerExit,倘若当前标记协程执行的时间比例大于 1.2 倍的 fractionalUtilizationGoal 阈值(3.4小节中设置),就会中止标记协程.

func pollFractionalWorkerExit() bool {
    
    now := nanotime()
    delta := now - gcController.markStartTime
    if delta <= 0 {
        return true
    }
    p := getg().m.p.ptr()
    selfTime := p.gcFractionalMarkTime + (now - p.gcMarkWorkerStartTime)
   
    return float64(selfTime)/float64(delta) > 1.2*gcController.fractionalUtilizationGoal
}

4.7 扫描根对象

在gcDrain方法正式开始循环扫描前,还会先对根对象进行扫描标记. Golang中的根对象包括如下几项:

  • .bss段内存中的未初始化全局变量
  • .data段内存中的已初始化变量)
  • span 中的 finalizer
  • 各协程栈

实现根对象扫描的方法是markroot:

func markroot(gcw *gcWork, i uint32, flushBgCredit bool) int64 {
    var workDone int64
    var workCounter *atomic.Int64
    switch {
    // 处理已初始化的全局变量
    case work.baseData <= i && i < work.baseBSS:
        workCounter = &gcController.globalsScanWork
        for _, datap := range activeModules() {
            workDone += markrootBlock(datap.data, datap.edata-datap.data, datap.gcdatamask.bytedata, gcw, int(i-work.baseData))
        }
    // 处理未初始化的全局变量
    case work.baseBSS <= i && i < work.baseSpans:
        workCounter = &gcController.globalsScanWork
        for _, datap := range activeModules() {
            workDone += markrootBlock(datap.bss, datap.ebss-datap.bss, datap.gcbssmask.bytedata, gcw, int(i-work.baseBSS))
        }
    // 处理 finalizer 队列
    case i == fixedRootFinalizers:
        for fb := allfin; fb != nil; fb = fb.alllink {
            cnt := uintptr(atomic.Load(&fb.cnt))
            scanblock(uintptr(unsafe.Pointer(&fb.fin[0])), cnt*unsafe.Sizeof(fb.fin[0]), &finptrmask[0], gcw, nil)
        }
    //  释放已终止的 g 的栈
    case i == fixedRootFreeGStacks:
        systemstack(markrootFreeGStacks)
    // 扫描 mspan 中的 special
    case work.baseSpans <= i && i < work.baseStacks:
        markrootSpans(gcw, int(i-work.baseSpans))


    default:
        // ...
        // 获取需要扫描的 g
        gp := work.stackRoots[i-work.baseStacks]
        // ...
        // 切回到 g0执行工作,扫描 g 的栈
        systemstack(func() {
            // ...
            // 栈扫描
            workDone += scanstack(gp, gcw)
           // ...
        })
    }
    // ...
    return workDone
}

其中,栈扫描方法链路展开如下:

func scanstack(gp *g, gcw *gcWork) int64 {
    // ...


    scanframe := func(frame *stkframe, unused unsafe.Pointer) bool {
        scanframeworker(frame, &state, gcw)
        return true
    }
    gentraceback(^uintptr(0), ^uintptr(0), 0, gp, 0, nil, 0x7fffffff, scanframe, nil, 0)
   // ...
}
func scanframeworker(frame *stkframe, state *stackScanState, gcw *gcWork) {
    // ...
    // 扫描局部变量
    if locals.n > 0 {
        size := uintptr(locals.n) * goarch.PtrSize
        scanblock(frame.varp-size, size, locals.bytedata, gcw, state)
    }


    // 扫描函数参数
    if args.n > 0 {
        scanblock(frame.argp, uintptr(args.n)*goarch.PtrSize, args.bytedata, gcw, state)
    }
    // ...
}

不论是全局变量扫描还是栈变量扫描,底层都会调用到scanblock方法. 在扫描时,会通过位图ptrmask辅助加速流程. 在 ptrmask当中,每个bit位对应了一个指针大小(8B)的位置的标识信息,指明当前位置是否是指针,倘若非指针,则直接跳过扫描.

此外,在标记一个对象时,需要获取到该对象所在mspan,这一过程会使用到heapArena中关于页和mspan间的映射索引(如有存疑可以看我的文章 Golang内存模型与分配机制),这部分内容放在 4.7 小节中集中阐述.

func scanblock(b0, n0 uintptr, ptrmask *uint8, gcw *gcWork, stk *stackScanState) {
  // ...
  b := b0
  n := n0
  // 遍历待扫描的地址
  for i := uintptr(0); i < n; {
  // 找到 bitmap 对应的 byte. ptrmask 辅助标识了 .data 一个指针的大小,bit 位为 1 代表当前位置是一个指针
  bits := uint32(*addb(ptrmask, i/(goarch.PtrSize*8)))
       // 非指针,跳过
   if bits == 0 {
        i += goarch.PtrSize * 8
        continue
    }
    for j := 0; j < 8 && i < n; j++ {
      if bits&1 != 0 {
      // Same work as in scanobject; see comments there.
        p := *(*uintptr)(unsafe.Pointer(b + i))
        if p != 0 {
          if obj, span, objIndex := findObject(p, b, i); obj != 0 {
            greyobject(obj, b, i, span, gcw, objIndex)
          } else if stk != nil && p >= stk.stack.lo && p < stk.stack.hi {
           stk.putPtr(p, false)
          }
        }
      }
      bits >>= 1
      i += goarch.PtrSize
    }
  }
 }
  1. 8扫描普通对象

gcDrain 方法中,会持续从灰对象缓存队列中取出灰对象,然后采用scanobject 方法进行处理.

func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    // ...
    // 遍历队列,进行对象标记
    for !(gp.preempt && (preemptible || atomic.Load(&sched.gcwaiting) != 0)) {
       
        // 尝试从 p 本地队列中获取灰色对象,无锁
        b := gcw.tryGetFast()
        if b == 0 {
            // 尝试从全局队列中获取灰色对象,加锁
            b = gcw.tryGet()
            if b == 0 {
                // 刷新写屏障缓存
                wbBufFlush(nil, 0)
                b = gcw.tryGet()
            }
        }
        if b == 0 {
            // 已无对象需要标记
            break
        }
        // 进行对象的标记,并顺延指针进行后续对象的扫描
        scanobject(b, gcw)
    }

done:
    //
}

scanobject方法遍历当前灰对象中的指针,依次调用greyobject方法将其指向的对象进行置灰操作.

const (
    bitPointer = 1 << 0
    bitScan    = 1 << 4
)


func scanobject(b uintptr, gcw *gcWork) {
    // 通过地址映射到所属的页
    // 通过 heapArena 中的映射信息,从页映射到所属的 mspan
    hbits := heapBitsForAddr(b)
    s := spanOfUnchecked(b)
    n := s.elemsize
    // ...


    // 顺延当前对象的成员指针,扫描后续的对象
    var i uintptr
    for i = 0; i < n; i, hbits = i+goarch.PtrSize, hbits.next() {
        // 通过 heapArena 中的 bitmap 记录的信息,加速遍历过程
        bits := hbits.bits()
        if bits&bitScan == 0 {
            break // no more pointers in this object
        }
        if bits&bitPointer == 0 {
            continue // not a pointer
        }


        obj := *(*uintptr)(unsafe.Pointer(b + i))
      
        if obj != 0 && obj-b >= n {
            // 对于遍历到的对象,将其置灰,并添加到队列中,等待后续扫描
            if obj, span, objIndex := findObject(obj, b, i); obj != 0 {
                greyobject(obj, b, i, span, gcw, objIndex)
            }
        }
    }
    // ...
}

在scanobject方法中还涉及到两项细节:

(1)如何通过对象地址找到其所属的mspan

首先根据对象地址,可以定位到对象所属的页,进一步可以通过地址偏移定位到其所属的heapArena. 在heapArena中,已经提前建立好了从页映射到mspan的索引,于是我们通过这一链路,实现从对象地址到mspan的映射. 从而能够获得mspan.gcmarkBits进行bitmap标记操作.

type heapArena struct {
    spans [pagesPerArena]*mspan
}
func findObject(p, refBase, refOff uintptr) (base uintptr, s *mspan, objIndex uintptr) {
    s = spanOf(p)
    // ...
    return
}


func spanOf(p uintptr) *mspan {
    // ...
    ri := arenaIndex(p)
    // ...
    l2 := mheap_.arenas[ri.l1()]
    // ...
    ha := l2[ri.l2()]
    // ...
    return ha.spans[(p/pageSize)%pagesPerArena]
}

(2)如何加速扫描过程

在heapArena中,通过一个额外的bitmap存储了内存信息:

bitmap中,每两个bit记录一个指针大小的内存空间的信息(8B),其中一个bit标志了该位置是否是指针;另一个bit标志了该位置往后是否还存在指针,于是在遍历扫描的过程中,可以通过这两部分信息推进for循环的展开速度.

const heapArenaBitmapBytes untyped int = 2097152


type heapArena struct {
    bitmap [heapArenaBitmapBytes]byte
    // ...
}
func heapBitsForAddr(addr uintptr) (h heapBits) {
    // 2 bits per word, 4 pairs per byte, and a mask is hard coded.
    arena := arenaIndex(addr)
    ha := mheap_.arenas[arena.l1()][arena.l2()]
    if ha == nil {
        return
    }
    h.bitp = &ha.bitmap[(addr/(goarch.PtrSize*4))%heapArenaBitmapBytes]
    h.shift = uint32((addr / goarch.PtrSize) & 3)
    h.arena = uint32(arena)
    h.last = &ha.bitmap[len(ha.bitmap)-1]
    return
}
func (h heapBits) bits() uint32 {
    // The (shift & 31) eliminates a test and conditional branch
    // from the generated code.
    return uint32(*h.bitp) >> (h.shift & 31)
}

4.9 对象置灰

对象置灰操作位于greyobject方法中. 如4.5小节所属,置灰分两步:

  • 将mspan.gcmarkBits对应bit位置为1
  • 将对象添加到灰色对象缓存队列
func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) {
    // ...
    // 在其所属的 mspan 中,将对应位置的 gcMark bitmap 位置为 1
    mbits.setMarked()
    
    // ...
    // 将对象添加到当前 p 的本地队列
    if !gcw.putFast(obj) {
        gcw.put(obj)
    }
}

4.10 新分配对象置黑

此外,值得一提的是,GC期间新分配的对象,会被直接置黑,呼应了混合写屏障中的设定.

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
        // ...
        if gcphase != _GCoff {
            gcmarknewobject(span, uintptr(x), size, scanSize)
        }
        // ...
}
func gcmarknewobject(span *mspan, obj, size, scanSize uintptr) {
    // ...
    objIndex := span.objIndex(obj)
    // 标记对象
    span.markBitsForIndex(objIndex).setMarked()
    // ...
}

5 辅助标记

5.1 辅助标记策略

在并发标记阶段,由于用户协程与标记协程共同工作,因此在极端场景下可能存在一个问题——倘若用户协程分配对象的速度快于标记协程标记对象的速度,这样标记阶段岂不是永远无法结束?

为规避这一问题,Golang GC引入了辅助标记的策略,建立了一个兜底的机制:在最坏情况下,一个用户协程分配了多少内存,就需要完成对应量的标记任务.

在每个用户协程 g 中,有一个字段 gcAssisBytes,象征GC期间可分配内存资产的概念,每个 g 在GC期间辅助标记了多大的内存空间,就会获得对应大小的资产,使得其在GC期间能多分配对应大小的内存进行对象创建.

type g struct {
    // ...
    gcAssistBytes int64
}
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // ...
    var assistG *g
    if gcBlackenEnabled != 0 {       
        assistG = getg()
        if assistG.m.curg != nil {
            assistG = assistG.m.curg
        }
        // 每个 g 会有资产
        assistG.gcAssistBytes -= int64(size)


        if assistG.gcAssistBytes < 0 {           
            gcAssistAlloc(assistG)
        }
    }
}

5.2 辅助标记执行

由于各对象中,可能存在部分不包含指针的字段,这部分字段是无需进行扫描的. 因此真正需要扫描的内存量会小于实际的内存大小,两者之间的比例通过gcController.assistWorkPerByte进行记录.

于是当一个用户协程在GC期间需要分配M大小的新对象时,实际上需要完成的辅助标记量应该为assistWorkPerByte*M.

辅助标记逻辑位于gcAssistAlloc方法. 在该方法中,会先尝试从公共资产池gcController.bgScanCredit中偷取资产,倘若资产仍然不够,则会通过systemstack方法切换至g0,并在 gcAssistAlloc1 方法内调用 gcDrainN 方法参与到并发标记流程当中.

func gcAssistAlloc(gp *g) {
    // ...
    // 计算待完成的任务量
    debtBytes := -gp.gcAssistBytes
    assistWorkPerByte := gcController.assistWorkPerByte.Load()
    scanWork := int64(assistWorkPerByte * float64(debtBytes))
    if scanWork < gcOverAssistWork {
        scanWork = gcOverAssistWork
        debtBytes = int64(assistBytesPerWork * float64(scanWork))
    }


    // 先尝试从全局的可用资产中偷取
    bgScanCredit := atomic.Loadint64(&gcController.bgScanCredit)
    stolen := int64(0)
    if bgScanCredit > 0 {
        if bgScanCredit < scanWork {
            stolen = bgScanCredit
            gp.gcAssistBytes += 1 + int64(assistBytesPerWork*float64(stolen))
        } else {
            stolen = scanWork
            gp.gcAssistBytes += debtBytes
        }
        atomic.Xaddint64(&gcController.bgScanCredit, -stolen)
        scanWork -= stolen
        // 全局资产够用,则无需辅助标记,直接返回
        if scanWork == 0 {         
            return
        }
    }


    // 切换到 g0,开始执行标记任务
    systemstack(func() {
        gcAssistAlloc1(gp, scanWork)        
    })


    // 辅助标记完成
    completed := gp.param != nil
    gp.param = nil
    if completed {
        gcMarkDone()
    }
    // ...
}

6 标记终止

方法文件作用
gcBgMarkWorkerruntime/mgc.go标记协程主方法
gcMarkDoneruntime/mgc.go所有标记任务完成后处理
stopTheWorldWithSemaruntime/proc.go停止所有用户协程
gcMarkTerminationruntime/mgc.go进入标记终止阶段
gcSweepruntime/mgc.go唤醒后台清扫协程
sweeponeruntime/mgcsweep.go每次清扫一个mspan
sweepLocked.sweepruntime/mgcsweep.go完成mspan中的bitmap更新
startTheWorldWithSemaruntime/proc.go将所有用户协程恢复为可运行态

6.1 标记完成

在并发标记阶段的gcBgMarkWorker方法中,当最后一个标记协程也完成任务后,会调用gcMarkDone方法,开始执行并发标记后处理的逻辑.

func gcBgMarkWorker() {
    // ...
    for{
        // ...
        if incnwait == work.nproc && !gcMarkWorkAvailable(nil) {
            // ...
            gcMarkDone()
        }
    }
}

gcMarkDone方法中,会遍历释放所有P的写屏障缓存,查看是否存在因屏障机制遗留的灰色对象,如果有,则会推出gcMarkDone方法,回退到gcBgMarkWorker的主循环中,继续完成标记任务.

倘若写屏障中也没有遗留的灰对象,此时会调用STW停止世界,并步入gcMarkTermination方法进入标记终止阶段.

func gcMarkDone()
top:    
    if !(gcphase == _GCmark && work.nwait == work.nproc && !gcMarkWorkAvailable(nil)) {
        semrelease(&work.markDoneSema)
        return    
    }    
    // ...
    // 切换到 p0
    systemstack(func() {
        gp := getg().m.curg
        casgstatus(gp, _Grunning, _Gwaiting)
        forEachP(func(_p_ *p) {
            // 释放一波写屏障的缓存,可能会新的待标记任务产生            
            wbBufFlush1(_p_)
        })
        casgstatus(gp, _Gwaiting, _Grunning)
    })


    // 倘若有新的标记对象待处理,则调回 top 处,可能会回退到并发标记阶段
    if gcMarkDoneFlushed != 0 {
        // ...
        goto top
    }
    // 正式进入标记完成阶段,会STW
    systemstack(stopTheWorldWithSema)
    // ...
    // 在 STW 状态下,进入标记终止阶段
    gcMarkTermination()
}

6.2 标记终止

gcMarkTermination方法包括几个核心步骤:

  • 设置GC进入标记终止阶段_GCmarktermination
  • 切换至g0,设置GC进入标记关闭阶段_GCoff
  • 切换至g0,调用gcSweep方法,唤醒后台清扫协程,执行标记清扫工作
  • 切换至g0,执行gcControllerCommit方法,设置触发下一轮GC的内存阈值
  • 切换至g0,调用startTheWorldWithSema方法,重启世界
func gcMarkTermination() {


    // 设置GC阶段进入标记终止阶段
    setGCPhase(_GCmarktermination)
    // ...


    systemstack(func() {
       // ...
        // 设置GC阶段进入标记关闭阶段
        setGCPhase(_GCoff)  
        // 开始执行标记清扫动作     
        gcSweep(work.mode)
    })
   // 提交下一轮GC的内存阈值
   systemstack(gcControllerCommit)
   // ...
   systemstack(func() { startTheWorldWithSema(true) })
    // ...
}

6.3 标记清扫

gwSweep方法的核心是调用ready方法,唤醒了因为gopark操作陷入被动阻塞的清扫协程sweep.g.

func gcSweep(mode gcMode) {
    assertWorldStopped()
   // ...


    // 唤醒后台清扫任务
    lock(&sweep.lock)
    if sweep.parked {
        sweep.parked = false
        ready(sweep.g, 0, true)
    }
    unlock(&sweep.lock)
}

那么sweep.g是在何时被创建,又是在何时被park的呢?

我们重新回到runtime包的main函数中,开始向下追溯:

func main() {
    // ...
    gcenable()
    // ...
}
func gcenable() {    
    // ...
    go bgsweep(c)
    <-c
    // ...
}

可以看到,在异步启动的bgsweep方法中,会首先将当前协程gopark挂起,等待被唤醒.

当在标记终止阶段被唤醒后,会进入for循环,每轮完成一个mspan的清扫工作,随后就调用Gosched方法主动让渡P的执行权,采用这种懒清扫的方式逐步推进标记清扫流程.

func bgsweep(c chan int) {
    sweep.g = getg()


    lockInit(&sweep.lock, lockRankSweep)
    lock(&sweep.lock)
    sweep.parked = true
    c <- 1
    // 执行 gopark 操作,等待 GC 并发标记阶段完成后将当前协程唤醒
    goparkunlock(&sweep.lock, waitReasonGCSweepWait, traceEvGoBlock, 1)


    for {
        // 每清扫一个 mspan 后,会发起主动让渡
        for sweepone() != ^uintptr(0) {
            sweep.nbgsweep++
            Gosched()
        }
        // ...
        lock(&sweep.lock)
        if !isSweepDone() {
            // This can happen if a GC runs between
            // gosweepone returning ^0 above
            // and the lock being acquired.
            unlock(&sweep.lock)
            continue
        }
        // 清扫完成,则继续 gopark 被动阻塞
        sweep.parked = true
        goparkunlock(&sweep.lock, waitReasonGCSweepWait, traceEvGoBlock, 1)
    }
}

sweepone方法每次清扫一个协程,清扫逻辑核心位于sweepLocked.sweep方法中,正是将mspan的gcmarkBits赋给allocBits,并创建出一个空白的bitmap作为新的gcmarkBits. 这一实现呼应了本文4.5小节谈到的设定.

func sweepone() uintptr {
    // ...
    sl := sweep.active.begin()
    // ...
    for {
        // 查找到一个待清扫的 mspan
        s := mheap_.nextSpanForSweep()
        // ...
        if s, ok := sl.tryAcquire(s); ok {
            npages = s.npages
            // 对一个 mspan 进行清扫
            if s.sweep(false) {
                // Whole span was freed. Count it toward the
                // page reclaimer credit since these pages can
                // now be used for span allocation.
                mheap_.reclaimCredit.Add(npages)
            } else {
                // Span is still in-use, so this returned no
                // pages to the heap and the span needs to
                // move to the swept in-use list.
                npages = 0
            }
            break
        }
    }
    sweep.active.end(sl)


    // ...
    return npages
}
func (sl *sweepLocked) sweep(preserve bool) bool {
    // ...
    s.allocBits = s.gcmarkBits
    s.gcmarkBits = newMarkBits(s.nelems)
    // ...
}

6.4 设置下轮GC阈值

在gcMarkTermination方法中,还会通过g0调用gcControllerCommit方法,完成下轮触发GC的内存阈值的设定.

func gcMarkTermination() {
   // ... 
   // 提交下一轮GC的内存阈值
   systemstack(gcControllerCommit)
   // ...
}
func gcControllerCommit() {
    assertWorldStoppedOrLockHeld(&mheap_.lock)


    gcController.commit(isSweepDone())
    // ...
}

在gcControllerState.commit方法中,会读取gcControllerState.gcPercent字段值作为触发GC的堆使用内存增长比例,并结合当前堆内存的使用情况,推算出触发下轮GC的内存阈值,设置到gcControllerState.gcPercentHeapGoal字段中.

func (c *gcControllerState) commit(isSweepDone bool) {
    // ...
    gcPercentHeapGoal := ^uint64(0)
    // gcPercent 值,用户可以通过环境变量 GOGC 显式设置. 未设时,默认值为 100.
    if gcPercent := c.gcPercent.Load(); gcPercent >= 0 {
        gcPercentHeapGoal = c.heapMarked + (c.heapMarked+atomic.Load64(&c.lastStackScan)+atomic.Load64(&c.globalsScan))*uint64(gcPercent)/100
    }
    // ...
    c.gcPercentHeapGoal.Store(gcPercentHeapGoal)
    // ...
}

在新一轮尝试触发 GC 的过程中,对于gcTriggerHeap类型的触发事件,会调用gcController.trigger方法,读取到gcControllerState.gcPercentHeapGoal中存储的内存阈值,进行触发条件校验.

func (t gcTrigger) test() bool {
    // ...
    switch t.kind {
        case gcTriggerHeap:
        // ...
            trigger, _ := gcController.trigger()
            return atomic.Load64(&gcController.heapLive) >= trigger
        // ...
    }
    return true
}
func (c *gcControllerState) trigger() (uint64, uint64) {
    goal, minTrigger := c.heapGoalInternal()
    // ...
    var trigger uint64
    runway := c.runway.Load()
    // ...
    trigger = goal - runway
    // ...
    return trigger, goal
}
func (c *gcControllerState) heapGoalInternal() (goal, minTrigger uint64) {    
    goal = c.gcPercentHeapGoal.Load()
    // ...
    return
}

7 系统驻留内存清理

Golang 进程从操作系统主内存(Random-Access Memory,简称 RAM)中申请到堆中进行复用的内存部分称为驻留内存(Resident Set Size,RSS). 显然,RSS 不可能只借不还,应当遵循实际使用情况进行动态扩缩.

Golang 运行时会异步启动一个回收协程,以趋近于 1% CPU 使用率作为目标,持续地对RSS中的空闲内存进行回收.

7.1 回收协程启动

在 runtime包下的main函数中,会异步启动回收协程bgscavenge,源码如下:

func main() {
    // ...
    gcenable()
    // ...
}
func gcenable() {    
    // ...
    go bgscavenge(c)
    <-c
    // ...
}

7.2 执行频率控制

在 bgscavenge 方法中,通过for循环 + sleep的方式,控制回收协程的执行频率在占用CPU 时间片的1%左右. 其中回收RSS的核心逻辑位于scavengerState.run方法.

func bgscavenge(c chan int) {
    scavenger.init()


    c <- 1
    scavenger.park()
    // 如果当前操作系统分配内存>目标内存
    for {
        // 释放内存
        released, workTime := scavenger.run()
        if released == 0 {
            scavenger.park()
            continue
        }
        atomic.Xadduintptr(&mheap_.pages.scav.released, released)
        scavenger.sleep(workTime)
    }
}

7.3 回收空闲内存

scavengerState.run方法中,会开启循环,经历pageAlloc.scavenge -> pageAlloc.scavengeOne 的调用链,最终通过sysUnused方法进行空闲内存页的回收.

func (s *scavengerState) run() (released uintptr, worked float64) {
    // ...
    for worked < minScavWorkTime {
        // ...
        const scavengeQuantum = 64 << 10
        r, duration := s.scavenge(scavengeQuantum)
        // ...
    }
    return
}
func (p *pageAlloc) scavenge(nbytes uintptr, shouldStop func() bool) uintptr {
    released := uintptr(0)
    for released < nbytes {
        ci, pageIdx := p.scav.index.find()
        if ci == 0 {
            break
        }
        systemstack(func() {
            released += p.scavengeOne(ci, pageIdx, nbytes-released)
        })
        if shouldStop != nil && shouldStop() {
            break
        }
    }
    return released
}

在 pageAlloc.scavengeOne 方法中,通过findScavengeCandidate 方法寻找到待回收的页,通过 sysUnused 方法发起系统调用进行内存回收.

func (p *pageAlloc) scavengeOne(ci chunkIdx, searchIdx uint, max uintptr) uintptr {
    // ...
    lock(p.mheapLock)
    if p.summary[len(p.summary)-1][ci].max() >= uint(minPages) {
        // 找到待回收的部分
        base, npages := p.chunkOf(ci).findScavengeCandidate(pallocChunkPages-1, minPages, maxPages)


        // If we found something, scavenge it and return!
        if npages != 0 {
            // Compute the full address for the start of the range.
            addr := chunkBase(ci) + uintptr(base)*pageSize
            // ...
            unlock(p.mheapLock)


            if !p.test {
                // 发起系统调用,回收内存
                sysUnused(unsafe.Pointer(addr), uintptr(npages)*pageSize)


                // 更新状态信息
                nbytes := int64(npages) * pageSize
                gcController.heapReleased.add(nbytes)
                gcController.heapFree.add(-nbytes)


                stats := memstats.heapStats.acquire()
                atomic.Xaddint64(&stats.committed, -nbytes)
                atomic.Xaddint64(&stats.released, nbytes)
                memstats.heapStats.release()
            }


            // 更新基数树信息
            lock(p.mheapLock)
            p.free(addr, uintptr(npages), true)
            p.chunkOf(ci).scavenged.setRange(base, npages)
            unlock(p.mheapLock)


            return uintptr(npages) * pageSize
        }
    }
   // 
}

前文 Golang 内存模型与分配机制中,我们有介绍过,在 Golang 堆中会基于基数树的形式建立空闲页的索引,且基数树每个叶子节点对应了一个 chunk 块大小的内存(512 * 8KB = 4MB).

其中chunk的封装类 pallocData 中有还两个核心字段,一个 pallocBits 中标识了一个页是否被占用了(1 占用、0空闲),同时还有另一个 scavenged bitmap 用于表示一个页是否已经被操作系统回收了(1 已回收、0 未回收). 因此,回收协程的目标就是找到某个页,当其 pallocBits 和 scavenged 中的 bit 都为 0 时,代表其可以回收.

由于回收时,可能需要同时回收多个页. 此时会利用基数树的特性,帮助快速找到连续的空闲可回收的页位置.

type pallocData struct {
    pallocBits
    scavenged pageBits
}