性能優(yōu)化 | Go Ballast 讓內(nèi)存控制更加絲滑
關(guān)于 Go GC 優(yōu)化的手段你知道的有哪些?比較常見的是通過調(diào)整 GC 的步調(diào),以調(diào)整 GC 的觸發(fā)頻率。
- 設(shè)置 GOGC
- 設(shè)置 debug.SetGCPercent()
這兩種方式的原理和效果都是一樣的,GOGC 默認(rèn)值是 100,也就是下次 GC 觸發(fā)的 heap 的大小是這次 GC 之后的 heap 的一倍。
我們都知道 GO 的 GC 是標(biāo)記-清除方式,當(dāng) GC 會(huì)觸發(fā)時(shí)全量遍歷變量進(jìn)行標(biāo)記,當(dāng)標(biāo)記結(jié)束后執(zhí)行清除,把標(biāo)記為白色的對(duì)象執(zhí)行垃圾回收。值得注意的是,這里的回收僅僅是標(biāo)記內(nèi)存可以返回給操作系統(tǒng),并不是立即回收,這就是你看到 Go 應(yīng)用 RSS 一直居高不下的原因。在整個(gè)垃圾回收過程中會(huì)暫停整個(gè) Go 程序(STW),Go 垃圾回收的耗時(shí)還是主要取決于標(biāo)記花費(fèi)的時(shí)間的長(zhǎng)短,清除過程是非??斓摹?/p>
設(shè)置 GOGC 的弊端
1. GOGC 設(shè)置比率的方式不精確
設(shè)置 GOGC 基本上我們比較常用的 Go GC 調(diào)優(yōu)的方式,大部分情況下其實(shí)我們并不需要調(diào)整 GOGC 就可以,一方面是不涉及內(nèi)存密集型的程序本身對(duì)內(nèi)存敏感程度太低,另外就是 GOGC 這種設(shè)置比率的方式不精確,我們很難精確的控制我們想要的觸發(fā)的垃圾回收的閾值。
2. GOGC 設(shè)置過小
GOGC 設(shè)置的非常小,會(huì)頻繁觸發(fā) GC 導(dǎo)致太多無效的 CPU 浪費(fèi),反應(yīng)到程序的表現(xiàn)就會(huì)特別明顯。舉個(gè)例子,對(duì)于 API 接口來說,導(dǎo)致的結(jié)果的就是接口周期性的耗時(shí)變化。這個(gè)時(shí)候你抓取 CPU profile 來看,大部分的耗時(shí)都集中在 GC 的相關(guān)處理上。
如上圖,這是一次 prometheus 的查詢操作,我們看到大部分的 CPU 都消耗在 GC 的操作上。這也是生產(chǎn)環(huán)境遇到的,由于 GOGC 設(shè)置的過小,導(dǎo)致過多的消耗都耗費(fèi)在 GC 上。
3. 對(duì)某些程序本身占用內(nèi)存就低,容易觸發(fā) GC
對(duì) API 接口耗時(shí)比較敏感的業(yè)務(wù),如果 GOGC 置默認(rèn)值的時(shí)候,也可能也會(huì)遇到接口的周期性的耗時(shí)波動(dòng)。這是為什么呢?
因?yàn)檫@種接口本身占用內(nèi)存比較低,每次 GC 之后本身占的內(nèi)存比較低,如果按照上次 GC 后的 heap 的一倍的 GC 步調(diào)來設(shè)置 GOGC 的話,這個(gè)閾值其實(shí)是很容易就能夠觸發(fā),于是就很容出現(xiàn)接口因?yàn)?GC 的觸發(fā)導(dǎo)致額外的消耗。
4. GOGC 設(shè)置很大,有的時(shí)候又容易觸發(fā) OOM
那如何調(diào)整呢?是不是把 GOGC 設(shè)置的越大越好呢?這樣確實(shí)能夠降低 GC 的觸發(fā)頻率,但是這個(gè)值需要設(shè)置特別大才有效果。這樣帶來的問題,GOGC 設(shè)置的過大,如果這些接口突然接受到一大波流量,由于長(zhǎng)時(shí)間無法觸發(fā) GC 可能導(dǎo)致 OOM。
由此,GOGC 對(duì)于某些場(chǎng)景并不是很友好,那有沒有能夠精確控制內(nèi)存,讓其在 10G 的倍數(shù)時(shí)準(zhǔn)確控制 GC 呢?
GO 內(nèi)存 ballast
這就需要 Go ballast 出場(chǎng)了。什么是 Go ballast,其實(shí)很簡(jiǎn)單就是初始化一個(gè)生命周期貫穿整個(gè) Go 應(yīng)用生命周期的超大 slice。
- func main() {
- ballast := make([]byte, 10*1024*1024*1024) // 10G
- // do something
- runtime.KeepAlive(ballast)
- }
上面的代碼就初始化了一個(gè) ballast,利用 runtime.KeepAlive 來保證 ballast 不會(huì)被 GC 給回收掉。
利用這個(gè)特性,就能保證 GC 在 10G 的一倍時(shí)才能被觸發(fā),這樣就能夠比較精準(zhǔn)控制 GO GC 的觸發(fā)時(shí)機(jī)。
這里你可能有一個(gè)疑問,這里初始化一個(gè) 10G 的數(shù)組,不就占用了 10 G 的物理內(nèi)存呢? 答案其實(shí)是不會(huì)的。
- package main
- import (
- "runtime"
- "math"
- "time"
- )
- func main() {
- ballast := make([]byte, 10*1024*1024*1024)
- <-time.After(time.Duration(math.MaxInt64))
- runtime.KeepAlive(ballast)
- }
- $ ps -eo pmem,comm,pid,maj_flt,min_flt,rss,vsz --sort -rss | numfmt --header --to=iec --field 5 | numfmt --header --from-unit=1024 --to=iec --field 6 | column -t | egrep "[t]est|[P]I"
- %MEM COMMAND PID MAJFL MINFL RSS VSZ
- 0.1 test 12859 0 1.6K 344M 11530184
這個(gè)結(jié)果是在 CentOS Linux release 7.9 驗(yàn)證的,我們看到占用的 RSS 真實(shí)的物理內(nèi)存只有 344M,但是 VSZ 虛擬內(nèi)存確實(shí)有 10G 的占用。
延伸一點(diǎn),當(dāng)懷疑我們的接口的耗時(shí)是由于 GC 的頻繁觸發(fā)引起的,我們需要怎么確定呢?首先你會(huì)想到周期性的抓取 pprof 的來分析,這種方案其實(shí)也可以,但是太麻煩了。其實(shí)可以根據(jù) GC 的觸發(fā)時(shí)間繪制這個(gè)曲線圖,GC 的觸發(fā)時(shí)間可以利用 runtime.Memstats 的 LastGC 來獲取。
生產(chǎn)環(huán)境驗(yàn)證
- 綠線 調(diào)整前 GOGC = 30
- 黃線 調(diào)整后 GOGC 默認(rèn)值,ballast = 100G
這張圖相同的流量壓力下,ballast 的表現(xiàn)明顯偏好。
結(jié)論
本篇文章只是簡(jiǎn)單的闡述了 Go ballast 的使用,Go ballast 是官方比較認(rèn)可的方案,具體可以參見 issue 23044[1]。很多開源程序,如 tidb[2],cortex[3] 都實(shí)現(xiàn)了 go ballast,如果你的程序飽受 GOGC 的問題影響或者周期性的耗時(shí)不穩(wěn)定,不妨嘗試下 go ballast。
當(dāng)然強(qiáng)烈推薦你看下twitch.tv 這篇文章[4],相信讓你會(huì)對(duì) GOGC 以及 ballast 的運(yùn)用理解的更加透徹。