首頁 > 軟體

golang程序記憶體控制避免docker內oom

2022-10-23 18:01:20

背景

golang版本:1.16

之前遇到的問題,docker啟動時禁用了oom-kill(kill後服務受損太大),導致golang記憶體使用接近docker上限後,程序會hang住,不響應任何請求,debug工具也無法attatch。

前文分析見:golang程序在docker中OOM後hang住問題

本文主要嘗試給出解決方案

測試程式

測試程式程式碼如下,協程h.allocate每秒檢查記憶體是否達到800MB,未達到則申請記憶體,協程h.clear每秒檢查記憶體是否超過800MB的80%,超過則釋放掉超出部分,模擬通常的業務程式頻繁進行記憶體申請和釋放的邏輯。程式通過http請求127.0.0.1:6060觸發開始執行方便debug。

docker啟動時加--memory 1G --memory-reservation 1G --oom-kill-disable=true引數限制總記憶體1G並關閉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          // 開始進行記憶體申請釋放
   total        int32           // 4kB記憶體總個數
   count        int             // 4KB記憶體最大個數
   ratio        float64         // 記憶體數達到count*ratio後釋放多的部分
   bytesBuffers [][]byte        // 記憶體池
   locks        []*sync.RWMutex // 每個4kb記憶體一個鎖減少競爭
   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) // 觸發開始記憶體申請釋放
}
func (h *handler) started() bool {
   return atomic.LoadUint32(&h.start) == 1
}
// 每s檢查記憶體未達到count個則補足
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: %dKBn", atomic.LoadInt32(&h.total)*arraySize/1024)
         }
      }
   }()
}
// 每s檢查記憶體超過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: %dKBn", 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...%dn", d[0])
         }()
      }
   }()
}
// 等待啟動
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()
}

程式執行一段時間後rss佔用即達到1G,程式不再響應請求,docker無法通過bash連線上,已經連線的bash執行命令顯示錯誤bash: fork: Cannot allocate memory

一、為gc預留空間方案

之前的分析中,hang住的地方是呼叫mmap,golang內的堆疊是gc stw後的mark階段,所以最開始的解決方法是想在stw之前預留100MB空間,stw後釋放該部分空間給作業系統,改動如下:

但是程序同樣會hang住,debug單步偵錯發現存在三種情況

  • 未觸發gc(是因為gc的步長引數預設為100%,下一次gc觸發的時機預設是記憶體達到上次gc的兩倍);
  • gc的stw之前就阻塞住,多數在gcBgMarkStartWorkers函數啟動新的goroutine時陷入阻塞;
  • gc的stw後mark prepare階段阻塞,即前文分析中的,申請新的workbuf時在mmap時阻塞;

可見,預留記憶體的方式只能對第3種情況有改善,增加了預留記憶體後多數為第2種情況阻塞。

從解決問題的角度看,預留記憶體,是讓gc去適配記憶體達到上限後系統呼叫阻塞的情況,對於其他情況gc反而更差了,因為有額外的記憶體和cpu開銷。更何況因為第2種情況的存在,導致gc的修改無法面面俱到。

而且即使第2種情況建立g不阻塞,建立g後仍然需要找到合適的m執行,但因為已有的m都會因為系統呼叫被阻塞,而建立新的m即新的執行緒,又會被阻塞在記憶體申請上。所以這是不光golang會遇到的問題,即使用其他語言寫也會有這種問題。在這種環境下執行的程序,必須對自身的記憶體大小做嚴格控制。

二、調整gc引數

通過第一種方案的嘗試,我們需要轉換角度,結合實際使用場景做適配, 避免影響golang執行機制。限制條件主要有:

  • 程序會使用較多記憶體
  • 程序的使用有上限, 達到上限後系統呼叫會阻塞

需要讓程序控制記憶體上限,同時在達到上限前多觸發gc。解決方式如下:

  • 用記憶體池。測試程式中的allocate和clear的邏輯,實際上就是實現了一個記憶體池,控制總的記憶體在640~800MB之間波動。
  • 增加gc頻率。程式啟動時加環境變數GOGC=12,控制gc步長在12%,例如記憶體池達到800MB時,會在800*112%=896MB時觸發gc,避免記憶體達到1G上限。

實測程序記憶體在900MB以下波動,沒有hang住。

以上就是golang程序記憶體控制避免docker內oom的詳細內容,更多關於golang程序避免docker oom的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com