1. 程式人生 > 其它 >golang 記憶體和cpu優化

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) 把計數器設定為nDone() 每次把計數器-1wait() 會阻塞程式碼的執行,直到計數器地值減為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:  拉起程序對應的命令

參考連結

map記憶體釋放

timer的正確使用

defer的效能分析
go-test
其他

【勵志篇】: 古之成大事掌大學問者,不惟有超世之才,亦必有堅韌不拔之志。