golang 記憶體和cpu優化
golang 記憶體和cpu優化
背景介紹
在壓力測試的過程中程式會發生記憶體和CPU飆升的情況,並且持續一段時間後,雖有所回落,但是記憶體還是沒有及時回收,分析可能存在記憶體洩露的情況。
問題分析
(1.)在程式碼中加入效能分析的監控,具體如下:
import ( _ "net/http/pprof" // 引入 pprof 模組 _ "github.com/mkevac/debugcharts" // 可選,圖形化外掛 ) func main(){ // ... // 記憶體分析 go func() { http.ListenAndServe("0.0.0.0:8090", nil) }() // ... }
(2.) 執行程式,由於程式執行在遠端linux伺服器,如需在本地檢視還需要進行埠對映。當然也可以直接在遠端linux伺服器上通過命令列方式進行檢視,但是追蹤程式碼路徑時可能找不到,需要指定程式碼源路徑。
go tool pprof -http 172.0.0.88:8070 http://172.0.0.88:8090/debug/pprof/heap
// 瀏覽器訪問
http://172.0.0.88:8070
(3.)通過jemter進行壓力測試
(4.)檢視top10的記憶體佔用,分析top10的函式佔用,這裡可以看到addMap()函式佔比較高,可著重分析。
引數說明:
列名 | 含義 |
---|---|
flat | 本函式的執行耗時 |
flat% | flat 佔 CPU 總時間的比例。 |
sum% | 前面每一行的 flat 佔比總和 |
cum | 累計量。指該函式加上該函式呼叫的函式總耗時 |
cum% | cum 佔 CPU 總時間的比例 |
(5.)停掉jemter的壓力測試,等待兩分鐘後(便於GC進行垃圾回收)檢視仍然在佔用中的記憶體。這裡可以查詢inuse_space和inuse_obj這兩個引數。這裡也可以通過peek檢視具體程式碼的哪一行佔用記憶體較高。
(6.)既然沒有了使用者操作,記憶體還被佔用,沒有釋放,那必然存在問題,進一步檢視這一塊程式碼進行分析。
這裡分析程式碼發現,addMap有一個遞迴操作,在呼叫該函式結束後,map仍然沒有釋放,這裡需要說明的是go1.14一直存在map記憶體的問題,go1.17該問題已修復。這裡我做了對該函式的效能測試,並列印了記憶體資訊。
// 列印堆疊資訊
func printMemStats() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc = %v TotalAlloc = %v Just Freed = %v Sys = %v NumGC = %v\n",
m.Alloc/1024, m.TotalAlloc/1024, ((m.TotalAlloc-m.Alloc)-lastTotalFreed)/1024, m.Sys/1024, m.NumGC)
lastTotalFreed = m.TotalAlloc - m.Alloc
}
-------------------------------------------------------------------
引數說明:
Alloc:當前堆上物件佔用的記憶體大小。
TotalAlloc:堆上總共分配出的記憶體大小。
Sys:程式從作業系統總共申請的記憶體大小。
NumGC:垃圾回收執行的次數。
// 基準測試
go test -bench=. -benchmem // 進行時間、記憶體的基準測試
go test -bench=. -run=none -benchmem -memprofile=mem.pprof
go test -bench=. -run=none -blockprofile=block.pprof
go test -bench=. -run=none -benchmem -memprofile=mem.pprof -cpuprofile=cpu.pprof
測試程式碼
import (
"testing"
)
func BenchmarAddMap(b *testing.B) {
// 執行 addMap 函式 b.N 次
for n := 0; n < b.N; n++ {
addMap()
printMemStats() // 列印記憶體資訊
}
}
// 輸出記憶體和CPU的資訊
go test -bench=. -run=none \
-benchmem -memprofile=mem.pprof \
-cpuprofile=cpu.pprof \
-blockprofile=block.pprof
// 使用go tool進行分析
go tool pprof cpu.pprof
top10 -cum // 檢視top10佔用情況
list xxx // 檢視具體某個函式的記憶體
go tool pprof -http=":8080" cpu.pprof // 使用web介面進行分析
經過對addMap()函式進行效能測試發現,申請的記憶體一直在增長,總的記憶體佔比也在增長。
(7.)map記憶體釋放
-
如果刪除的元素是值型別,如int,float,bool,string以及陣列和struct,map的記憶體不會自動釋放
-
如果刪除的元素是引用型別,如指標,slice,map,chan等,map的記憶體會自動釋放,但釋放的記憶體是子元素應用型別的記憶體佔用
-
將map設定為nil後,記憶體被回收,map 不會收縮 “不再使用” 的空間。就算把所有鍵值刪除,它依然保留記憶體空間以待後用。
綜合以上三點結論,我們需要對所有頻繁使用map的地方,進行手動釋放map記憶體,即將
map=nil
。slice在用完後,最好也能手動置空
slice= slice[0:0]
,理由是:golang中slice是對陣列的引用,底層實現實際上還是陣列。對slice一定要謹慎使用append操作。如果cap未變化時,slice是對陣列的引用,並且append會修改被引用陣列的值。append操作導致cap變化後,會複製被引用的陣列,然後切斷引用關係。
(8.)修改完map後,繼續分析,發現goroutine中wg使用也存在部分問題。
WaitGroup
物件內部有一個計數器,最初從0開始,它有三個方法:Add(), Done(), Wait()
用來控制計數器的數量。Add(n)
把計數器設定為n
,Done()
每次把計數器-1
,wait()
會阻塞程式碼的執行,直到計數器地值減為0。 使用wg時計數器不能為負值,另外WaitGroup物件不是一個引用型別,在通過函式傳值的時候需要使用地址。
// 錯誤示例:
func testGoroutine() {
wg := sync.WaitGroup{}
for i := 0; i < 10; i++ {
// wg.Add(1) // 正確用法
go func() {
wg.Add(1) // 注意:wg.Add需要放到goroutine外部,才能起到計數的作用
defer wg.Done()
fmt.Println("hello world")
}()
}
wg.Wait()
}
另外這裡建議使用goroutine池來實現,防止因為啟動過多的goutine而導致記憶體佔用過多,需要控制goroutine數量, 可以使用sync waitGroup
+ 非阻塞channel
實現 程式碼如下:
package gopool
import "sync"
// goroutine pool
type GoroutinePool struct {
c chan struct{}
wg *sync.WaitGroup
}
// 採用有緩衝channel實現,當channel滿的時候阻塞
func NewGoroutinePool(maxSize int) *GoroutinePool {
if maxSize <= 0 {
panic("max size too small")
}
return &GoroutinePool{
c: make(chan struct{}, maxSize),
wg: new(sync.WaitGroup),
}
}
// add
func (g *GoroutinePool) Add(delta int) {
g.wg.Add(delta)
for i := 0; i < delta; i++ {
g.c <- struct{}{}
}
}
// done
func (g *GoroutinePool) Done() {
<-g.c
g.wg.Done()
}
// wait
func (g *GoroutinePool) Wait() {
g.wg.Wait()
}
(9.)goroutine修改完後,再次測試效果又好了很多,再分析一下timer和ticker,畢竟這兩個也很容易產生記憶體洩露,進一步完善一下程式碼。
sendTimer := time.NewTimer(time.Second)
for {
if !sendTimer.Stop() {
select {
case <-sendTimer.C:
default:
}
}
select {
case <-this.exit:
sendTimer.Stop()
return
case <-sendTimer.C:
// 傳送
// doSomething()
sendTimer.Reset(time.Second)
}
}
(10.)儘可能的少用全域性變數,因為全域性變數只有在程式結束後,記憶體才能得到釋放。儘量使用區域性變數(棧上分配),多個區域性變數合併一個大的結構體或陣列,減少掃描物件的次數,一次回儘可能多的記憶體。
(11)defer雖好,但是也要適當使用。
當前程式碼中有許多地方為了列印日誌方便,直接使用defer log.Printf("xxx"),建議直接在函式結尾處列印,或者發生錯誤的地方列印。defer設計之初,主要用於資源釋放,鎖的釋放等場景。
defer的實現機制:編譯器通過 runtime.deferproc “註冊” 延遲呼叫,除目標函式地址外,還會複製相關引數(包括 receiver)。在函式返回前,執行 runtime.deferreturn 提取相關資訊執行延遲呼叫。這其中的代價自然不是普通函式呼叫一條 CALL 指令所能比擬的。
(12)檢視某程式記憶體佔用,可以通過pidstat -r -p 13084 1
來檢視。
minflt/s: 每秒次缺頁錯誤次數(minor page faults),次缺頁錯誤次數意即虛擬記憶體地址對映成實體記憶體地址產生的page fault次數
majflt/s: 每秒主缺頁錯誤次數(major page faults),當虛擬記憶體地址對映成實體記憶體地址時,相應的page在swap中,這樣的page fault為major page fault,一般在記憶體使用緊張時產生
VSZ: 該程序使用的虛擬記憶體(以kB為單位)
RSS: 該程序使用的實體記憶體(以kB為單位)
%MEM: 該程序使用記憶體的百分比
Command: 拉起程序對應的命令