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
其他


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM