Go GC优化方法

Golang自1.5版本开始引入三色GC,多次改进,GC停顿时间降低到1ms。

1. GC的触发

GC的触发条件有以下几种:

  • gcTriggerAlways:强制触发GC
  • gcTriggerHeap:当前分配的内存达到一定值就触发GC
  • gcTriggerTime:当一定时间没有执行过GC触发
  • gcTriggerCycle:要求启动新一轮的GC,已启动则跳过,手动触发GC的runtime.GC()会使用这个条件
    其中gcTriggerHeap和gcTriggerTime这两个条件是自然触发的。
    gcTriggerHeap的判断依据为:
memstats.heap_live >= memstats.gc_trigger //heap_live is the number of bytes considered live by the GC
其中memstats.gc_trigger的计算公式是:
trigger = unit64(float64(memstats.heap_marked)*(1+triggerRatio))//heap_marked is the number of bytes marked by the previous GC
tiggerRatio 与goalGrowthRatio正相关
goalGrowthRatio = float64(gcpercent)/100

公式中的goalGrowthRatio“目标Heap增长率”通过设置环境变量GOGC(gcpercent)调整,默认值为100。
gcTriggerTime的判断依据为:

t.now - lastgc > forcegcperiod

其中forcegcperiod的定义是2分钟,也就是2分钟没有执行GC就会强制触发。

2. 如何进行GC优化

2.1:硬性参数GOGC
比如当前程序使用4M堆内存,即memstats.heap_marked内存为4M,当程序占用的内存上升到memstats.heap_marked*(1+GOGC/100)=8M时候,gc就会被触发,开始进行相关的gc操作。
如何对GOGC的参数进行设置,要根据生产情况中的实际场景来定。增加它的值可以减少GC触发但需保证内存足够大。如果你的内存少,只能更频繁的GC以节省内存,那么降低GOGC值。设置GOGC=off可以彻底关掉GC
2.2:减少对象分配
内存复用,减少对象申请。下面的样例代码中实现内存复用,其关键是一个缓存的管道buffer,可存储5个字节数组,当程序需要一个字节数组时候,优先使用select从缓存的管道中去取。最终内存池中的内存和从操作系统请求的内存很接近,堆中只有很少量的未使用内存最终返还给操作系统。

package main
import (
    "fmt"
    "math/rand"
    "runtime"
    "time"
)
func makeBuffer() []byte {
    return make([]byte, rand.Intn(5000000)+5000000)
}
func main() {
    pool := make([][]byte, 20)
    buffer := make(chan []byte, 5)
    var m runtime.MemStats
    makes := 0
    for {
        var b []byte
        select {
        case b = <-buffer:
        default:
            makes += 1
            b = makeBuffer()
        }

        i := rand.Intn(len(pool))
        if pool[i] != nil {
            select {
            case buffer <- pool[i]:
                pool[i] = nil
            default:
            }
        }
        pool[i] = b
        time.Sleep(time.Second)
        bytes := 0
        for i := 0; i < len(pool); i++ {
            if pool[i] != nil {
                bytes += len(pool[i])
            }
        }

        runtime.ReadMemStats(&m)
        fmt.Printf("%d,%d,%d,%d,%d,%d\n", m.HeapSys, bytes, m.HeapAlloc,
            m.HeapIdle, m.HeapReleased, makes)
    }
}

2.3:string和[]byte转换
在string和[]byte之间进行转换,会给gc造成压力。原则上二者可直接相互转换。

package main
import "fmt"
func main() {
    str2 := "hello"
    data2 := []byte(str2)
    fmt.Println(data2)

    str2 = string(data2[:])
    fmt.Println(str2)
}
输出
[104 101 108 108 111]
hello

二者的数据结构

type = struct []uint8 {uint8 *array; int len; int cap;}
type = struct string {uint8 *str; int len;}

两者发生转换的时候,底层数据结构会进行复制,导致gc效率降低。解决策略上,一种方式是一直使用[]byte,数据传输方面,[]byte中包含许多string会常用到的有效操作。另一种是使用更为底层的操作直接转换,避免复制行为,使用unsafe.Poniter直接进行转化。
由二者的数据结构来看,string可以看作[2]unitptr{ptr,len},[]byte则是[3]unitptr。则上述的代码可以更改为如下内容:

package main

import (
	"fmt"
	"unsafe"
)

func str2bytes(s string) []byte {
	x := (*[2]uintptr)(unsafe.Pointer(&s))
	h := [3]uintptr{x[0],x[1],x[1]}
	return *(*[]byte)(unsafe.Pointer(&h))
}

func bytes2str(b []byte) string {
	return *(*string)(unsafe.Pointer(&b))
}

func main() {
	s := "hello"
	b := str2bytes(s)
	s2 := bytes2str(b)
	fmt.Println(b, s2)
}

2.4:少量使用+连接string
由于采用+来进行string的连接会生成新的对象,降低gc的效率。对于连续添加字符,一个有效的方式是准备好一个字符切片[]string,使用append函数进行,最后使用string.Join()函数一次性将所有字符串联起来。
但使用append函数有一个弊端,如下,make初始化b,在使用了append操作之后,数组的空间由1024增长到1280,同样增加gc压力:

b := make([]int, 1024)
fmt.Println("len:", len(b), "cap:", cap(b))
b = append(b, 99)
fmt.Println("len:", len(b), "cap:", cap(b))
输出:
len: 1024 cap: 1024
len: 1025 cap: 1280

append使用,slice容量扩展规律:

  • 如果当前slice容量足够容纳新增元素,则不会扩容;
  • 如果新增元素后容量不足,则会扩容为原来容量的2倍大小(或是原容量的n倍,按照实际上新增元素的个数决定),然后将底层数组原来的元素copy到扩容后的新slice上,添加新的元素后将slice的引用地址指向新的slice;
  • 如果原slice容量已经达到1024,则最终扩容后的容量等于每次扩容前的1/4,直到满足最终容量大小等于新申请的容量。如上面的1024,其1/4是256,扩容后容量为1024+256=1280。

介绍完使用场景,下面具体介绍一下gc的处理流程及gc处理的对象具体的分配过程

3. GC对象处理过程

GC是并行gc,也就是GC的大部分处理和普通的go代码是同时运行的。GC各个版本的垃圾回收机制:

  • v1.1 STW
  • v1.3 Mark STW,Sweep并行
  • v1.5 三色标记法
  • v1.8 hybrid write barrier

首先GC有4个阶段,分别是:

  • Sweep Termination:对未清理的span进行清扫,只有上一轮的GC的清扫工作完成才可以开始新一轮的GC
  • Mark:扫描所有根对象,和根对象可以到达的所有对象,标记它们不可回收
  • Mark Termination:完成标记工作,重新扫描部分根对象
  • Sweep:按标记结果清扫span
    整个GC过程中会有两种后台任务(G),一种是标记用的后台任务,一种是清扫用的后台任务。
    标记用的后台任务会在需要时启动,可同时工作的后台任务数量大约是P数量的25%,也就是go所讲的让25%的cpu用在GC上的根据。
    清扫用的后台任务在程序启动时会启动一个,进入清扫阶段时唤醒。

STW
整个GC的过程中会进行两次STW(stop the world),第一次是Mark阶段的开始(快速找出所有需要扫描的根对象后结束STW,开始进行三色标记),第二次是Mark Termination阶段。
第一次STW会准备根对象的扫描,启动写屏障(Write Barrier)和辅助GC。
第二次STW会重新扫描部分根对象,禁用写屏障(Write Barrier)和辅助GC。
不是所有的根对象的扫描都需要STW,例如扫描栈上的对象只需要停止用于该栈的G。go 1.9开始,写屏障使用Hybrid Write Barrier,大幅减少第二次STW的时间。

三色标记
其中标记使用三色定义,三色定义可简单理解为:

  • 黑色:对象在这次GC中已标记,且这个对象包含的子对象也已标记。
  • 灰色:对象在这次GC中已标记,但这个对象包含的子对象未标记
  • 白色:对象在这次GC中未标记
    在go内部并没有保存颜色的属性,三色只是状态的描述:
  • 白色对象在它所在的span的gcmarkBits中对应的bit为0
  • 灰色对象在它所在的span的gcmarkBits中对应的bit为1,并且对象在标记队列中
  • 黑色对象在它所在的span的gcmarkBits中对应的bit为1,并且对象已经从标记队列中取出并处理

写屏障
因为go支持并行GC,GC的扫描和go代码可以同时运行,这样带来的问题是GC扫描的过程go代码有可能改变对象的依赖树。例如开始扫描时发现根对象A和B,B拥有C的指针,GC先扫描A,然后B把C的指针交给A,GC再扫描B,这时C就不会被扫描到。为避免这个问题,go在GC的标记阶段会启动写屏障,当B把C的指针交给A时,GC会认为这一轮的扫描中C的指针是存活的,即使A可能会在稍后丢掉C,那么C就在下一轮回收。
写屏障只针对指针启用,而且只有在GC的标记阶段启用。混合写屏障会同时标记指针写入目标的“原指针”和“新指针”。标记原指针的原因是,其他运行中的线程有可能会同时把这个指针的值复制到寄存器或栈的本地变量,复制指针到寄存器或者栈上的本地变量不会经过写屏障,有可能导致指针不被标记。混合写屏障可以让GC在并行标记结束后不需要重新扫描各个G的堆栈,可以减少Mark Termination中STW时间。

辅助GC
为防止heap增速太快,在GC执行的过程中如果同时运行G的分配了内存,那么这个G会被要求辅助GC做一部分的工作。

4. go内存分配机制

go在程序启动时,会分配一块虚拟内存是连续的内存,结构如下:
在这里插入图片描述
内存分3个区域,在X64上大小分别是512M,16G和512G。
arena:
通常所说的heap分配区域
bitmap:
用于标明arena区域中哪些地址保存了对象,并且对象中哪些地址包含了指针。其中一个比特byte对应arena区域的四个指针大小的内存结构,每个指针大小的内存都会又两个bit分别表示是否需要继续扫描(should scan)和是否包含指针(is pointer)。
bitmap中byte和arena的对应关系从末尾开始,也就是随着内存分配会向两边扩展。
在这里插入图片描述
spans
spans区域用于表示arena区中某一个页(page,8k)属于哪个spans。spans用于分配对象的区块,其一个指针(8 byte)对应arena区域中的一页。spans本身一页8k~32k,按spans中元素大小划分为66个标准span,以及一个class为0的只包含一个大于32k的大对象。
分配对象时会从不同位置获取适合的span用于分配:

  • 首先从P的缓存mcache中获取,如果有缓存的span并且未满则使用,此步不需要锁
  • 从全局缓存mcentral获取,全局缓存有可用的span的列表,如果获取成功则设置到P,需要锁。mcache不够用时候,向mcentral申请。
  • 从mheap获取,mheap中也有span的自由列表,获取后设置到全局缓存,此步需要锁。mcentral不够用时,通过mheap向操作系统申请
    对象分配内存的主要流程:
  • object size > 32K,则使用mheap直接分配
  • object size < 16K,直接使用mcache直接分配
  • object size > 16K && object size < 32K,先使用mcache中对应的size class分配,如果mcache对应的size class的span已经没有可用的块,则向mcentral请求。如果mcentral也没有可用的块,则向mheap申请,并切分。如果mheap也没有合适的span,则向操作系统申请。

go程序执行,P为系统线程运行协程所需虚拟资源,同一时间只能有一个线程访问同一个P,所以P中的数据不需要锁,为分配对象时有更好的性能,各个P中都有span的缓存(mcache)。各个P中按 span类型的不同,有67*2个span的缓存。
其中2表示scan和noscan。

  • scan:如果对象包含指针,分配对象时会使用scan的span;
  • noscan:如果对象不包含指针,分配对象时会使用noscan的span。
    把span分为scan和noscan的意义,GC扫描对象的时候对于noscan的span可以不会查看bitmap区域来标记子对象,可大幅度提升标记效率。

应用侧面new和make分配内存
new:返回一个指针,指向新分配的类型T的零值。使用者用new创建的一个数据结构的实体并可以直接工作。如bytes.Buffer的文档所述,Buffer的零值是一个准备好了的空缓存
make:内建函数make(T,args)只能创建slice,map和channel,并返回一个有初始值(非零)的T类型,而不是*T。导致这三个类型有所不同的原因是指向数据结构的引用在使用前必须初始化。如,一个slice,是一个包含数据(内部array)的指针,长度和容量的三项描述,在这些项目初始化之前,slice为nil。对于slice,map和channel,make初始化了内部的数据结构,填充适当的值。

我们探一探 Go 程序的启动流程,其中涉及到 Go Runtime 的调度器启动。 1. Go 引导阶段入口方法在 rt0_linux_amd64.s 文件中,可发现 _rt0_amd64_darwin JM ...