Go 最細節篇|pprof 統計的內存總是偏小?

Go 的內存泄漏

內存泄漏通常在 c/c++ 等語言常見,手工管理內存對程序猿的編程能力有較高要求。最常見的就是分配釋放沒有配對使用。

Go 是一門帶 Gc 的語言,內存分配位置由編譯器推斷是在棧還是堆上,內存分配完全由 Go 本身把控,程序猿無法介入。程序猿在前端觸發分配,後端的 runtime 的 GC 任務則不斷的回收內存,從而達到一個平衡。理論上是不存在常規意義的內存泄漏的。但在程序中,還是經常見到內存佔用持續升高的場景,今天就是來分享這類場景的思考。

Go 的內存問題更多的是內存對象的不合理使用,比如一個全局的 map ,程序猿 A 不知什麼原因持續往裏面添加元素,從來不刪。這就是一個典型的內存泄漏(或者叫做內存不合理佔用)。也就是說,Go 的內存問題基本都是不合理的業務邏輯導致的

一般來說,這類內存問題其實非常好排查,怎麼排查?

使用 Go 自帶的 pprof 工具

運用 pprof 利器

 1   開啓 pprof 端口

導入 pprof 包即可,然後開啓一個監聽端口:

import "net/http"
import _ "net/http/pprof"

func main() {
    // ...
    go func() {
        http.ListenAndServe("0.0.0.0:8080", nil)
    }()
    // ...
}

 2   pprof 排查姿勢

把程序運行起來,然後直接通過網絡接口拿到 pporf 的數據,很方便。

go tool pprof http://127.0.0.1:8080/debug/pprof/allocs

登陸之後 top 就能看到堆棧:

root@ubuntu20:~# go tool pprof http://127.0.0.1:8080/debug/pprof/allocs

(pprof) top
Showing nodes accounting for 1468.90MB, 99.79% of 1471.93MB total
Dropped 22 nodes (cum <= 7.36MB)
      flat  flat%   sum%        cum   cum%
 1270.89MB 86.34% 86.34%  1468.90MB 99.79%  main.main
     198MB 13.45% 99.79%      198MB 13.45%  encoding/base64.(*Encoding).EncodeToString
         0     0% 99.79%      198MB 13.45%  main.EncodeGid
         0     0% 99.79%  1469.90MB 99.86%  runtime.main

 3   pprof 原理其實很簡單

pprof 實現的原理其實很簡單,就是分配釋放的地方做好打點,最好就是能把分配的路徑記錄下來。舉個例子,A 對象分配路徑是:main -> fn1 -> fn2 -> fn3 ( 詳細的原理可以參考文章:Go 內存管理深度細節 ),那麼 go 把這個棧路徑記錄下來即可。這個事情就是在 mallocgc 中實現,每一次的分配都會判斷是否要統計採樣。

pprof 統計到的比 top 看到的要小?

主要有幾個重要原因:1)pprof 有采樣週期,2)管理內存 + 內存碎片,3)cgo 分配的內存 。

 1   pprof 有采樣週期

pprof 統計到的比實際的小,最重要的原因就是:採樣的頻率間隔導致的。

採樣是有性能消耗的。 畢竟是多出來的操作,每次還要記錄堆棧開銷是不可忽視的。所以只能在採樣的頻率上有個權衡,mallocgc 採樣默認是 512 KiB,也就是說,進程每分配滿 512 KiB 的數據纔會記錄一次分配路徑。

// runtime/mprof.go
var MemProfileRate int = 512 * 1024

// runtime/malloc.go
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
 // ...
    if rate := MemProfileRate; rate > 0 {
        if rate != 1 && size < c.next_sample {
         // 累積採樣
            c.next_sample -= size
        } else {
   // 內存的分配採樣入口
            profilealloc(mp, x, size)
        }
    }
    // ...
}

所以這個採樣的頻率就會導致 pprof 看到的分配、在用內存都比實際的物理內存要小。

有辦法影響到加快採樣的頻率嗎?

Go 進程加載的時候,是有機會機會修改這個值的,通過 GODEBUG 來設置 memprofilerate 的值,就會覆蓋 MemProfileRate 的默認值。

func parsedebugvars() {

    for p := gogetenv("GODEBUG"); p != ""; {
        // ...
        if key == "memprofilerate" {
            if n, ok := atoi(value); ok {
                MemProfileRate = n
            }
        } else {
            // ...
        }
    }
}

改成 1 的話,每一次分配都要被採樣,採樣的全面但是性能也是最差的。

 2   內存有碎片率

Go 使用的是 tcmalloc 的內存分配的模型,把內存 page 按照固定大小劃分成小塊。這種方式解決了外部碎片,但是小塊內部還是有碎片的,這個 gap 也是內存差異的一部分。tcmalloc 內部碎片率整體預期控制在 12.5% 左右。

 3   runtime 管理內存

Go 運行過程中會採樣統計內存的分配路徑情況,還會時刻關注整體的內存數值,通過 runtime.ReadMemStats 可以獲取得到。裏面的信息非常之豐富,基本上看一眼就知道內存的一個大概情況,memstat 字段詳情(建議收藏)

其中,內存的 gap 主要來源於

  1. heap 上 Idle span,分配了但是未使用的(往往出現這種情況是一波波的請求峯值導致的,衝上去就一時半會不下來);

  2. stack 的內存佔用;

  3. OS 分配但是是 reserved 的;

  4. runtime 的 Gc 元數據,mcache,mspan 等管理內存;

這部分可大可小,大家記得關注即可。

 4   cgo 分配的內存

cgo 分配的內存無法被統計到,這個很容易理解。因爲 Go 程序的統計是在 malloc.go 文件 mallocgc 這個函數中,cgo 調用的是 c 程序的代碼,八杆子都打不到,它的內存用的 libc 的 malloc ,free 來管理,go 程序完全感知不到,根本沒法統計。

所以,如果你的程序遇到了內存問題,並且沒用到 cgo ,那麼就可以快速 pass ,如果用到了,一般也是排除完前面的所有情況,再來考慮這個因素吧。因爲如果真是 cgo 裏面分配的內存導致的問題,相當於你要退回到原來 c 語言的排查手段,有點蛋疼。

一個實際的例子

來看一個有趣的例子分析下吧,曾經有個 Go 程序系統 top 看起來 15G,go pprof 只能看到 6G 左右,如下:

top 的樣子

[amy@centos7]$ top

   PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
499174 service   20   0   19.2g  15.1g   6932 S 635.3 24.3 496911:01 testprog

pprof 看到的

(pprof) top20
Showing nodes accounting for 6.08GB, 88.63% of 6.86GB total

memstat 看到的

sys_bytes 1.6808429272e+10    // 16029  // 系統常駐內存,單位是 M

alloc_bytes 1.0527689648e+10  // 10040  // 分配出的對象,且使用的

heap_alloc_bytes 1.0527689648e+10  // 10040  // 堆上分配出來,且在使用的
heap_idle_bytes 4.272185344e+09  // 4074  // 堆上的內存,但是還沒人用,等待被使用
heap_inuse_bytes 1.154125824e+10  // 11006  // 堆內存,且在使用的
heap_released_bytes 5.06535936e+08  // 483   // 釋放給 os 的堆內存
heap_sys_bytes 1.5813443584e+10  // 15080.8  // 系統佔用內存

stack_inuse_bytes 2.424832e+07   // 23   // 棧上的內存
stack_sys_bytes 2.424832e+07   // 23    // 棧上的內存

mcache_inuse_bytes 55552    // 0.05  // mcache 結構的內存佔用
mcache_sys_bytes 65536     // 0.06  // mcache 結構的內存佔用
mspan_inuse_bytes 1.87557464e+08  // 178.8  // mspan 結構的內存佔用
mspan_sys_bytes 2.31292928e+08   // 220.5  // mspan 結構的內存佔用

gc_sys_bytes 6.82119168e+08   // 650  // gc 的元數據
buck_hash_sys_bytes 3.86714e+06  // 3.6   // bucket hash 表的開銷
other_sys_bytes 5.3392596e+07   // 50   // 用於其他的系統分配出來的內存

next_gc_bytes 1.4926500032e+10   // 14235  // 下一次 gc 的目標內存大小

上面案例數據可以給到我們幾個小結論

  1. 系統總內存佔用 15+ G 左右;

  2. Go heap 總共佔用約 15 G ,其中 idle 的內存還挺大的,差不多 4G;

  3. 說明有過請求峯值,並且已經過去

  4. mspan,mcache,gc 元數據的內存加起來也到 1G 了,這個值不小了;

  5. 下一次 gc 後的目標是 14 G,也就是說至少有 1G 的垃圾;

  6. pprof 採樣到 6.86 G 的內存分配,pprof top 可以看到 Go 的分配詳情;

總結

  1. Go 的內存問題好排查,用 pprof 能解決 99% 的問題

  2. pprof 採樣雖然比實際的分配要少,但不影響問題的排查,看最多的就行,有問題的會一直有問題

  3. memprofilerate 可以影響到採樣頻率,但一般不建議配置;

  4. tcmalloc 的管理方式沒有外部碎片,但是有內部碎片,整體碎片率控制在 12 % 左右

  5. heap 上的 Idle span 的內存,stack 的內存,runtime 本身結構體的內存,這些內存佔用有時候也不小,值得關注;

  6. 一般我們不會把問題指向 cgo,除非你用到了 cgo 並且已經排除了所有其他方向;

後記

今天分享的是 Go 內存的一個小思考,點贊、在看 是對奇伢最大的支持。

堅持思考,方向比努力更重要。關注我:奇伢雲存儲。歡迎加我好友,技術交流。

歡迎加我好友,技術交流。

奇伢雲存儲 雲存儲深耕之路,專注於對象存儲,塊存儲,雲計算領域。堅持撰寫有思考的技術文章。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-3X0c4P0WdZdjtkTsX16Rw