go 語言最全優化技巧總結
導語 | 本文總結了在維護 go 基礎庫過程中,用到或者見到的一些性能優化技巧,現將一些理解梳理撰寫成文,和大家探討。
一、常規手段
(一)sync.Pool
臨時對象池應該是對可讀性影響最小且優化效果顯著的手段。基本上,業內以高性能著稱的開源庫,都會使用到。
最典型的就是 fasthttp(網址:https://github.com/valyala/fasthttp/)了,它幾乎把所有的對象都用 sync.Pool 維護。
但這樣的複用不一定全是合理的。比如在 fasthttp 中,傳遞上下文相關信息的 RequestCtx 就是用 sync.Pool 維護的,這就導致了你不能把它傳遞給其他的 goroutine。
如果要在 fasthttp 中實現類似接受請求 -> 異步處理的邏輯, 必須得拷貝一份 RequestCtx 再傳遞。這對不熟悉 fasthttp 原理的使用者來講,很容易就踩坑了。
還有一種利用 sync.Pool 特性,來減少鎖競爭的優化手段,也非常巧妙。另外,在優化前要善用 go 逃逸檢查分析對象是否逃逸到堆上,防止負優化。
(二)string2bytes & bytes2string
這也是兩個比較常規的優化手段,核心還是複用對象,減少內存分配。
在 go 標準庫中也有類似的用法 gostringnocopy。
要注意 string2bytes 後,不能對其修改。
unsafe.Pointer 經常出現在各種優化方案中,使用時要非常小心。這類操作引發的異常,通常是不能 recover 的。
(三)協程池
絕大部分應用場景,go 是不需要協程池的。當然,協程池還是有一些自己的優勢:
-
可以限制 goroutine 數量,避免無限制的增長。
-
減少棧擴容的次數。
-
頻繁創建 goroutine 的場景下,資源複用,節省內存。(需要一定規模。一般場景下,效果不太明顯。)
go 對 goroutine 有一定的複用能力。所以要根據場景選擇是否使用協程池,不恰當的場景不僅得不到收益,反而增加系統複雜性。
(四)反射
go 裏面的反射代碼可讀性本來就差,常見的優化手段進一步犧牲可讀性。而且後續馬上就有泛型的支持,所以若非必要,建議不要優化反射部分的代碼。
比較常見的優化手段有:
-
緩存反射結果,減少不必要的反射次數。例如 json-iterator
(網址:https://github.com/json-iterator/go)。
-
直接使用 unsafe.Pointer 根據各個字段偏移賦值。
-
消除一般的 struct 反射內存消耗 go-reflect。
(網址:https://github.com/goccy/go-reflect)
-
避免一些類型轉換,如 interface->[]byte。
(五)減小鎖消耗
併發場景下,對臨界區加鎖比較常見。帶來的性能隱患也必須重視。常見的優化手段有:
-
減小鎖粒度:
go 標準庫當中,math.rand 就有這麼一處隱患。當我們直接使用 rand 庫生成隨機數時,實際上由全局的 globalRand 對象負責生成。globalRand 加鎖後生成隨機數,會導致我們在高頻使用隨機數的場景下效率低下。
-
atomic:
適當場景下,用原子操作代替互斥鎖也是一種經典的 lock-free 技巧。標準庫中 sync.map 針對讀操作的優化消除了 rwlock,是一個標準的案例。對它的介紹文章也比較多,不在贅述。
prometheus 裏的組件 histograms 直方圖也是一個非常巧妙的設計。一般的開源庫,比如 go-metrics(網址:https://github.com/rcrowley/go-metrics)是直接在這裏使用了互斥鎖。指標上報作爲一個高頻操作,在這裏加鎖,對系統性能影響可想而知。
參考 sync.map 裏冗餘 map 的做法,prometheus 把原來 histograms 的計數器也分爲兩個:cold 和 hot,還有一個 hotIdx 用來表示哪個計數器是 hot。prometheus 裏的組件 histograms 直方圖也是一個非常巧妙的設計。一般的開源庫,比如 go-metrics(網址:https://github.com/rcrowley/go-metrics)是直接在這裏使用了互斥鎖。指標上報作爲一個高頻操作,在這裏加鎖,對系統性能影響可想而知。
業務代碼上報指標時,用 atomic 原子操作對 hot 計數器累加向 prometheus 服務上報數據時,更改 hotIdx,把原來的熱數據變爲冷數據,作爲上報的數據。然後把現在冷數據裏的值,累加到熱數據裏,完成一次冷熱數據的更新替換。
還有一些狀態等待,結構體內存佈局的介紹,不再贅述。
二、另類手段
(一)golink
golink(網址:https://golang.org/cmd/compile/)在官方的文檔裏有介紹,使用格式:
//go:linkname FastRand runtime.fastrand
func FastRand() uint32
主要功能就是讓編譯器編譯的時候,把當前符號指向到目標符號。上面的函數 FastRand 被指向到 runtime.fastrand,runtime 包生成的也是僞隨機數,和 math 包不同的是,它的隨機數生成使用的上下文是來自當前 goroutine 的,所以它不用加鎖。正因如此,一些開源庫選擇直接使用 runtime 的隨機數生成函數。性能對比如下:
Benchmark_MathRand-12 84419976 13.98 ns/op
Benchmark_Runtime-12 505765551 2.158 ns/op
還有很多這樣的例子,比如我們要拿時間戳的話,可以標準庫中的 time.Now(),這個庫在會有兩次系統調用 runtime.walltime1 和 runtime.nanotime,分別獲取時間戳和程序運行時間。大部分場景下,我們只需要時間戳,這時候就可以直接使用 runtime.walltime1。性能對比如下:
Benchmark_Time-12 16323418 73.30 ns/op
Benchmark_Runtime-12 29912856 38.10 ns/op
同理,如果我們需要統計某個函數的耗時,也可以直接調用兩次 runtime.nanotime 然後相減,不用再調用兩次 time.Now。
//go:linkname nanotime1 runtime.nanotime1
func nanotime1() int64
func main() {
defer func( begin int64) {
cost := (nanotime1() - begin)/1000/1000
fmt.Printf("cost = %dms \n" ,cost)
}(nanotime1())
time.Sleep(time.Second)
}
運行結果:cost = 1000ms
系統調用在 go 裏面相對來講是比較重的。runtime 會切換到 g0 棧中去執行這部分代碼,time.Now 方法在 go<=1.16 中有兩次連續的系統調用。
不過,go 官方團隊的 lan 大佬已經發現並提交優化 pr。
優化後,這兩次系統調將會合並在一起,減少一次 g0 棧的切換。
linkname 爲我們提供了一種方法,可以直接調用 go 標準庫裏的未導出方法,可以讀取未導出變量。使用時要注意 go 版本更新後,是否有兼容問題,畢竟 go 團隊並沒有保證這些未導出的方法變量後續不會變更。
還有一些其他奇奇怪怪的用法:
-
reflect2 包,創建 reflect.typelinks 的引用,用來讀取所有包中 struct 的定義。
-
創建 panic 的引用後,用一些 hook 函數重定向 panic,這樣你的程序 panic 後會走到你的自定義邏輯裏。
-
runtime.main_inittask 保存了程序初始化時,init 函數的執行順序,之前版本沒有 init 過程 debug 功能時,可以用它來打印程序 init 調用鏈。最新版本已經有官方的調試方案:GODEBUG=inittracing=1 開啓 init。
-
runtime.asmcgocall 是 cgo 代碼的實際調用入口。有時候我們可以直接用它來調用 cgo 代碼,避免 goroutine 切換, 具體會在 cgo 優化部分展開。
(二) log - 函數名稱行號的獲取
雖然很多高性能的日誌庫,默認都不開啓記錄行號。但實際業務場景中,我們還是覺得能打印最好。
在 runtime 中,函數行號和函數名稱的獲取分爲兩步:
-
runtime 回溯 goroutine 棧,獲取上層調用方函數的的程序計數器(pc)。
-
根據 pc,找到對應的 funcInfo, 然後返回行號名稱。
經過 pprof 分析。第二步性能佔比最大,約 60%。針對第一步,我們經過多次嘗試,並沒有找到有效的辦法。但是第二步很明顯,我們不需要每次都調用 runtime 函數去查找 pc 和函數信息的,我們可以把第一次的結果緩存起來,後面直接使用。這樣,第二步約 60% 的消耗就可以去掉。
var(
m sync.Map
)
func Caller(skip int)(pc uintptr, file string, line int, ok bool){
rpc := [1]uintptr{}
n := runtime.Callers(skip+1, rpc[:])
if n < 1 {
return
}
var (
frame runtime.Frame
)
pc = rpc[0]
if item,ok:=m.Load(pc);ok{
frame = item.(runtime.Frame)
}else{
tmprpc := []uintptr{
pc,
}
frame, _ = runtime.CallersFrames(tmprpc).Next()
m.Store(pc,frame)
}
return frame.PC,frame.File,frame.Line,frame.PC!=0
}
壓測數據如下,優化後稍微減輕這部分的負擔,同時消除掉不必要的內存分配。
BenchmarkCaller-8 2765967 431.7 ns/op 0 B/op 0 allocs/op
BenchmarkRuntime-8 1000000 1085 ns/op 216 B/op 2 allocs/op
(三)cgo
cgo 的支持讓我們可以在 go 中調用 c++ 和 c 的代碼,但 cgo 的代碼在運行期間不受 go 調度器的管理,爲了防止 cgo 調用引起調度阻塞,cgo 調用會切換到 g0 棧執行,並獨佔 m。由於 runtime 設計時沒有考慮 m 的回收,所以運行時間久了之後,會發現有 cgo 代碼的程序,線程數都比較多。
用 go 的編譯器轉換包含 cgo 的代碼:
go tool cgo main.go
轉換後看代碼,cgo 調用實際上是由 runtime.cgocall 發起,而 runtime.cgocall 調用過程主要分爲以下幾步:
-
entersyscall(): 保存上下文,標記當前 mincgo 獨佔 m,跳過垃圾回收。
-
osPreemptExtEnter:標記異步搶佔,使異步搶佔邏輯失效。
-
asmcgocall:真正的 cgo call 入口,切換到 g0 執行 c 代碼。
-
恢復之前的上下文,清理標記。
對於一些簡單的 c 函數,我們可以直接用 asmcgocall 調用,避免來回切換:
package main
/*
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
struct args{
int p1,p2;
int r;
};
int add(struct args* arg) {
arg->r= arg->p1 + arg->p2;
return 100;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
//go:linkname asmcgocall runtime.asmcgocall
func asmcgocall(unsafe.Pointer, uintptr) int32
func main() {
arg := C.struct_args{}
arg.p1 = 100
arg.p2 = 200
//C.add(&arg)
asmcgocall(C.add,uintptr(unsafe.Pointer(&arg)))
fmt.Println(arg.r)
}
壓測數據如下:
BenchmarkCgo-12 16143393 73.01 ns/op 16 B/op 1 allocs/op
BenchmarkAsmCgoCall-12 119081407 9.505 ns/op 0 B/op 0 allocs/op
(四)epoll
runtime 對網絡 io,以及定時器的管理,會放到自己維護的一個 epoll 裏,具體可以參考 runtime/netpool。在一些高併發的網絡 io 中,有以下幾個問題:
-
需要維護大量的協程去處理讀寫事件。
-
對連接的狀態無感知,必須要等待 read 或者 write 返回錯誤才能知道對端狀態,其餘時間只能等待。
-
原生的 netpool 只維護一個 epoll,沒有充分發揮多核優勢。
基於此,有很多項目用 x/unix 擴展包實現了自己的基於 epoll 的網絡庫,比如潘神的 gnet(網址:https://github.com/panjf2000/gnet),還有字節跳動的 netpoll。
在我們的項目中,也有嘗試過使用。最終我們還是覺得基於標準庫的實現已經足夠。理由如下:
-
用戶態的 goroutine 優先級沒有 gonetpool 的調度優先級高。帶來的問題就是毛刺多了。近期字節跳動也開源了自己的 netpool,並且通過優化擴展包內 epoll 的使用方式來優化這個問題,具體效果未知。
-
效果不明顯,我們絕大部分業務的 QPS 主要受限於其他的 RPC 調用,或者 CPU 計算。收發包的優化效果很難體現。
-
增加了系統複雜性,雖然標準庫慢一點點,但是足夠穩定和簡單。
(五)包大小優化
我們 CI 是用藍盾流水線實現的,有一次業務反饋說藍盾編譯的二進制會比自己開發機編譯的體積大 50% 左右。對比了操作系統和 go 版本都是一樣的, tlinux2.2 golang1.15。我們在用 linux 命令 size—A 對兩個文件各個 section 做對比時,發現了 debug 相關的 section size 明顯不一致,而且 section 的名稱也不一樣:
size -A test-30MB
section size addr
.interp 28 4194928
.note.ABI-tag 32 4194956
... ... ... ...
.zdebug_aranges 1565 0
.zdebug_pubnames 56185 0
.zdebug_info 2506085 0
.zdebug_abbrev 13448 0
.zdebug_line 1250753 0
.zdebug_frame 298110 0
.zdebug_str 40806 0
.zdebug_loc 1199790 0
.zdebug_pubtypes 151567 0
.zdebug_ranges 371590 0
.debug_gdb_scripts 42 0
Total 93653020
size -A test-50MB
section size addr
.interp 28 4194928
.note.ABI-tag 32 4194956
.note.go.buildid 100 4194988
... ... ...
.debug_aranges 6272 0
.debug_pubnames 289151 0
.debug_info 8527395 0
.debug_abbrev 73457 0
.debug_line 4329334 0
.debug_frame 1235304 0
.debug_str 336499 0
.debug_loc 8018952 0
.debug_pubtypes 1072157 0
.debug_ranges 2256576 0
.debug_gdb_scripts 62 0
Total 113920274
通過查找 debug 和 zdebug 的區別瞭解到,zdebug 是對 debug 段做了 zip 壓縮,所以壓縮後包體積會更小。查看 go 的源碼(網址:https://github.com/golang/go/blob/master/src/cmd/link/internal/ld/dwarf.go#L2210),發現鏈接器默認已經對 debug 段做了 zip 壓縮。
看來,未壓縮的 debug 段不是 go 自己乾的。我們很容易就猜到,由於代碼中引入了 cgo,可能是 c++ 的鏈接器沒有壓縮導致的。
代碼引入cgo後,go代碼由go編譯器編譯,c代碼由g++編譯。
後續由ld鏈接成可執行文件
所以包含cgo的代碼在跨平臺編譯時,需要更改對應平臺的c代碼編譯器,鏈接器。
具體過程可以翻閱go編譯過程相關資料,不再贅述
再次尋找原因,我們猜測可能跟 tlinux2.2 支持 go 1.16 有關,之前我們發現升級 go 版本之後,在開發機上無法編譯。最後發現是因爲 go1.16 優化了一部分編譯指令,導致我們的 ld 版本太低不支持。所以我們用 yum install -y binutils 升級了 ld 的版本。果然,在翻閱了 ld 的文檔之後,我們確認了 tlinux2.2 自帶的 ld 不支持 --compress-debug-sections=zlib-gnu 這個指令,升級後 ld 才支持。
**總結:**在包含 cgo 的代碼編譯時,將 ld 升級到 2.27 版本,編譯後的體積可以減少約 50%。
(六)simd
首先,go 鏈接器支持 simd 指令,但 go 編譯器不支持 simd 指令的生成。
所以在 go 中使用 simd 一般來說有三種方式:
-
手寫彙編。
-
llvm。
-
cgo(如果用 cgo 的方式來調用,會受限於 cgo 的性能,達不到加速的目的)。
目前比較流行的做法是 llvm:
-
用 c 來寫 simd 相關的函數,然後用 llvm 編譯成 c 彙編。
-
用工具把 c 彙編轉換成 go 的彙編格式,保存爲. s 文件。
-
在 go 中調用. s 裏的方法,最後用 go 編譯器編譯。
以下開源庫用到了 simd,可以參考:
-
simdjson-go
(網址:https://github.com/minio/simdjson-go)
-
soni
(網址:https://github.com/bytedance/sonic)
-
sha256-simd
(網址:https://github.com/minio/sha256-simd)
合理的使用 simd 可以充分發揮 cpu 特性,但是存在以下弊端:
-
難以維護,要麼需要懂彙編的大神,要麼需要引入第三方語言。
-
跨平臺支持不夠,需要對不同平臺彙編指令做適配。
-
彙編代碼很難調試,作爲使用方來講,完全黑盒。
(七)jit
go 中使用 jit 的方式可以參考 Writing a JIT compiler in Golang,
目前只有在字節跳動剛開源的 json 解析庫中發現了使用場景 sonic。
(網址:https://github.com/bytedance/sonic)
這種使用方式個人感覺在 go 中意義不大,僅供參考。
三、總結
過早的優化是萬惡之源,千萬不要爲了優化而優化:
-
pprof 分析,競態分析,逃逸分析,這些基礎的手段是必須要學會的。
-
常規的優化技巧是比較實用的,他們往往能解決大部分的性能問題並且足夠安全。
-
在一些着重性能的基礎庫中,使用一些非常規的優化手段也是可以的,但必須要權衡利弊,不要過早放棄可讀性,兼容性和穩定性。
** 作者簡介**
趙柯
騰訊音樂後臺開發工程師
騰訊音樂後臺開發工程師,Go Contributor。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/_VGaV8ef65h9goxxfWejtQ