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 是不需要協程池的。當然,協程池還是有一些自己的優勢:

  1. 可以限制 goroutine 數量,避免無限制的增長。

  2. 減少棧擴容的次數。

  3. 頻繁創建 goroutine 的場景下,資源複用,節省內存。(需要一定規模。一般場景下,效果不太明顯。)

go 對 goroutine 有一定的複用能力。所以要根據場景選擇是否使用協程池,不恰當的場景不僅得不到收益,反而增加系統複雜性。

(四)反射

go 裏面的反射代碼可讀性本來就差,常見的優化手段進一步犧牲可讀性。而且後續馬上就有泛型的支持,所以若非必要,建議不要優化反射部分的代碼。

比較常見的優化手段有:

  1. 緩存反射結果,減少不必要的反射次數。例如 json-iterator

    (網址:https://github.com/json-iterator/go)。

  2. 直接使用 unsafe.Pointer 根據各個字段偏移賦值。

  3. 消除一般的 struct 反射內存消耗 go-reflect。

    (網址:https://github.com/goccy/go-reflect)

  4. 避免一些類型轉換,如 interface->[]byte。

(五)減小鎖消耗

併發場景下,對臨界區加鎖比較常見。帶來的性能隱患也必須重視。常見的優化手段有:

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(網址: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 團隊並沒有保證這些未導出的方法變量後續不會變更。

還有一些其他奇奇怪怪的用法:

  1. reflect2 包,創建 reflect.typelinks 的引用,用來讀取所有包中 struct 的定義。

  2. 創建 panic 的引用後,用一些 hook 函數重定向 panic,這樣你的程序 panic 後會走到你的自定義邏輯裏。

  3. runtime.main_inittask 保存了程序初始化時,init 函數的執行順序,之前版本沒有 init 過程 debug 功能時,可以用它來打印程序 init 調用鏈。最新版本已經有官方的調試方案:GODEBUG=inittracing=1 開啓 init。

  4. runtime.asmcgocall 是 cgo 代碼的實際調用入口。有時候我們可以直接用它來調用 cgo 代碼,避免 goroutine 切換, 具體會在 cgo 優化部分展開。

(二) log - 函數名稱行號的獲取

雖然很多高性能的日誌庫,默認都不開啓記錄行號。但實際業務場景中,我們還是覺得能打印最好。

在 runtime 中,函數行號和函數名稱的獲取分爲兩步:

  1. runtime 回溯 goroutine 棧,獲取上層調用方函數的的程序計數器(pc)。

  2. 根據 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 調用過程主要分爲以下幾步:

  1. entersyscall(): 保存上下文,標記當前 mincgo 獨佔 m,跳過垃圾回收。

  2. osPreemptExtEnter:標記異步搶佔,使異步搶佔邏輯失效。

  3. asmcgocall:真正的 cgo call 入口,切換到 g0 執行 c 代碼。

  4. 恢復之前的上下文,清理標記。

對於一些簡單的 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 中,有以下幾個問題:

  1. 需要維護大量的協程去處理讀寫事件。

  2. 對連接的狀態無感知,必須要等待 read 或者 write 返回錯誤才能知道對端狀態,其餘時間只能等待。

  3. 原生的 netpool 只維護一個 epoll,沒有充分發揮多核優勢。

基於此,有很多項目用 x/unix 擴展包實現了自己的基於 epoll 的網絡庫,比如潘神的 gnet(網址:https://github.com/panjf2000/gnet),還有字節跳動的 netpoll。

在我們的項目中,也有嘗試過使用。最終我們還是覺得基於標準庫的實現已經足夠。理由如下:

  1. 用戶態的 goroutine 優先級沒有 gonetpool 的調度優先級高。帶來的問題就是毛刺多了。近期字節跳動也開源了自己的 netpool,並且通過優化擴展包內 epoll 的使用方式來優化這個問題,具體效果未知。

  2. 效果不明顯,我們絕大部分業務的 QPS 主要受限於其他的 RPC 調用,或者 CPU 計算。收發包的優化效果很難體現。

  3. 增加了系統複雜性,雖然標準庫慢一點點,但是足夠穩定和簡單。

(五)包大小優化

我們 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 一般來說有三種方式:

  1. 手寫彙編。

  2. llvm。

  3. cgo(如果用 cgo 的方式來調用,會受限於 cgo 的性能,達不到加速的目的)。

目前比較流行的做法是 llvm:

  1. 用 c 來寫 simd 相關的函數,然後用 llvm 編譯成 c 彙編。

  2. 用工具把 c 彙編轉換成 go 的彙編格式,保存爲. s 文件。

  3. 在 go 中調用. s 裏的方法,最後用 go 編譯器編譯。

以下開源庫用到了 simd,可以參考:

  1. simdjson-go

    (網址:https://github.com/minio/simdjson-go)

  2. soni

    (網址:https://github.com/bytedance/sonic)

  3. sha256-simd

    (網址:https://github.com/minio/sha256-simd)

合理的使用 simd 可以充分發揮 cpu 特性,但是存在以下弊端:

  1. 難以維護,要麼需要懂彙編的大神,要麼需要引入第三方語言。

  2. 跨平臺支持不夠,需要對不同平臺彙編指令做適配。

  3. 彙編代碼很難調試,作爲使用方來講,完全黑盒。

(七)jit

go 中使用 jit 的方式可以參考 Writing a JIT compiler in Golang,

目前只有在字節跳動剛開源的 json 解析庫中發現了使用場景 sonic。

(網址:https://github.com/bytedance/sonic)

這種使用方式個人感覺在 go 中意義不大,僅供參考。

三、總結

過早的優化是萬惡之源,千萬不要爲了優化而優化:

  1. pprof 分析,競態分析,逃逸分析,這些基礎的手段是必須要學會的。

  2. 常規的優化技巧是比較實用的,他們往往能解決大部分的性能問題並且足夠安全。

  3. 在一些着重性能的基礎庫中,使用一些非常規的優化手段也是可以的,但必須要權衡利弊,不要過早放棄可讀性,兼容性和穩定性。

** 作者簡介**

趙柯

騰訊音樂後臺開發工程師

騰訊音樂後臺開發工程師,Go Contributor。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/_VGaV8ef65h9goxxfWejtQ