Go 应用性能优化指北
假期重溫曹大的性能優化文章,收獲還是很大的,值得所有 Gopher 精讀。無論是理論還是實踐,都能從中學到很多。
為什么要做優化
這是一個速度決定一切的時代,我們的生活在不斷地數字化,線下的流程依然在持續向線上轉移,轉移過程中,作為工程師,我們會碰到各種各樣的性能問題。
互聯網公司本質是將用戶共通的行為流程進行了集中化管理,通過中心化的信息交換達到效率提升的目的,同時用規模效應降低了數據交換的成本。
用人話來講,公司希望的是用盡量少的機器成本來賺取盡量多的利潤。利潤的提升與業務邏輯本身相關,與技術關系不大。而降低成本則是與業務無關,純粹的技術話題。這里面最重要的主題就是“性能優化”。
如果業務的后端服務規模足夠大,那么一個程序員通過優化幫公司節省的成本,就可以負擔他十年的工資了。
優化的前置知識
從資源視角出發來對一臺服務器進行審視的話,CPU、內存、磁盤與網絡是后端服務最需要關注的四種資源類型。
對于計算密集型的程序來說,優化的主要精力會放在 CPU 上,要知道 CPU 基本的流水線概念,知道怎么樣在使用少的 CPU 資源的情況下,達到相同的計算目標。
對于 IO 密集型的程序(后端服務一般都是 IO 密集型)來說,優化可以是降低程序的服務延遲,也可以是提升系統整體的吞吐量。
IO 密集型應用主要與磁盤、內存、網絡打交道。因此我們需要知道一些基本的與磁盤、內存、網絡相關的基本數據與常見概念:
要了解內存的多級存儲結構:L1,L2,L3,主存。還要知道這些不同層級的存儲操作時的大致延遲:latency numbers every programmer should know[1]。
要知道基本的文件系統讀寫 syscall,批量 syscall,數據同步 syscall。
要熟悉項目中使用的網絡協議,至少要對 TCP, HTTP 有所了解。
優化越靠近應用層效果越好
Performance tuning is most effective when done closest to where the work is performed. For workloads driven by applications, this means within the application itself.
我們在應用層的邏輯優化能夠幫助應用提升幾十倍的性能,而最底層的優化則只能提升幾個百分點。
這個很好理解,我們可以看到一個 GTA Online 的新聞:rockstar thanks gta online player who fixed poor load times[2]。
簡單來說,GTA online 的游戲啟動過程讓玩家等待時間過于漫長,經過各種工具分析,發現一個 10M 的文件加載就需要幾十秒,用戶 diy 進行優化之后,將加載時間減少 70%,并分享出來:how I cut GTA Online loading times by 70%[3]。
這就是一個非常典型的案例,GTA 在商業上取得了巨大的成功,但不妨礙它局部的代碼是一坨屎。我們只要把這里的重復邏輯干掉,就可以完成三倍的優化效果。同樣的案例,如果我們去優化磁盤的讀寫速度,則可能收效甚微。
優化是與業務場景相關的
不同的業務場景優化的側重也是不同的。
對于大多數無狀態業務模塊來說,內存一般不是瓶頸,所以業務 API 的優化主要聚焦于延遲和吞吐。對于網關類的應用,因為有海量的連接,除了延遲和吞吐,內存占用可能就會成為一個關注的重點。對于存儲類應用,內存是個逃不掉的瓶頸點。
在關注一些性能優化文章時,我們也應特別留意作者的業務場景。場景的側重可能會讓某些人去選擇使用更為 hack 的手段進行優化,而 hack 往往也就意味著 bug。如果你選擇了少有人走過的路,那你要面臨的也是少有人會碰到的 bug。解決起來令人頭疼。
優化的工作流程
對于一個典型的 API 應用來說,優化工作基本遵從下面的工作流:
建立評估指標,例如固定 QPS 壓力下的延遲或內存占用,或模塊在滿足 SLA 前提下的極限 QPS
通過自研、開源壓測工具進行壓測,直到模塊無法滿足預設性能要求:如大量超時,QPS 不達預期,OOM
通過內置 profile 工具尋找性能瓶頸
本地 benchmark 證明優化效果
集成 patch 到業務模塊,回到 2
可以使用的工具
pprof
memory profiler
Go 內置的內存 profiler 可以讓我們對線上系統進行內存使用采樣,有四個相應的指標:
inuse_objects:當我們認為內存中的駐留對象過多時,就會關注該指標
inuse_space:當我們認為應用程序占據的 RSS 過大時,會關注該指標
alloc_objects:當應用曾經發生過歷史上的大量內存分配行為導致 CPU 或內存使用大幅上升時,可能關注該指標
alloc_space:當應用歷史上發生過內存使用大量上升時,會關注該指標
網關類應用因為海量連接的關系,會導致進程消耗大量內存,所以我們經常看到相關的優化文章,主要就是降低應用的 inuse_space。
而兩個對象數指標主要是為 GC 優化提供依據,當我們進行 GC 調優時,會同時關注應用分配的對象數、正在使用的對象數,以及 GC 的 CPU 占用的指標。
GC 的 CPU 占用情況可以由內置的 CPU profiler 得到。
cpu profiler
The builtin Go CPU profiler uses the setitimer(2) system call to ask the operating system to be sent a SIGPROF signal 100 times a second. Each signal stops the Go process and gets delivered to a random thread’s sigtrampgo() function. This function then proceeds to call sigprof() or sigprofNonGo() to record the thread’s current stack.
Go 語言內置的 CPU profiler 使用 setitimer 系統調用,操作系統會每秒 100 次向程序發送 SIGPROF 信號。在 Go 進程中會選擇隨機的線程執行 sigtrampgo 函數。該函數使用 sigprof 或 sigprofNonGo 來記錄線程當前的棧。
Since Go uses non-blocking I/O, Goroutines that wait on I/O are parked and not running on any threads. Therefore they end up being largely invisible to Go’s builtin CPU profiler.
Go 語言內置的 cpu profiler 是在性能領域比較常見的 On-CPU profiler,對于瓶頸主要在 CPU 消耗的應用,我們使用內置的 profiler 也就足夠了。
如果碰到的問題是應用的 CPU 使用不高,但接口的延遲卻很大,那么就需要用上 Off-CPU profiler,遺憾的是官方的 profiler 并未提供該功能,我們需要借助社區的 fgprof。
fgprof
fgprof is implemented as a background goroutine that wakes up 99 times per second and calls runtime.GoroutineProfile. This returns a list of all goroutines regardless of their current On/Off CPU scheduling status and their call stacks.
fgprof 是啟動了一個后臺的 goroutine,每秒啟動 99 次,調用 runtime.GoroutineProfile 來采集所有 gorooutine 的棧。
雖然看起來很美好:
func?GoroutineProfile(p?[]StackRecord)?(n?int,?ok?bool)?{.....stopTheWorld("profile")for?_,?gp1?:=?range?allgs?{......}if?n?<=?len(p)?{//?Save?current?goroutine.........systemstack(func()?{saveg(pc,?sp,?gp,?&r[0])})//?Save?other?goroutines.for?_,?gp1?:=?range?allgs?{if?isOK(gp1)?{.......saveg(^uintptr(0),?^uintptr(0),?gp1,?&r[0]).......}}}startTheWorld()return?n,?ok }但調用 GoroutineProfile 函數的開銷并不低,如果線上系統的 goroutine 上萬,每次采集 profile 都遍歷上萬個 goroutine 的成本實在是太高了。所以 fgprof 只適合在測試環境中使用。
trace
一般情況下我們是不需要使用 trace 來定位性能問題的,通過壓測 + profile 就可以解決大部分問題,除非我們的問題與 runtime 本身的問題相關。
比如 STW 時間比預想中長,超過百毫秒,向官方反饋問題時,才需要出具相關的 trace 文件。比如類似?long stw[4]?這樣的 issue。
采集 trace 對系統的性能影響還是比較大的,即使我們只是開啟 gctrace,把 gctrace 日志重定向到文件,對系統延遲也會有一定影響,因為 gctrace 的日志 print 是在 stw 期間來做的:gc trace 阻塞調度[5]。
perf
如果應用沒有開啟 pprof,在線上應急時,我們也可以臨時使用 perf:
perf demo微觀性能優化
編寫 library 時會關注關鍵函數的性能,這時可以脫離系統去探討性能優化,Go 語言的 test 子命令集成了相關的功能,只要我們按照約定來寫 Benchmark 前綴的測試函數,就可以實現函數級的基準測試。我們以常見的二維數組遍歷為例:
package?mainimport?"testing"var?x?=?make([][]int,?100)func?init()?{for?i?:=?0;?i?<?100;?i++?{x[i]?=?make([]int,?100)} }func?traverseVertical()?{for?i?:=?0;?i?<?100;?i++?{for?j?:=?0;?j?<?100;?j++?{x[j][i]?=?1}} }func?traverseHorizontal()?{for?i?:=?0;?i?<?100;?i++?{for?j?:=?0;?j?<?100;?j++?{x[i][j]?=?1}} }func?BenchmarkHorizontal(b?*testing.B)?{for?i?:=?0;?i?<?b.N;?i++?{traverseHorizontal()} }func?BenchmarkVertical(b?*testing.B)?{for?i?:=?0;?i?<?b.N;?i++?{traverseVertical()} }執行?go test -bench=.
BenchmarkHorizontal-12???????102368??????10916?ns/op BenchmarkVertical-12??????????66612??????18197?ns/op可見橫向遍歷數組要快得多,這提醒我們在寫代碼時要考慮 CPU 的 cache 設計及局部性原理,以使程序能夠在相同的邏輯下獲得更好的性能。
除了 CPU 優化,我們還經常會碰到要優化內存分配的場景。只要帶上 -benchmem 的 flag 就可以實現了。
舉個例子,形如下面這樣的代碼:
logStr?:=?"userid?:"?+?userID?+?";?orderid:"?+?orderID你覺得代碼寫的很難看,想要優化一下可讀性,就改成了下列代碼:
logStr?:=?fmt.Sprintf("userid:?%v;?orderid:?%v",?userID,?orderID)這樣的修改方式在某公司的系統中曾經導致了 p2 事故,上線后接口的超時俱增至 SLA 承諾以上。
我們簡單驗證就可以發現:
BenchmarkPrin-12???????7168467????????157?ns/op???????64?B/op????????3?allocs/op BenchmarkPlus -12?????43278558?????????26.7?ns/op????????0?B/op????????0?allocs/op使用 + 進行字符串拼接,不會在堆上產生額外對象。而使用 fmt 系列函數,則會造成局部對象逃逸到堆上,這里是高頻路徑上有大量逃逸,所以導致線上服務的 GC 壓力加重,大量接口超時。
出于謹慎考慮,修改高并發接口時,拿不準的盡量都應進行簡單的線下 benchmark 測試。
當然,我們不能指望靠寫一大堆 benchmark 幫我們發現系統的瓶頸。
實際工作中還是要使用前文提到的優化工作流來進行系統性能優化。也就是盡量從接口整體而非函數局部考慮去發現與解決瓶頸。
宏觀性能優化
接口類的服務,我們可以使用兩種方式對其進行壓測:
固定 QPS 壓測:在每次系統有大的特性發布時,都應進行固定 QPS 壓測,與歷史版本進行對比,需要關注的指標包括,相同 QPS 下的系統的 CPU 使用情況,內存占用情況(監控中的 RSS 值),goroutine 數,GC 觸發頻率和相關指標(是否有較長的 stw,mark 階段是否時間較長等),平均延遲,p99 延遲。
極限 QPS 壓測:極限 QPS 壓測一般只是為了 benchmark show,沒有太大意義。系統滿負荷時,基本 p99 已經超出正常用戶的忍受范圍了。
壓測過程中需要采集不同 QPS 下的 CPU profile,內存 profile,記錄 goroutine 數。與歷史情況進行 AB 對比。
Go 的 pprof 還提供了 --base 的 flag,能夠很直觀地幫我們發現不同版本之間的指標差異:用 pprof 比較內存使用差異[6]。
總之記住一點,接口的性能一定是通過壓測來進行優化的,而不是通過硬啃代碼找瓶頸點。關鍵路徑的簡單修改往往可以帶來巨大收益。如果只是啃代碼,很有可能將 1% 優化到 0%,優化了 100% 的局部性能,對接口整體影響微乎其微。
尋找性能瓶頸
在壓測時,我們通過以下步驟來逐漸提升接口的整體性能:
使用固定 QPS 壓測,以階梯形式逐漸增加壓測 QPS,如 1000 -> 每分鐘增加 1000 QPS
壓測過程中觀察系統的延遲是否異常
觀察系統的 CPU 使用情況
如果 CPU 使用率在達到一定值之后不再上升,反而引起了延遲的劇烈波動,這時大概率是發生了阻塞,進入 pprof 的 web 頁面,點擊 goroutine,查看 top 的 goroutine 數,這時應該有大量的 goroutine 阻塞在某處,比如 Semacquire
如果 CPU 上升較快,未達到預期吞吐就已經過了高水位,則可以重點考察 CPU 使用是否合理,在 CPU 高水位進行 profile 采樣,重點關注火焰圖中較寬的“平頂山”
重復上述步驟,直至系統性能達到或超越我們設置的性能目標。
一些優化案例
gc mark 占用過多 CPU
在 Go 語言中 gc mark 占用的 CPU 主要和運行時的對象數相關,也就是我們需要看 inuse_objects。
定時任務,或訪問流量不規律的應用,需要關注 alloc_objects。
優化主要是下面幾方面:
減少變量逃逸
盡量在棧上分配對象,關于逃逸的規則,可以查看 Go 編譯器代碼中的逃逸測試部分:
查看某個 package 內的逃逸情況,可以使用 build + 全路徑的方式,如:
go build -gcflags="-m -m" github.com/cch123/elasticsql
需要注意的是,逃逸分析的結果是會隨著版本變化的,所以去背誦網上逃逸相關的文章結論是沒有什么意義的。
使用 sync.Pool 復用堆上對象
sync.Pool 用出花兒的就是 fasthttp 了,可以看看我之前寫的這一篇:fasthttp 為什么快[7]。
最簡單的復用就是復用各種 struct,slice,在復用時 put 時,需要判斷 size 是否已經擴容過頭,小心因為 sync.Pool 中存了大量的巨型對象導致進程占用了大量內存。
調度占用過多 CPU
goroutine 頻繁創建與銷毀會給調度造成較大的負擔,如果我們發現 CPU 火焰圖中 schedule,findrunnable 占用了大量 CPU,那么可以考慮使用開源的 workerpool 來進行改進,比較典型的?fasthttp worker pool[8]。
如果客戶端與服務端之間使用的是短連接,那么我們可以使用長連接來減少連接創建的開銷,這里就包含了 goroutine 的創建與銷毀。
進程占用大量內存
當前大多數的業務后端服務是不太需要關注進程消耗的內存的。
我們經常看到做 Go 內存占用優化的是在網關(包括 mesh)、存儲系統這兩個場景。
對于網關類系統來說,Go 的內存占用主要是因為 Go 獨特的抽象模型造成的,這個很好理解:
海量的連接加上海量的 goroutine,使網關和 mesh 成為 Go OOM 的重災區。所以網關側的優化一般就是優化:
goroutine 占用的棧內存
read buffer 和 write buffer 占用的內存
很多項目都有相關的分享,這里就不再贅述了。
對于存儲類系統來說,內存占用方面的不少努力也是在優化各種?buffer,比如 dgraph 使用 cgo + jemalloc 來優化他們的產品內存占用[9]。
堆外內存不會在 Go 的 GC 系統里進行管轄,所以也不會影響到 Go 的 GC Heap Goal,所以不會因為分配大量對象造成 Go 的 Heap Goal 被推高,系統整體占用的 RSS 也被推高。
鎖沖突嚴重,導致吞吐量瓶頸
我在?幾個 Go 系統可能遇到的鎖問題[10]?中分享過實際的線上 case。
進行鎖優化的思路無非就一個“拆”和一個“縮”字:
拆:將鎖粒度進行拆分,比如全局鎖,我能不能把鎖粒度拆分為連接粒度的鎖;如果是連接粒度的鎖,那我能不能拆分為請求粒度的鎖;在 logger fd 或 net fd 上加的鎖不太好拆,那么我們增加一些客戶端,比如從 1-> 100,降低鎖的沖突是不是就可以了。
縮:縮小鎖的臨界區,業務允許的前提下,可以把 syscall 移到鎖外面;有時只是想要鎖 map 的讀寫邏輯,但是卻不小心鎖了連接讀寫的邏輯,或許簡單地用 sync.Map 來代替 map Lock,defer Unlock 就能簡單地縮小臨界區了。
timer 相關函數占用大量 CPU
同樣是在網關和海量連接的應用中較常見,優化手段:
使用時間輪/粗粒度的時間管理,精確到 ms 級一般就足夠了
升級到 Go 1.14+,享受官方的升級紅利
模擬真實工作負載
在前面的論述中,我們對問題進行了簡化。真實世界中的后端系統往往不只一個接口,壓測工具、平臺往往只支持單接口壓測。
公司的業務希望知道的是后端系統整體性能,即這些系統作為一個整體,在限定的資源條件下,能夠承載多少業務量(如并發創建訂單)而不崩潰。
雖然大家都在講微服務,但單一服務往往也不只有單一功能,如果一個系統有 10 個接口(已經算是很小的服務了),那么這個服務的真實負載是很難靠人肉去模擬的。
這也就是為什么互聯網公司普遍都需要做全鏈路壓測。像樣點的公司會定期進行全鏈路壓測演練,以便知曉隨著系統快速迭代變化,系統整體是否出現了嚴重的性能衰退。
通過真實的工作負載,我們才能發現真實的線上性能問題。講全鏈路壓測的文章也很多,本文就不再贅述了。
當前性能問題定位工具的局限性
本文中幾乎所有優化手段都是通過 Benchmark 和壓測來進行的,但真實世界的軟件還會有下列場景:
做 ToB 生意,我們的應用是部署在客戶側(比如一些數據庫產品),客戶說我們的應用會 OOM,但是我們很難拿到 OOM 的現場,不知道到底是哪些對象分配導致了 OOM
做大型平臺,平臺上有各種不同類型的用戶編寫代碼,升級用戶代碼后,線上出現各種 CPU 毛刺和 OOM 問題
這些問題在壓測中是發現不了的,需要有更為靈活的工具和更為強大的平臺,關于這些問題,可以看我的開源項目:mosn/holmes。
推薦閱讀:
cache contention[11]
every-programmer-should-know[12]
go-perfbook[13]
Systems Performance[14]
[1]
latency numbers every programmer should know:?https://colin-scott.github.io/personal_website/research/interactive_latency.html
[2]rockstar thanks gta online player who fixed poor load times:?https://www.pcgamer.com/rockstar-thanks-gta-online-player-who-fixed-poor-load-times-official-update-coming/
[3]how I cut GTA Online loading times by 70%:?https://nee.lv/2021/02/28/How-I-cut-GTA-Online-loading-times-by-70/
[4]long stw:?https://github.com/golang/go/issues/19378
[5]gc trace 阻塞調度:?http://xiaorui.cc/archives/6232
[6]用 pprof 比較內存使用差異:?https://colobu.com/2019/08/20/use-pprof-to-compare-go-memory-usage/
[7]fasthttp 為什么快:?https://xargin.com/why-fasthttp-is-fast-and-the-cost-of-it/
[8]fasthttp worker pool:?https://github.com/valyala/fasthttp/blob/master/workerpool.go#L19
[9]內存占用:?https://dgraph.io/blog/post/manual-memory-management-golang-jemalloc/
[10]幾個 Go 系統可能遇到的鎖問題:?https://xargin.com/lock-contention-in-go/
[11]cache contention:?https://web.eecs.umich.edu/~zmao/Papers/xu10mar.pdf
[12]every-programmer-should-know:?https://github.com/mtdvio/every-programmer-should-know
[13]go-perfbook:?https://github.com/dgryski/go-perfbook
[14]Systems Performance:?https://www.amazon.com/Systems-Performance-Brendan-Gregg/dp/0136820158/ref=sr_1_1?dchild=1&keywords=systems+performance&qid=1617092159&sr=8-1
總結
以上是生活随笔為你收集整理的Go 应用性能优化指北的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 『Go 语言底层原理剖析』文末送书
- 下一篇: 介绍一个欧神写的剪贴板多端同步神器