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: 拉起進程對應的命令