Go 記憶體洩漏?不是那麼簡單!

originator發表於2020-03-10

最近遇到一個 Go 記憶體不釋放的問題,記錄一下測試和調研的情況。我到不把它歸為 Go 記憶體洩漏的問題,因為它和一般的記憶體洩漏的方式不同。

Go 常見記憶體洩漏的情況

Go 程式可能會在一些情況下造成記憶體洩漏。go101 網站總結了各種記憶體洩漏的情況,我在這裡簡單羅列一下:

  • 獲取長字串中的一段導致長字串未釋放
  • 同樣,獲取長 slice 中的一段導致長 slice 未釋放
  • 在長 slice 新建 slice 導致洩漏
  • goroutine 洩漏
  • time.Ticker 未關閉導致洩漏
  • Finalizer 導致洩漏
  • Deferring Function Call 導致洩漏

記憶體回收分析

實際問題

寫這篇文章的初衷是我在實現一個新專案的時候遇到一個問題。這個專案使用了一個快取元件對請求的結果進行快取,以提高請求的耗時。這個快取元件對使用的最大記憶體進行了限制,比如快取佔用的最大記憶體為 1GB。執行過程中可以對這個最大值進行調整,比如我們可以調整到 100MB。在調整的過程中發現雖然最大記憶體從 1GB 調整到 100MB 之後,程式的 RSS 依然佔用很大,一直是 1GB+ ~ 2GB 的記憶體,感覺記憶體並沒有降下去。

可以看到快取調小了它佔用的記憶體確實降到幾乎為 0 了:

但是釋放的記憶體並沒有返回給作業系統(HeapReleased)

當然經過相應的測試和調研之後,可以看到快取的最大記憶體減少後佔用記憶體和 RSS 也下降了:

倉促之間我只擷取了很小一段時間的指標,實際觀察很長時間也是這樣。

測試程式

我在這個專案中實現了一個 LRU 的 cache, 這個 cache 基於記憶體管理,一旦使用的記憶體超過了 MaxMemory,就會自動進行記憶體清理工作,將最不常用的快取項刪除,具體實現太長就不貼出來了,基本上就是 map + container/list + sync.Mutex 的實現,實現的介面如下:

type Cache interface {
    AddValue(slot uint32, key string, value []byte)
    GetAndValidate(key string, bizKey []byte) (value *CachedValue, ok bool)
    SetMaxMemory(m int64)
    Clear()
}

現在通過一個程式進行測試,分別測試測試前、增加一千萬條資料、將最大記憶體從 1G 減少到 1B、強制垃圾回收四個動作之後的記憶體的使用情況,程式碼如下:

  memoryleak git:(master)  GODEBUG=gctrace=1 go run leak.go
gc 1 @0.027s 0%: 0.009+0.43+0.009 ms clock, 0.037+0.13/0.31/0.82+0.038 ms cpu, 4->4->0 MB, 5 MB goal, 4 P
gc 2 @0.048s 0%: 0.005+0.40+0.003 ms clock, 0.022+0.17/0.29/0.91+0.014 ms cpu, 4->4->0 MB, 5 MB goal, 4 P
……
gc 4 @0.063s 6%: 0.003+7.8+0.028 ms clock, 0.012+0.12/6.1/15+0.11 ms cpu, 23->24->22 MB, 24 MB goal, 4 P
gc 5 @0.121s 5%: 0.003+12+0.035 ms clock, 0.013+1.3/11/27+0.14 ms cpu, 42->43->40 MB, 44 MB goal, 4 P
? before: inuse: 656 KB, idle: 63 MB, released: 0 B, heapsys: 63 MB, sys: 66 MB
gc 1 @0.007s 3%: 0.031+0.94+0.013 ms clock, 0.12+0.10/0.84/0.18+0.052 ms cpu, 4->4->4 MB, 5 MB goal, 4 P
gc 2 @0.013s 7%: 0.004+4.3+0.045 ms clock, 0.017+0.078/3.9/0.85+0.18 ms cpu, 7->8->8 MB, 8 MB goal, 4 P
……
gc 17 @22.855s 6%: 0.018+517+0.011 ms clock, 0.072+105/516/11+0.047 ms cpu, 2441->2644->1368 MB, 2553 MB goal, 4 P
? added: inuse: 1 GB, idle: 942 MB, released: 0 B, heapsys: 2 GB, sys: 2 GB, current: 1023 MB
gc 18 @24.763s 6%: 0.015+87+0.009 ms clock, 0.063+6.0/83/13+0.038 ms cpu, 2568->2622->128 MB, 2737 MB goal, 4 P
gc 19 @25.295s 6%: 0.014+35+0.009 ms clock, 0.056+0.41/35/64+0.037 ms cpu, 247->263->90 MB, 257 MB goal, 4 P
gc 20 @25.397s 6%: 0.015+89+0.004 ms clock, 0.061+14/50/0.60+0.019 ms cpu, 173->194->95 MB, 181 MB goal, 4 P
gc 21 @25.551s 6%: 0.012+59+0.010 ms clock, 0.050+17/59/0.46+0.043 ms cpu, 175->207->105 MB, 191 MB goal, 4 P
? after decreased: inuse: 156 MB, idle: 2 GB, released: 0 B, heapsys: 2 GB, sys: 2 GB, current: 0 B
gc 22 @25.651s 6%: 0.003+67+0.003 ms clock, 0.015+0/52/14+0.012 ms cpu, 156->156->74 MB, 211 MB goal, 4 P (forced)
scvg-1: 2740 MB released
scvg-1: inuse: 75, idle: 2740, sys: 2815, released: 2740, consumed: 75 (MB)
? after gc: inuse: 75 MB, idle: 2 GB, released: 2 GB, heapsys: 2 GB, sys: 2 GB, current: 0 B

首先,我們複習一下 Go 垃圾回收的日誌的意義,再進一步看各個階段記憶體的變化。

以這一條為例:

gc 21 @25.551s 6%: 0.012+59+0.010 ms clock, 0.050+17/59/0.46+0.043 ms cpu, 175->207->105 MB, 191 MB goal, 4 P
  • gc 21: 21 是垃圾回收的編號,逐步遞增,可能會從 1 重新開始
  • @25.551s: 自程式開始經歷了多少時間,這裡是 25 秒多
  • 6%: 自程式啟動花在 GC 上的 CPU 時間百分比, CPU 6% 花在了 GC 上
  • 0.012+59+0.010 ms clock: GC 各階段的牆上時間 (wall-clock),各階段包括STW sweep terminationconcurrent mark and scanSTW mark termination
  • 0.050+17/59/0.46+0.043 ms cpu: 各階段的 CPU 時間。各階段同上,其中 mark/scan 階段又分成了assist timebackground GC timeidle GC time階段
  • 175->207->105 MB: GC 開始時、GC 結束的 heap 大小、存活 (live) 的 heap 大小
  • 191 MB goal:下一次垃圾回收的目標值
  • 4 P: 使用的處理器的數量
  • (forced): 強制垃圾回收, 程式中呼叫 runtime.GC() 或者類似操作
  • scvg-1: 2740 MB released: gctrace 的值大於 0 時,如果垃圾回收將記憶體返回給作業系統時,會列印一條 summary,包括下一條資料

通過對每一項的介紹,你應該瞭解了 go gc 日誌的含義,接下來讓我們看看我們的測試各階段的記憶體佔用情況,也就是標記?的日誌:

? before: inuse: 656 KB, idle: 63 MB, released: 0 B, heapsys: 63 MB, sys: 66 MB
? added: inuse: 1 GB, idle: 942 MB, released: 0 B, heapsys: 2 GB, sys: 2 GB, current: 1023 MB
? after decreased: inuse: 156 MB, idle: 2 GB, released: 0 B, heapsys: 2 GB, sys: 2 GB, current: 0 B
? after gc: inuse: 75 MB, idle: 2 GB, released: 2 GB, heapsys: 2 GB, sys: 2 GB, current: 0 B
  • 在程式剛啟動時,記憶體佔用很小, 真正 inuse 不到 1MB。
  • 我們增加了上萬條資料,每條資料光數就 1KB,如果加上 key 的大小,以及管理 cache 的一些資料結構的額外開銷,佔用就比較大了,粗略統計 inuse 的佔用就達到了 1GB 以上,idle 的 span 的位元組數不到 1GB,從作業系統獲得了 2GB 的記憶體,沒有記憶體返回。可以看到 cache 使用的記憶體粗算為 1023MB。
  • 我們將 cache 的最大記憶體設定為 1B,這會觸發 cache 物件的清理工作,因為最大記憶體很小,導致後續的增加快取操作實際並不會快取物件,可以看到快取的實際大小為 0B。可以看到 inuse 講到了 156MB,我們可以把它看作額外的一些開銷,實際上開始新增的物件都被回收掉了。idle span 的位元組數達到了 2GB,但是並沒有記憶體返還給作業系統。這會導致作業系統認為這個程式佔用記憶體達到 2GB,linux 伺服器上有可能會導致 OOM killer 殺掉這個程式。
  • 我們進行了一次強制垃圾回收 (實際呼叫 debug.FreeOSMemory(),它會進行一次強制垃圾回收),可以看到雖然 idle span 的值還是 2GB+,但是實際其中的 2GB+ 的大小返還給作業系統了,如果這個時候你能夠通過 top 觀察程式的記憶體使用的話,可以看到這個程式的 RES 佔用很小了。

top 命令中關於程式使用記憶體的項介紹:

  • %MEM:Memory usage (RES) 記憶體佔用 使用的實體記憶體

  • VIRT:Virtual Image (kb) 虛擬映象 總虛擬記憶體的使用數量

  • SWAP:Swapped size (kb) 非駐留但是存在於程式中的記憶體,虛擬記憶體減去實體記憶體

  • RES:Resident size (kb) 非 swap 的實體記憶體

  • SHR:Shared Mem size (kb) 程式使用的共享記憶體,可以被其它程式所共享

可以看到,當物件釋放的時候,釋放出來的記憶體並沒有立即返還給作業系統,而在我們進行了一次強制垃圾回收後才返還。 Go 語言把返還的過程叫做 scavenging (拾荒)。這個拾荒的演算法一直在演化,可以檢視 issue #16930,相關的優化提案可以參考:issue #30333。

原先的 scavenging 是每隔幾分鐘 (5 分鐘) 執行一次拾荒操作,保證程式使用的記憶體和 RSS 基本一致。後來在 1.11、1.12 的演化過程中,改成了"智慧"的拾荒操作。目標是儘量避免全部返還給作業系統導致的很重的重獲取的花銷,但是這也帶來了一個問題,那就是當前的拾荒設計對於偶爾一個尖峰,並不會將不用的大量記憶體返還給作業系統,也就是本文一開始我在專案中遇到的問題。這個問題在 issue 中也有討論:

Thus, I propose the following heuristic, borrowed from #16930: retain C*max(heap goal, max(heap goal over the last N GCs))

What happens in an application that has a huge heap spike (say, an initial loading phase) and then the heap drops significantly? In particular, let's say this is drastic enough that the runtime doesn't even notice the drop until a 2 minute GC kicks in. At that scale, it could take a while for N GCs to pass, and we won't reclaim the heap spike until they do.

This is something that came to my mind recently too. An alternative is to set a schedule to decrease the scavenge goal linearly, or according to a smoothstep function, which goes to zero over N GCs. If this schedule ever gets below C * the heap goal, we use that instead. We'll get smoother cliffs in general and still make progress in the case you describe. Smoothstep is preferred here since we won't over-fit to transient drops in heap size, but this also means we might be slower to react in the case you described. I prefer not to over-fit here because that carries a performance cost.

這是一個坑,不幸踩到了。我們這個專案的需求就是運維人員有時候可以將快取使用的最大記憶體設定一個比較小的數,設定之後,go 執行時不觸發拾荒事件,就會導致記憶體被大量佔用而不返還給作業系統。

目前我的修改是在 cache 的最大記憶體調小後執行一次 debug.FreeOSMemory(),這樣可以保證不用的一些記憶體返還給作業系統。當然執行這個操作也是有代價的:

  • Returning all free memory back to the underlying system at once is expensive, and can lead to latency spikes as it holds the heap lock through the whole process.
  • It’s an invasive solution: you need to modify your code to call it when you need it.
  • Reusing free chunks of memory becomes more expensive. On UNIX-y systems that means an extra page fault (which is surprisingly expensive on some systems).

Go 1.13 中對拾荒的實現有進行了改進,而且 Go 1.13 也快釋出了,釋出之後我再做進一步的測試,儘量避免使用 debug.FreeOSMemory()。

runtime.MemStats

通過 runtime.MemStats 可以實時的獲取 Go 執行時的記憶體統計資訊,這個資料結構包含很多的欄位。欄位雖然很多,但是由於文件還是不夠詳細,如果沒有深入理解 Go 語言內部的實現方式和相關的概念的話,不容易理解這個資料結構具體的含義,只根據字面值去理解很容易誤用, 比如 HeapIdle 並不是 Go 佔用的還沒有釋放的記憶體空間,其中的 HeapReleased 其實已經返還給作業系統了。

我將各個欄位的中文解釋列在了這裡,如果你要監控 go 執行時的記憶體,需要仔細閱讀相關的欄位的解釋。

type MemStats struct {
        // 已分配的物件的位元組數.
        //
        // 和HeapAlloc相同.
        Alloc uint64
        // 分配的位元組數累積之和.
        //
        // 所以物件釋放的時候這個值不會減少.
        TotalAlloc uint64
        // 從作業系統獲得的記憶體總數.
        //
        // Sys是下面的XXXSys欄位的數值的和, 是為堆、棧、其它內部資料保留的虛擬記憶體空間. 
        // 注意虛擬記憶體空間和實體記憶體的區別.
        Sys uint64
        // 執行時地址查詢的次數,主要用在執行時內部除錯上.
        Lookups uint64
        // 堆物件分配的次數累積和.
        // 活動物件的數量等於`Mallocs - Frees`.
        Mallocs uint64
        // 釋放的物件數.
        Frees uint64
        // 分配的堆物件的位元組數.
        //
        // 包括所有可訪問的物件以及還未被垃圾回收的不可訪問的物件.
        // 所以這個值是變化的,分配物件時會增加,垃圾回收物件時會減少.
        HeapAlloc uint64
        // 從作業系統獲得的堆記憶體大小.
        //
        // 虛擬記憶體空間為堆保留的大小,包括還沒有被使用的.
        // HeapSys 可被估算為堆已有的最大尺寸.
        HeapSys uint64
        // HeapIdle是idle(未被使用的) span中的位元組數.
        //
        // Idle span是指沒有任何物件的span,這些span **可以**返還給作業系統,或者它們可以被重用,
        // 或者它們可以用做棧記憶體.
        //
        // HeapIdle 減去 HeapReleased 的值可以當作"可以返回到作業系統但由執行時保留的記憶體量".
        // 以便在不向作業系統請求更多記憶體的情況下增加堆,也就是執行時的"小金庫".
        //
        // 如果這個差值明顯比堆的大小大很多,說明最近在活動堆的上有一次尖峰.
        HeapIdle uint64
        // 正在使用的span的位元組大小.
        //
        // 正在使用的span是值它至少包含一個物件在其中.
        // HeapInuse 減去 HeapAlloc的值是為特殊大小保留的記憶體,但是當前還沒有被使用.
        HeapInuse uint64
        // HeapReleased 是返還給作業系統的實體記憶體的位元組數.
        //
        // 它統計了從idle span中返還給作業系統,沒有被重新獲取的記憶體大小.
        HeapReleased uint64
        // HeapObjects 實時統計的分配的堆物件的數量,類似HeapAlloc.
        HeapObjects uint64
        // 棧span使用的位元組數。
        // 正在使用的棧span是指至少有一個棧在其中.
        //
        // 注意並沒有idle的棧span,因為未使用的棧span會被返還給堆(HeapIdle).
        StackInuse uint64
        // 從作業系統取得的棧記憶體大小.
        // 等於StackInuse 再加上為作業系統執行緒棧獲得的記憶體.
        StackSys uint64
        // 分配的mspan資料結構的位元組數.
        MSpanInuse uint64
        // 從作業系統為mspan獲取的記憶體位元組數.
        MSpanSys uint64
        // 分配的mcache資料結構的位元組數.
        MCacheInuse uint64
        // 從作業系統為mcache獲取的記憶體位元組數.
        MCacheSys uint64
        // 在profiling bucket hash tables中的記憶體位元組數.
        BuckHashSys uint64
        // 垃圾回收後設資料使用的記憶體位元組數.
        GCSys uint64 // Go 1.2
        // off-heap的雜項記憶體位元組數.
        OtherSys uint64 // Go 1.2
        // 下一次垃圾回收的目標大小,保證 HeapAlloc ≤ NextGC.
        // 基於當前可訪問的資料和GOGC的值計算而得.
        NextGC uint64
        // 上一次垃圾回收的時間.
        LastGC uint64
        // 自程式開始 STW 暫停的累積納秒數.
        // STW的時候除了垃圾回收器之外所有的goroutine都會暫停.
        PauseTotalNs uint64
        // 一個迴圈buffer,用來記錄最近的256個GC STW的暫停時間.
        PauseNs [256]uint64
        // 最近256個GC暫停截止的時間.
        PauseEnd [256]uint64 // Go 1.4
        // GC的總次數.
        NumGC uint32
        // 強制GC的次數.
        NumForcedGC uint32 // Go 1.8
        // 自程式啟動後由GC佔用的CPU可用時間,數值在 0 到 1 之間.
        // 0代表GC沒有消耗程式的CPU. GOMAXPROCS * 程式執行時間等於程式的CPU可用時間.
        GCCPUFraction float64 // Go 1.5
        // 是否允許GC.
        EnableGC bool
        // 未使用.
        DebugGC bool
        // 按照大小進行的記憶體分配的統計,具體可以看Go記憶體分配的文章介紹.
        BySize [61]struct {
                // Size is the maximum byte size of an object in this
                // size class.
                Size uint32
                // Mallocs is the cumulative count of heap objects
                // allocated in this size class. The cumulative bytes
                // of allocation is Size*Mallocs. The number of live
                // objects in this size class is Mallocs - Frees.
                Mallocs uint64
                // Frees is the cumulative count of heap objects freed
                // in this size class.
                Frees uint64
        }
}

Go 執行時的記憶體分配演算法可以檢視文章: A visual guide to Go Memory Allocator from scratch (Golang) , 或者中文翻譯: Go 記憶體分配器視覺化指南,這是目前第一篇全面介紹 Go 執行時記憶體管理的文章。

runtime.SetGCPercent

GOGC 設定垃圾回收的目標百分比。什麼時候會觸發 Go 執行時的垃圾回收操作呢,主要靠這個值。當這次新分配的資料和上一次垃圾回收後存活資料之比達到這個數值之後就會觸發一次垃圾回收。

GOGC 的預設值是 100。設定 GOGC=off 會禁止垃圾回收。

你也可以通過程式碼設定這個引數,呼叫runtime.SetGCPercent進行設定。

MADV

MADV 是 Linux 的一個特性,,可以看相關的介紹:MADV_FREE functionality

一直以來 go 的 runtime 在釋放記憶體返回到核心時,在 Linux 上使用的是 MADV_DONTNEED,雖然效率比較低,但是會讓 RSS(resident set size 常駐記憶體集)數量下降得很快。不過在 go 1.12 裡專門針對這個做了優化,runtime 在釋放記憶體時,使用了更加高效的 MADV_FREE 而不是之前的 MADV_DONTNEED。這樣帶來的好處是,一次 GC 後的記憶體分配延遲得以改善,runtime 也會更加積極地將釋放的記憶體歸還給作業系統,以應對大塊記憶體分配無法重用已存在的堆空間的問題。不過也會帶來一個副作用:RSS 不會立刻下降,而是要等到系統有記憶體壓力了,才會延遲下降。需要注意的是,MADV_FREE 需要 4.5 以及以上核心,否則 runtime 會繼續使用原先的 MADV_DONTNEED 方式。當然 go 1.12 為了避免像這樣一些靠判斷 RSS 大小的自動化測試因此出問題,也提供了一個 GODEBUG=madvdontneed=1 引數可以強制 runtime 繼續使用 MADV_DONTNEED:runtime: provide way to disable MADV_FREE

相關 issue 和資料

這裡列出了 Go 官方庫中的一些記憶體洩漏相關的 issue,以及關於 Go 記憶體洩漏的一些文章,感興趣的同學可以進一步閱讀。

  1. https://golang.org/pkg/runtime/#MemStats
  2. https://github.com/golang/go/issues/33684
  3. https://github.com/golang/go/issues/33376
  4. https://github.com/golang/go/issues/32284
  5. https://github.com/golang/go/issues/16843
  6. https://github.com/golang/go/issues/14521
  7. https://go101.org/article/memory-leaking.html
  8. http://play.golang.org/p/Nb39COQgxr
  9. https://www.freecodecamp.org/news/how-i-investigated-memory-leaks-in-go-using-pprof-on-a-large-codebase-4bec4325e192/
  10. https://medium.com/dm03514-tech-blog/sre-debugging-simple-memory-leaks-in-go-e0a9e6d63d4d
  11. https://github.com/golang/go/issues/16930
  12. https://github.com/golang/go/issues/30333
  13. https://go-review.googlesource.com/c/go/+/135395/
  14. https://github.com/golang/go/issues/23687
  15. https://ms2008.github.io/2019/06/30/golang-madvfree/
  16. https://golang.org/doc/go1.12#runtime
更多原創文章乾貨分享,請關注公眾號
  • Go 記憶體洩漏?不是那麼簡單!
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章