最近做了許多有關Go內存優化的工作,總結了一些定位、調優方面的套路和經驗,於是,想通過這篇文章與大家分享討論。
發現問題
性能優化領域有一條總所周知的鐵律,即:不要過早地優化。編寫一個程序,首先應該保證其功能的正確性,以及諸如設計是否合理、需求等是否滿足,過早地優化只會引入不必要的復雜度以及設計不合理等各種問題。
那么何時才能開始優化呢?一句話,問題出現時。諸如程序出現頻繁OOM,CPU使用率異常偏高等情況。如今,在這微服務盛行的時代,公司內部都會擁有一套或簡單或復雜的監控系統,當系統給你發出相關告警時,你就要開始重視起來了。
問題定位
1. 查看內存曲線
首先,當程序發生OOM時,首先應該查看程序的內存使用量曲線,可以通過現有監控系統查看,或者prometheus之類的開源工具。
曲線一般都是呈上升趨勢,比如goroutine泄露的曲線一般是使用量緩慢上升直至OOM,而內存分配不合理往往時在高負載時快速攀升以致OOM。
2. 問題復現
這塊是可選項,但是最好能保證復現。如果能在本地或debug環境復現問題,這將非常有利於我們反復進行測試和驗證。
3. 使用pprof定位
Go官方工具提供了pporf來專門用以性能問題定位,首先得在程序中開啟pprof收集功能,這里假定問題程序已開啟pprof。(對這塊不夠了解的同學,建議通過這兩篇文章(1, 2)學習下pprof工具的基本用法)
接下來,我們復現問題場景,並及時獲取heap和groutine的采樣信息。
- 獲取heap信息: curl http://loalhost:6060/debug/pprof/heap -o h1.out
- 獲取groutine信息:curl http://loalhost:6060/debug/pprof/goroutine -o g1.out
這里你可能想問,這樣就夠了嗎?
當然不是,只獲取一份樣本信息是不夠的。內存使用量是不斷變化的(通常是上升),因此我們需要的也是期間heap、gourtine信息的變化信息,而非瞬時值。一般來說,我們需要一份正常情況下的樣本信息,一份或多份內存升高期間的樣本信息。
數據收集完畢后,我們按照如下3個方面來排查定位。
排查goroutine泄露
使用命令go tool pprof --base g1.out g2.out
,比較goroutine信息來判斷是否有goroutine激增的情況。
進入交互界面后,輸入top
命令,查看期間goroutine的變化。
同時可執行go tool pprof --base g2.out g3.out
來驗證。我之前寫了的一篇實戰文章,記錄了goroutine泄露的排查過程。
排查內存使用量
使用命令go tool pprof --base h1.out h2.out
,比較當前堆內存的使用量信息來判斷內存使用量。
進入交互界面后,輸入top
命令,查看期間堆內存使用量的變化。
排查內存分配量
當上述排查方向都沒發現問題時,那就要查看期間是否有大量的內存申請了。
我們知道Go預言中的GC有三個觸發點:
- 兩分鍾沒有GC,就強制GC
- 用戶手動調用runtime.GC(一般只測試用)
- 程序當前使用的堆內存達到了runtime中動態計算的GC heap goal
若進程過於頻繁地申請大內存,那么就會把GC heap goal不斷提高,到超過內存限制后,就會OOM。
我們可以使用命令go tool pprof --alloc_space --base h1.out h2.out
,通過比較前后內存分配量來找到是否有分配不合理的現象。(按照經驗來說,在熱點函數中申請內存易觸發這種情況)
進入交互界面后,輸入top
命令,查看期間堆內存分配量的變化。
一般來說,通過上述3個方面的排查,我們基本就能定位出究竟是哪方面的問題導致內存激增了。我們也可以通過web
命令,更為直觀地查看問題函數(方法)的完整調用鏈。
問題優化
定位到問題根因后,接下來就是優化階段了。這個階段需要對Go本身足夠熟悉,還得對問題程序的業務邏輯有所了解。
我梳理了一些常見的優化手段,僅供參考。實際場景還是得實際分析。
goroutine泄露
這種問題還是比較好修復的,需要顯式地保證goroutine能正確退出,而非以一些自以為的假設來保證。例如,通過傳遞context.Context
對象來顯式退出
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
default:
}
...
}
}(ctx)
對象復用
在一些熱點代碼處,我們應該避免每次調用都申請新的內存,因為在極端情況下,內存分配速度可能會超過GC的速度,從而導致內存激增。這種情況下,我們可以采取復用對象的方式,例如我們可以使用sync.Pool
來復用對象
var pool = sync.Pool{New: func() interface{} { return make([]byte, 4096) }}
func fn() {
buf := pool.Get().([]byte) // takes from pool or calls New
// do work
pool.Put(buf) // returns buf to the pool
}
避免[]byte和string轉換
在Go中,使用string()
或[]byte()
來實現[]byte和string的類型轉換,會額外申請一塊內存來復制。我們可以通過一些技巧來避免復制,例如*(*[]byte)(unsafe.Pointer(&s))
來實現string轉[]byte
除此之外,還有很多優化方法,可以多看看dave cheney大神的這篇文章,寫得很全面。
優化驗證
最后一步,我們需要驗證優化的結果,畢竟你至少得說服自己,你的優化是的確有成效的。
除了通過復現測試來驗證有效性外的,還可以編寫Benchmark測試用例來比較優化前后的內存分配情況(在Benchmark測試用例中加入一行b.ReportAllocs()
,即可得到內存分配量信息)
總結
性能調優是一項必備但是較為困難的技能,不僅需要熟悉語言、操作系統等基本知識,還需要一定的經驗積累。
本文介紹了針對Go程序內存問題的發現、定位、優化以及驗證,希望能對你排查內存問題有所幫助(還有某些情況未能沒考慮到,歡迎評論區參與討論)。