golang進(jìn)程內(nèi)存控制避免docker內(nèi)oom
背景
golang版本:1.16
之前遇到的問題,docker啟動(dòng)時(shí)禁用了oom-kill(kill后服務(wù)受損太大),導(dǎo)致golang內(nèi)存使用接近docker上限后,進(jìn)程會(huì)hang住,不響應(yīng)任何請(qǐng)求,debug工具也無法attatch。
前文分析見:golang進(jìn)程在docker中OOM后hang住問題
本文主要嘗試給出解決方案
測(cè)試程序
測(cè)試程序代碼如下,協(xié)程h.allocate每秒檢查內(nèi)存是否達(dá)到800MB,未達(dá)到則申請(qǐng)內(nèi)存,協(xié)程h.clear每秒檢查內(nèi)存是否超過800MB的80%,超過則釋放掉超出部分,模擬通常的業(yè)務(wù)程序頻繁進(jìn)行內(nèi)存申請(qǐng)和釋放的邏輯。程序通過http請(qǐng)求127.0.0.1:6060觸發(fā)開始執(zhí)行方便debug。
docker啟動(dòng)時(shí)加--memory 1G --memory-reservation 1G --oom-kill-disable=true
參數(shù)限制總內(nèi)存1G并關(guān)閉oom-kill
package main import ( "fmt" "math/rand" "net/http" _ "net/http/pprof" "sync" "sync/atomic" "time" ) const ( maxBytes = 800 * 1024 * 1024 // 800MB arraySize = 4 * 1024 ) type handler struct { start uint32 // 開始進(jìn)行內(nèi)存申請(qǐng)釋放 total int32 // 4kB內(nèi)存總個(gè)數(shù) count int // 4KB內(nèi)存最大個(gè)數(shù) ratio float64 // 內(nèi)存數(shù)達(dá)到count*ratio后釋放多的部分 bytesBuffers [][]byte // 內(nèi)存池 locks []*sync.RWMutex // 每個(gè)4kb內(nèi)存一個(gè)鎖減少競(jìng)爭(zhēng) wg *sync.WaitGroup } func newHandler(count int, ratio float64) *handler { h := &handler{ count: count, bytesBuffers: make([][]byte, count), locks: make([]*sync.RWMutex, count), wg: &sync.WaitGroup{}, ratio: ratio, } for i := range h.locks { h.locks[i] = &sync.RWMutex{} } return h } func (h *handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { atomic.StoreUint32(&h.start, 1) // 觸發(fā)開始內(nèi)存申請(qǐng)釋放 } func (h *handler) started() bool { return atomic.LoadUint32(&h.start) == 1 } // 每s檢查內(nèi)存未達(dá)到count個(gè)則補(bǔ)足 func (h *handler) allocate() { h.wg.Add(1) go func() { defer h.wg.Done() ticker := time.NewTicker(time.Second) for range ticker.C { for i := range h.bytesBuffers { h.locks[i].Lock() if h.bytesBuffers[i] == nil { h.bytesBuffers[i] = make([]byte, arraySize) h.bytesBuffers[i][0] = 'a' atomic.AddInt32(&h.total, 1) } h.locks[i].Unlock() fmt.Printf("allocated size: %dKB\n", atomic.LoadInt32(&h.total)*arraySize/1024) } } }() } // 每s檢查內(nèi)存超過count*ratio將超出的部分釋放掉 func (h *handler) clear() { h.wg.Add(1) go func() { defer h.wg.Done() ticker := time.NewTicker(time.Second) for range ticker.C { diff := int(atomic.LoadInt32(&h.total)) - int(float64(h.count)*h.ratio) tmp := diff for diff > 0 { i := rand.Intn(h.count) h.locks[i].RLock() if h.bytesBuffers[i] == nil { h.locks[i].RUnlock() continue } h.locks[i].RUnlock() h.locks[i].Lock() if h.bytesBuffers[i] == nil { h.locks[i].Unlock() continue } h.bytesBuffers[i] = nil h.locks[i].Unlock() atomic.AddInt32(&h.total, -1) diff-- } fmt.Printf("free size: %dKB, left size: %dKB\n", tmp*arraySize/1024, atomic.LoadInt32(&h.total)*arraySize/1024) } }() } // 每s打印日志檢查是否阻塞 func (h *handler) print() { h.wg.Add(1) go func() { defer h.wg.Done() ticker := time.NewTicker(time.Second) for range ticker.C { go func() { d := make([]byte, 1024) // trigger gc d[0] = 1 fmt.Printf("running...%d\n", d[0]) }() } }() } // 等待啟動(dòng) func (h *handler) wait() { h.wg.Add(1) go func() { defer h.wg.Done() addr := "127.0.0.1:6060" // trigger to start err := http.ListenAndServe(addr, h) if err != nil { fmt.Printf("failed to listen on %s, %+v", addr, err) } }() for !h.started() { time.Sleep(time.Second) fmt.Printf("waiting...\n") } } // 等待退出 func (h *handler) waitDone() { h.wg.Wait() } func main() { go func() { addr := "127.0.0.1:6061" // debug _ = http.ListenAndServe(addr, nil) }() h := newHandler(maxBytes/arraySize, 0.8) h.wait() h.allocate() h.clear() h.print() h.waitDone() }
程序執(zhí)行一段時(shí)間后rss占用即達(dá)到1G,程序不再響應(yīng)請(qǐng)求,docker無法通過bash連接上,已經(jīng)連接的bash執(zhí)行命令顯示錯(cuò)誤bash: fork: Cannot allocate memory
一、為gc預(yù)留空間方案
之前的分析中,hang住的地方是調(diào)用mmap,golang內(nèi)的堆棧是gc stw后的mark階段,所以最開始的解決方法是想在stw之前預(yù)留100MB空間,stw后釋放該部分空間給操作系統(tǒng),改動(dòng)如下:
但是進(jìn)程同樣會(huì)hang住,debug單步調(diào)試發(fā)現(xiàn)存在三種情況
- 未觸發(fā)gc(是因?yàn)間c的步長(zhǎng)參數(shù)默認(rèn)為100%,下一次gc觸發(fā)的時(shí)機(jī)默認(rèn)是內(nèi)存達(dá)到上次gc的兩倍);
- gc的stw之前就阻塞住,多數(shù)在gcBgMarkStartWorkers函數(shù)啟動(dòng)新的goroutine時(shí)陷入阻塞;
- gc的stw后mark prepare階段阻塞,即前文分析中的,申請(qǐng)新的workbuf時(shí)在mmap時(shí)阻塞;
可見,預(yù)留內(nèi)存的方式只能對(duì)第3種情況有改善,增加了預(yù)留內(nèi)存后多數(shù)為第2種情況阻塞。
從解決問題的角度看,預(yù)留內(nèi)存,是讓gc去適配內(nèi)存達(dá)到上限后系統(tǒng)調(diào)用阻塞的情況,對(duì)于其他情況gc反而更差了,因?yàn)橛蓄~外的內(nèi)存和cpu開銷。更何況因?yàn)榈?種情況的存在,導(dǎo)致gc的修改無法面面俱到。
而且即使第2種情況創(chuàng)建g不阻塞,創(chuàng)建g后仍然需要找到合適的m執(zhí)行,但因?yàn)橐延械膍都會(huì)因?yàn)橄到y(tǒng)調(diào)用被阻塞,而創(chuàng)建新的m即新的線程,又會(huì)被阻塞在內(nèi)存申請(qǐng)上。所以這是不光golang會(huì)遇到的問題,即使用其他語言寫也會(huì)有這種問題。在這種環(huán)境下運(yùn)行的進(jìn)程,必須對(duì)自身的內(nèi)存大小做嚴(yán)格控制。
二、調(diào)整gc參數(shù)
通過第一種方案的嘗試,我們需要轉(zhuǎn)換角度,結(jié)合實(shí)際使用場(chǎng)景做適配, 避免影響golang運(yùn)行機(jī)制。限制條件主要有:
- 進(jìn)程會(huì)使用較多內(nèi)存
- 進(jìn)程的使用有上限, 達(dá)到上限后系統(tǒng)調(diào)用會(huì)阻塞
需要讓進(jìn)程控制內(nèi)存上限,同時(shí)在達(dá)到上限前多觸發(fā)gc。解決方式如下:
- 用內(nèi)存池。測(cè)試程序中的allocate和clear的邏輯,實(shí)際上就是實(shí)現(xiàn)了一個(gè)內(nèi)存池,控制總的內(nèi)存在640~800MB之間波動(dòng)。
- 增加gc頻率。程序啟動(dòng)時(shí)加環(huán)境變量GOGC=12,控制gc步長(zhǎng)在12%,例如內(nèi)存池達(dá)到800MB時(shí),會(huì)在800*112%=896MB時(shí)觸發(fā)gc,避免內(nèi)存達(dá)到1G上限。
實(shí)測(cè)進(jìn)程內(nèi)存在900MB以下波動(dòng),沒有hang住。
以上就是golang進(jìn)程內(nèi)存控制避免docker內(nèi)oom的詳細(xì)內(nèi)容,更多關(guān)于golang進(jìn)程避免docker oom的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Golang連接池的幾種實(shí)現(xiàn)案例小結(jié)
這篇文章主要介紹了Golang連接池的幾種實(shí)現(xiàn)案例小結(jié),文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-03-03Golang中文字符串截取函數(shù)實(shí)現(xiàn)原理
在golang中可以通過切片截取一個(gè)數(shù)組或字符串,但是當(dāng)截取的字符串是中文時(shí),可能會(huì)出現(xiàn)問題,下面我們來自定義個(gè)函數(shù)解決Golang中文字符串截取問題2018-03-03Golang中 import cycle not allowed 問題
這篇文章主要介紹了Golang中 import cycle not allowed 問題的解決方法,問題從描述到解決都非常詳細(xì),需要的小伙伴可以參考一下2022-03-03goFrame的隊(duì)列g(shù)queue對(duì)比channel使用詳解
這篇文章主要為大家介紹了goFrame的gqueue對(duì)比channel使用詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-06-06