go 優化技巧


go語言最全優化技巧總結,值得收藏! https://mp.weixin.qq.com/s/_VGaV8ef65h9goxxfWejtQ

趙柯  雲加社區  2021-08-23

圖片

 

導語 | 本文總結了在維護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。

 

 

(五)減小鎖消耗

 

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

 

  • 減小鎖粒度:

    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.fastrandfunc FastRand() uint32

 

主要功能就是讓編譯器編譯的時候,把當前符號指向到目標符號。上面的函數FastRand被指向到runtime.fastrand,runtime包生成的也是偽隨機數,和math包不同的是,它的隨機數生成使用的上下文是來自當前goroutine的,所以它不用加鎖。正因如此,一些開源庫選擇直接使用runtime的隨機數生成函數。性能對比如下:

 

Benchmark_MathRand-12 84419976 13.98 ns/opBenchmark_Runtime-12 505765551 2.158 ns/op

 

還有很多這樣的例子,比如我們要拿時間戳的話,可以標准庫中的time.Now(),這個庫在會有兩次系統調用runtime.walltime1和runtime.nanotime,分別獲取時間戳和程序運行時間。大部分場景下,我們只需要時間戳,這時候就可以直接使用runtime.walltime1。性能對比如下:

 

Benchmark_Time-12 16323418 73.30 ns/opBenchmark_Runtime-12 29912856 38.10 ns/op

 

同理,如果我們需要統計某個函數的耗時,也可以直接調用兩次runtime.nanotime然后相減,不用再調用兩次time.Now。

 

//go:linkname nanotime1 runtime.nanotime1func nanotime1() int64func 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/opBenchmarkRuntime-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.asmcgocallfunc 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-30MBsection 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 0Total 93653020
size -A test-50MBsection 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 0Total 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. 在一些着重性能的基礎庫中,使用一些非常規的優化手段也是可以的,但必須要權衡利弊,不要過早放棄可讀性,兼容性和穩定性。

 

 

 

 

趙柯  雲加社區  2021-08-23

圖片

 

導語 | 本文總結了在維護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。

 

 

(五)減小鎖消耗

 

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

 

  • 減小鎖粒度:

    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.fastrandfunc FastRand() uint32

 

主要功能就是讓編譯器編譯的時候,把當前符號指向到目標符號。上面的函數FastRand被指向到runtime.fastrand,runtime包生成的也是偽隨機數,和math包不同的是,它的隨機數生成使用的上下文是來自當前goroutine的,所以它不用加鎖。正因如此,一些開源庫選擇直接使用runtime的隨機數生成函數。性能對比如下:

 

Benchmark_MathRand-12 84419976 13.98 ns/opBenchmark_Runtime-12 505765551 2.158 ns/op

 

還有很多這樣的例子,比如我們要拿時間戳的話,可以標准庫中的time.Now(),這個庫在會有兩次系統調用runtime.walltime1和runtime.nanotime,分別獲取時間戳和程序運行時間。大部分場景下,我們只需要時間戳,這時候就可以直接使用runtime.walltime1。性能對比如下:

 

Benchmark_Time-12 16323418 73.30 ns/opBenchmark_Runtime-12 29912856 38.10 ns/op

 

同理,如果我們需要統計某個函數的耗時,也可以直接調用兩次runtime.nanotime然后相減,不用再調用兩次time.Now。

 

//go:linkname nanotime1 runtime.nanotime1func nanotime1() int64func 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/opBenchmarkRuntime-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.asmcgocallfunc 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-30MBsection 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 0Total 93653020
size -A test-50MBsection 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 0Total 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. 在一些着重性能的基礎庫中,使用一些非常規的優化手段也是可以的,但必須要權衡利弊,不要過早放棄可讀性,兼容性和穩定性。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM