乐趣区

关于go:34-GolangGC调度与调优

  对于垃圾回收的基本知识曾经介绍的差不多了,只是要晓得垃圾回收过程是须要消耗 CPU 工夫的,那就有可能会影响到用户协程的调度,所以在某些场景须要垃圾回收相干调优。本篇文章次要介绍垃圾回收的触发机会,以及垃圾回收器的几种调度模式,只有理解这些能力晓得如何调优;最初联合罕用的缓存框架 bigcache,剖析如何缩小垃圾回收的压力。

触发机会

  什么时候触发垃圾回收呢?首先内存应用增长肯定比例时有可能会触发(总不能任由内存增长吧),还有其余形式吗?咱们也能够通过 runtime.GC 函数手动触发(会阻塞用户协程,直到垃圾回收流程完结),另外,Go 辅助线程也会检测,如果超过 2 分钟没有执行垃圾回收,则强制启动垃圾回收。三种触发形式定义如下:

// gcTriggerHeap indicates that a cycle should be started when
// the heap size reaches the trigger heap size computed by the
// controller.
gcTriggerHeap gcTriggerKind = iota   // 内存增长到触发门限

// gcTriggerTime indicates that a cycle should be started when
// it's been more than forcegcperiod nanoseconds since the
// previous GC cycle.
gcTriggerTime                        // 定时触发

// gcTriggerCycle indicates that a cycle should be started if
// we have not yet started cycle number gcTrigger.n (relative
// to work.cycles).
gcTriggerCycle                      // 可用于强制触发


// 蕴含触发类型,以后工夫,周期数
type gcTrigger struct {
    kind gcTriggerKind
    now  int64  // gcTriggerTime: current time
    n    uint32 // gcTriggerCycle: cycle number to start
}

  还记得上一篇文章介绍垃圾回收入口函数是 gcstart,该函数的输出参数就是 gcTrigger,每一次开启垃圾回收之前,都会检测是否应该触发垃圾回收,检测形式如下:

func (t gcTrigger) test() bool {
    switch t.kind {
    case gcTriggerHeap:
        // 内存达到门限
        return gcController.heapLive >= gcController.trigger
    case gcTriggerTime:
        // 可敞开 GC:Initialized from GOGC. GOGC=off means no GC.
        if gcController.gcPercent.Load() < 0 {return false}

        //forcegcperiod 定义为 2 分钟
        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
}

  总给下来,垃圾回收总共有三种触发形式:申请内存,定时触发,被动触发。被动触发与定时触发的逻辑比较简单,这里就不做过多介绍了,咱们重点理解申请内存触发的垃圾回收。

  申请内存如何能触发垃圾回收呢?想想申请内存的入口函数是不是 mallocgc,所以只须要在这里判断就能够了:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // 只有当一次申请内存过大(超过 32768)才会检测是否开启 GC
    if size <= maxSmallSize { } else {shouldhelpgc = true}

    if shouldhelpgc {if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {gcStart(t)
        }
    }
}

  不过,你有没有想过,如何统计以后调配的内存字节数呢?以及如何计算下一次垃圾回收触发的内存门限呢?分配内存字节数实践上也应该在 mallocgc 函数更新吧,不过 Go 语言并没有这么做,而是从 mcentral 获取 mspan 缓存到 mcache 时候更新的(也就是说缓存到 mcache 就算 ” 申请 ” 了),所以这个数据其实并不是真正用户代码已调配的内存数(略大)。

func (c *mcache) refill(spc spanClass) {
    // 该 mspan 已应用的内存
    usedBytes := uintptr(s.allocCount) * s.elemsize
    // 更新全局统计变量
    gcController.update(int64(s.npages*pageSize)-int64(usedBytes), int64(c.scanAlloc))
}

func (c *gcControllerState) update(dHeapLive, dHeapScan int64) {
    if dHeapLive != 0 {atomic.Xadd64(&gcController.heapLive, dHeapLive)
    }
}

  另一个问题呢,如何计算下一次垃圾回收触发的内存门限呢?当然在每一次垃圾回收完结之后,须要更新下一次垃圾回收触发的内存门限,该门限与上一次垃圾回收的标记内存数以及触发比例无关,触发比例是多少呢?与环境变量 GOGC 无关。触发门限计算过程如下:

func (c *gcControllerState) commit(triggerRatio float64) {
    //gcPercent 数据来源于环境变量 GOGC

    // goal 下一次垃圾回收触发指标
    goal := ^uint64(0)
    if gcPercent := c.gcPercent.Load(); gcPercent >= 0 {goal = c.heapMarked + (c.heapMarked+atomic.Load64(&c.stackScan)+atomic.Load64(&c.globalsScan))*uint64(gcPercent)/100
    }

    //trigger 由 goal 计算得来
    // 因为垃圾回收过程中,用户协程还在并发申请内存,所以最终触发门限 trigger 并不等于 goal

    c.trigger = trigger
}

  留神因为垃圾回收过程中,用户协程还在并发申请内存,所以最终触发门限 trigger 并不等于 goal,然而不可否认 trigger 是由 goal 计算得来的,还波及到调步算法,这里就不开展了。

  垃圾回收一方面能够回收无用内存,防止 Go 程序占用过多内存,然而垃圾回收过程也须要占用 CPU 工夫,就可能会对用户协程的调度有肯定影响,所以在某些场景可能须要垃圾回收相干调优,个别也就是调整环境变量 GOGC,均衡 Go 程序内存应用与垃圾回收对 CPU 的占用。

垃圾回收调度模式

  圾回收过程也须要占用 CPU 工夫,上一篇文章咱们看到垃圾回收工作协程 gcBgMarkWorker 数目与逻辑处理器 P 的数目保持一致,这些协程同时被调度吗?调度之后是始终运行等到垃圾回收过程完结吗?Go 语言是如何保障垃圾回收过程占用肯定比例的 CPU 呢,不至于太影响用户协程,也不至于太慢呢?Go 语言定义了三种垃圾回收工作协程调度模式:

// gcMarkWorkerDedicatedMode indicates that the P of a mark
// worker is dedicated to running that mark worker. The mark
// worker should run without preemption.
gcMarkWorkerDedicatedMode      // 以后 P 只能运行垃圾回收工作协程,且不能被抢占

// gcMarkWorkerFractionalMode indicates that a P is currently
// running the "fractional" mark worker. The fractional worker
// is necessary when GOMAXPROCS*gcBackgroundUtilization is not
// an integer and using only dedicated workers would result in
// utilization too far from the target of gcBackgroundUtilization.
// The fractional worker should run until it is preempted and
// will be scheduled to pick up the fractional part of
// GOMAXPROCS*gcBackgroundUtilization.
gcMarkWorkerFractionalMode     // 以后 P 运行垃圾回收工作协程,须要保障总得 CPU 占用工夫

// gcMarkWorkerIdleMode indicates that a P is running the mark
// worker because it has nothing else to do. The idle worker
// should run until it is preempted and account its time
// against gcController.idleMarkTime.
gcMarkWorkerIdleMode          // 如果以后 P 没有用户协程可调度,才调度垃圾回收工作协程 

  首先要明确,Go 语言保障垃圾回收工作协程 CPU 利用率为 25% 左右,如何保障呢?假如 Go 程序创立了 8 个逻辑处理器 P,25% 就相当于在两个逻辑处理器 P 调度垃圾回收主协程(以后 P 只运行垃圾回收工作协程),那如果逻辑处理器 P 的数目不能被 4 整除怎么办?这时候必定会有局部 P 调度模式采纳 gcMarkWorkerFractionalMode,计算形式如下:

func (c *gcControllerState) startCycle(markStartTime int64, procs int) {

    // gcBackgroundUtilization 是常量 0.25,procs 即逻辑处理器 P 的数目
    totalUtilizationGoal := float64(procs) * gcBackgroundUtilization
    // 向上取整,这些 P 只能运行垃圾回收工作协程
    c.dedicatedMarkWorkersNeeded = int64(totalUtilizationGoal + 0.5)
    // 非整数时,计算误差
    utilError := float64(c.dedicatedMarkWorkersNeeded)/totalUtilizationGoal - 1
    // 误差比拟大,须要修改
    if utilError < -maxUtilError || utilError > maxUtilError {

        // 太多 P 处于 gcMarkWorkerDedicatedMode 模式,减 1
        if float64(c.dedicatedMarkWorkersNeeded) > totalUtilizationGoal {c.dedicatedMarkWorkersNeeded--}

        // 须要有局部 P 处于 gcMarkWorkerFractionalMode 模式,这些 P 占用 CPU 工夫比例为 fractionalUtilizationGoal
        c.fractionalUtilizationGoal = (totalUtilizationGoal - float64(c.dedicatedMarkWorkersNeeded)) / float64(procs)
    } else {
        // 误差较小,不须要修改
        c.fractionalUtilizationGoal = 0
    }
}

  垃圾回收启动的时候,会计算好各调度模式下逻辑处理器 P 的数目,Go 语言调度器在调度垃圾回收工作协程时,设置各个工作协程的调度模式,参考函数 findRunnableGCWorker 的实现:

func (c *gcControllerState) findRunnableGCWorker(_p_ *p) *g {
    // 函数 decIfPositive 判断输出参数是否是负数,并减 1

    // 调度模式为 gcMarkWorkerDedicatedMode
    if decIfPositive(&c.dedicatedMarkWorkersNeeded) {_p_.gcMarkWorkerMode = gcMarkWorkerDedicatedMode}else if c.fractionalUtilizationGoal == 0 {......} else {

        //delta 垃圾回收标记开始到当初时间段
        delta := nanotime() - c.markStartTime

        // 计算 gcMarkWorkerFractionalMode 调度模式下垃圾回收占用的 CPU 比例,如果超过间接返回
        if delta > 0 && float64(_p_.gcFractionalMarkTime)/float64(delta) > c.fractionalUtilizationGoal {......}
        // 运行在 gcMarkWorkerFractionalMode 调度模式
        _p_.gcMarkWorkerMode = gcMarkWorkerFractionalMode
    }


}

  怎么没有 gcMarkWorkerIdleMode 呢?想想这种模式的含意是什么:如果以后 P 没有用户协程可调度,才调度垃圾回收工作协程。所以应该是 Go 语言调度器在获取可运行用户协程时,发现没有可运行协程而此时正处于垃圾回收过程,则调度垃圾回收工作协程(参考调度器查找协程函数 findrunnable)。

  调度模式曾经确定了,垃圾回收工作协程 gcBgMarkWorker 在运行时,会依据调度模式,决定如何执行标记扫描过程:

func gcBgMarkWorker() {
    for {gopark(...)

        startTime := nanotime()

        systemstack(func() {
            switch pp.gcMarkWorkerMode {
            default:
                throw("gcBgMarkWorker: unexpected gcMarkWorkerMode")

            // 只运行垃圾回收协程,第一次执行标记扫描过程直到被抢占
            case gcMarkWorkerDedicatedMode:
                gcDrain(&pp.gcw, gcDrainUntilPreempt|gcDrainFlushBgCredit)
                // 如果被抢占,将以后 P 队列的协程增加到全局协程队列
                if gp.preempt {if drainQ, n := runqdrain(pp); n > 0 {lock(&sched.lock)
                        globrunqputbatch(&drainQ, int32(n))
                        unlock(&sched.lock)
                    }
                }
                
                // 执行标记扫描,直到工作完结
                gcDrain(&pp.gcw, gcDrainFlushBgCredit)
            // 依照肯定 CPU 工夫比例执行标记扫描,或者直到被抢占
            case gcMarkWorkerFractionalMode:
                gcDrain(&pp.gcw, gcDrainFractional|gcDrainUntilPreempt|gcDrainFlushBgCredit)
            // 只在闲暇时执行标记扫描
            case gcMarkWorkerIdleMode:
                gcDrain(&pp.gcw, gcDrainIdle|gcDrainUntilPreempt|gcDrainFlushBgCredit)
            }
        })

        duration := nanotime() - startTime

        // 统计 gcMarkWorkerFractionalMode 模式下,标记扫描过程的运行工夫
        if pp.gcMarkWorkerMode == gcMarkWorkerFractionalMode {atomic.Xaddint64(&pp.gcFractionalMarkTime, duration)
        }

        ......
    }
}


gcDrainUntilPreempt gcDrainFlags = 1 << iota    // 运行 gcDrain 直到被抢占
gcDrainFlushBgCredit                            // gcDrain 更新全局现金池(回顾辅助标记)gcDrainIdle                                     // gcDrain 只在闲暇时运行
gcDrainFractional                                // gcDrain 只能占用肯定 CPU 比例 

  gcDrain 函数次要也是一个循环,循环获取灰色节点并执行标记扫描流程,如何实现这几种调度模式呢?也就是何时完结循环呢?只须要在每次循环开始判断一下就行了,比方在循环条件中判断当然是否被抢占,占用 CPU 工夫是否超过肯定比例,以后 P 是否有用户协程在期待调度等等

func gcDrain(gcw *gcWork, flags gcDrainFlags) {

    // 是否可被抢占
    preemptible := flags&gcDrainUntilPreempt != 0

    // 循环完结检测办法
    if flags&(gcDrainIdle|gcDrainFractional) != 0 {
        if idle {check = pollWork     // 检测是否有用户协程期待调度} else if flags&gcDrainFractional != 0 {check = pollFractionalWorkerExit   // 检测 CPU 工夫占用比例}
    }

    // 如果容许抢占且被抢占,完结
    for !(gp.preempt && (preemptible || atomic.Load(&sched.gcwaiting) != 0)) {
        // 标记扫描

        // 校验是否完结循环
        if check != nil && check() {break}

    }

    ......
}

bigcache 概述

  垃圾回收如何调优呢?一方面能够针对业务类型调整环境变量 GOGC,均衡 Go 程序内存应用与垃圾回收对 CPU 的占用;另一方面能够尽量减少用户代码分配内存的数量,比方应用对象池(复用)。

  还有其余计划吗?回忆一下标记扫描整个过程:1)从灰色对象汇合中抉择一个对象,标为彩色;2)扫描该对象指向的所有对象,将其退出到灰色对象汇合;3)一直反复步骤 1 /2。实质上就是只有对象蕴含指针,就须要持续扫描,所以 Go 语言才会将每种 mspan 分为两种规格,有指针与无指针,而不蕴含指针的 mspan 是不须要持续扫描的。

  通常为了晋升服务性能,都会应用本地缓存,那用户代码就必然会大量分配内存,垃圾回收的压力也会十分大,这时候该如何解决呢?bigcache 是罕用的本地内存缓存组件,就是通过去除指针来缩小垃圾回收扫描的压力。

  缓存组件还能去除指针?想想个别缓存数据存储怎么设计呢:通常有一个 map,存储缓存 key-value 对象,个别还会基于 LRU 实现缓存淘汰算法。bigcache 也是是用的 map 存储缓存对象,那怎么说去除了指针呢?因为缓存的 key 和 value 都是整数!key 按 hash 值存储,那字符串 key 存储在哪呢?value 又是怎么依照整数存储呢?其实 value 存储的也是地位索引,真正的数据 entry 存储在字节数组,并不像传统 map,entry 是一个个独立的对象 / 节点。

type cacheShard struct {
    // map 定义,key-value 都是整数
    hashmap     map[uint64]uint32
    // 真正存储数据 entry,是一个字节数组
    entries     queue.BytesQueue
    // 读写须要加锁
    lock        sync.RWMutex
}

  看到了吧 map 的定义是不蕴含指针的,而且数据 key-value 是编码为二进制存储在 entries 字节数组的,整个也不蕴含指针。上面咱们简略看看 bigcache 的数据查找逻辑:

func (s *cacheShard) get(key string, hashedKey uint64) ([]byte, error) {s.lock.RLock()

    // 或者 entry 的字节编码
    wrappedEntry, err := s.getWrappedEntry(hashedKey)

    // readKeyFromEntry 函数将字节数组解码为
    if entryKey := readKeyFromEntry(wrappedEntry); key != entryKey {// 哈希抵触,返回谬误}

    // 解码
    entry := readEntry(wrappedEntry)
    s.lock.RUnlock()

    return entry, nil
}

func (s *cacheShard) getWrappedEntry(hashedKey uint64) ([]byte, error) {itemIndex := s.hashmap[hashedKey]

    // itemIndex 就是字节数组索引
    wrappedEntry, err := s.entries.Get(int(itemIndex))
    return wrappedEntry, err
}

  bigcache 的数据存储不蕴含指针,通过这种形式来缩小垃圾回收扫描的压力,不过因为 entries 是一般的字节数组,所以也就无奈实现灵便的缓存淘汰策略了。

总结

  本篇文章次要介绍垃圾回收的触发机会,以及垃圾回收器的几种调度模式,你须要理解垃圾回收占用 CPU 资源,可能会影响用户协程的调度执行,所以某些业务场景须要垃圾回收调优。最初联合罕用的缓存框架 bigcache,学习如何缩小垃圾回收的压力(无指针)。

退出移动版