Go 模塊在下遊服務抖動恢復後CPU占用無法恢復原因

引言

某團圓節日公司服務到達歷史峰值 10w+ QPS,而之前沒有預料到營銷系統又在峰值期間搞事情,雪上加霜,流量增長到 11w+ QPS,本組服務差點被打掛(汗

所幸命大雖然 CPU idle 一度跌至 30 以下,最終還是幸存下來,沒有背上過節大鍋。與我們的服務代碼寫的好不無關系(拍飛

事後回顧現場,發現服務恢復之後整體的 CPU idle 和正常情況下比多消耗瞭幾個百分點,感覺十分驚詫。恰好又禍不單行,工作日午後碰到下遊系統抖動,雖然短時間恢復,我們的系統相比恢復前還是多消耗瞭兩個百分點。如下圖:

確實不太符合直覺,cpu 的使用率上會發現 GC 的各個函數都比平常用的 cpu 多瞭那麼一點點,那我們隻能看看 inuse 是不是有什麼變化瞭,一看倒是嚇瞭一跳:

這個 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
}

創建 g

怎麼會出來這麼多 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(&amp;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(&amp;allglock)
	for i := 0; i &lt; len(allgs); i++ {
		gp := allgs[i]
		if isSystemGoroutine(gp, false) {
			continue
		}
    }
}

檢查死鎖這個操作在每次 sysmon、創建 templateThread、線程進 idle 隊列的時候都會調用,調用頻率也不能說特別低。

翻閱瞭所有 allgs 的引用代碼,發現該數組創建之後,並不會收縮。

我們可以根據上面看到的所有代碼,來還原這種抖動情況下整個系統的情況瞭:

  • 下遊系統超時,很多 g 都被阻塞瞭,掛在 gopark 上,相當於提高瞭系統的並發
  • 因為 gFree 沒法復用,導致創建瞭比平時更多的 goroutine(具體有多少,就看你超時設置瞭多少
  • 抖動時創建的 goroutine 會進入全局 allgs 數組,該數組不會進行收縮,且每次 gc、sysmon、死鎖檢查期間都會進行全局掃描
  • 上述全局掃描導致我們的系統在下遊系統抖動恢復之後,依然要去掃描這些抖動時創建的 g 對象,使 cpu 占用升高,idle 降低。
  • 隻能重啟

看起來並沒有什麼解決辦法,如果想要復現這個問題的讀者,可以試一下下面這個程序:

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 依然有百萬之巨。

循環查看單個進程的 cpu 消耗:

import psutil
import time
p = psutil.Process(1) # 改成你自己的 pid 就行瞭
while 1:
    v = str(p.cpu_percent())
    if "0.0" != v:
        print(v, time.time())
    time.sleep(1)

以上就是Go 模塊在下遊服務抖動恢復後CPU占用無法恢復原因的詳細內容,更多關於Go CPU占用無法恢復原因的資料請關註WalkonNet其它相關文章!

推薦閱讀: