为什么 Go 模块在下游服务抖动恢复后,CPU 占用无法恢复

某团圆节日公司服务到达历史峰值 10w+ QPS,而之前没有预料到营销系统又在峰值期间搞事情,雪上加霜,流量增长到 11w+ QPS,本组服务差点被打挂(汗

所幸命大虽然 CPU idle 一度跌至 30 以下,最终还是幸存下来,没有背上过节大锅。与我们的服务代码写的好不无关系(拍飞

事后回顾现场,发现服务恢复之后整体的 CPU idle 和正常情况下比多消耗了几个百分点,感觉十分惊诧。恰好又祸不单行,工作日午后碰到下游系统抖动,虽然短时间恢复,我们的系统相比恢复前还是多消耗了两个百分点。如下图:

shake

确实不太符合直觉,cpu 的使用率上会发现 GC 的各个函数都比平常用的 cpu 多了那么一点点,那我们只能看看 inuse 是不是有什么变化了,一看倒是吓了一跳:

flame

这个 mstart -> systemstack -> newproc -> malg 显然是 go func 的时候的函数调用链,按道理来说,创建 goroutine 结构体时,如果可用的 g 和 sudog 结构体能够复用,会优先进行复用:

func gfput(_p_ *p, gp *g) {
 if readgstatus(gp) != _Gdead {
  throw("gfput: bad status (not Gdead)")
 }

 stksize := gp.stack.hi - gp.stack.lo

 if stksize != _FixedStack {
  // non-standard stack size - free it.
  stackfree(gp.stack)
  gp.stack.lo = 0
  gp.stack.hi = 0
  gp.stackguard0 = 0
 }

 _p_.gFree.push(gp)
 _p_.gFree.n++
 if _p_.gFree.n >= 64 {
  lock(&sched.gFree.lock)
  for _p_.gFree.n >= 32 {
   _p_.gFree.n--
   gp = _p_.gFree.pop()
   if gp.stack.lo == 0 {
    sched.gFree.noStack.push(gp)
   } else {
    sched.gFree.stack.push(gp)
   }
   sched.gFree.n++
  }
  unlock(&sched.gFree.lock)
 }
}

func gfget(_p_ *p) *g {
retry:
 if _p_.gFree.empty() && (!sched.gFree.stack.empty() || !sched.gFree.noStack.empty()) {
  lock(&sched.gFree.lock)
  for _p_.gFree.n < 32 {
   // Prefer Gs with stacks.
   gp := sched.gFree.stack.pop()
   if gp == nil {
    gp = sched.gFree.noStack.pop()
    if gp == nil {
     break
    }
   }
   sched.gFree.n--
   _p_.gFree.push(gp)
   _p_.gFree.n++
  }
  unlock(&sched.gFree.lock)
  goto retry
 }
 gp := _p_.gFree.pop()
 if gp == nil {
  return nil
 }
 _p_.gFree.n--
 if gp.stack.lo == 0 {
  systemstack(func() {
   gp.stack = stackalloc(_FixedStack)
  })
  gp.stackguard0 = gp.stack.lo + _StackGuard
 } else {
        // ....
 }
 return gp
}

怎么会出来这么多 malg 呢?再来看看创建 g 的代码:

func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) {
 _g_ := getg()
    
    // .... 省略无关代码

 _p_ := _g_.m.p.ptr()
 newg := gfget(_p_)
 if newg == nil {
  newg = malg(_StackMin)
  casgstatus(newg, _Gidle, _Gdead)
  allgadd(newg) // 重点在这里
 }
}

一旦在 当前 p 的 gFree 和全局的 gFree 找不到可用的 g,就会创建一个新的 g 结构体,该 g 结构体会被 append 到全局的 allgs 数组中:

var (
 allgs    []*g
 allglock mutex
)

这个 allgs 在什么地方会用到呢:

GC 的时候:

func gcResetMarkState() {
 lock(&allglock)
 for _, gp := range allgs {
  gp.gcscandone = false  // set to true in gcphasework
  gp.gcscanvalid = false // stack has not been scanned
  gp.gcAssistBytes = 0
 }
}

检查死锁的时候:

func checkdead() {
    // ....
 grunning := 0
 lock(&allglock)
 for i := 0; i < len(allgs); i++ {
  gp := allgs[i]
  if isSystemGoroutine(gp, false) {
   continue
  }
    }
}

检查死锁这个操作在每次 sysmon、线程创建、线程进 idle 队列的时候都会调用,调用频率也不能说特别低。

翻阅了所有 allgs 的引用代码,发现该数组创建之后,并不会收缩。

我们可以根据上面看到的所有代码,来还原这种抖动情况下整个系统的情况了:

  1. 下游系统超时,很多 g 都被阻塞了,挂在 gopark 上,相当于提高了系统的并发

  2. 因为 gFree 没法复用,导致创建了比平时更多的 goroutine(具体有多少,就看你超时设置了多少

  3. 抖动时创建的 goroutine 会进入全局 allgs 数组,该数组不会进行收缩,且每次 gc、sysmon、死锁检查期间都会进行全局扫描

  4. 上述全局扫描导致我们的系统在下游系统抖动恢复之后,依然要去扫描这些抖动时创建的 g 对象,使 cpu 占用升高,idle 降低。

  5. 只能重启(重启大法好

看起来并没有什么解决办法,如果想要复现这个问题的读者,可以试一下下面这个程序:

package main

import (
 "log"
 "net/http"
 _ "net/http/pprof"
 "time"
)

func sayhello(wr http.ResponseWriter, r *http.Request) {}

func main() {
 for i := 0; i < 1000000; i++ {
  go func() {
   time.Sleep(time.Second * 10)
  }()
 }
 http.HandleFunc("/", sayhello)
 err := http.ListenAndServe(":9090", nil)
 if err != nil {
  log.Fatal("ListenAndServe:", err)
 }
}

启动后等待 10s,待所有 goroutine 都散过后,pprof 的 inuse 的 malg 依然有百万之巨。

欢迎关注 TechPaper 和码农桃花源:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值