用golang開發系統軟件的一些細節

用golang開發系統軟件的一些細節

作者:張富春(ahfuzhang),轉載時請註明作者和引用鏈接,謝謝!


(本文的pdf版本)

眾所周知,golang非常適合用於開發後台應用,但也通常是各種各樣的應用層軟件。

開發系統軟件, 目前的首選還是C++, C, rust等語言。相比應用軟件,系統軟件需要更加穩定,更加高效。其維持自身運行的資源消耗要儘可能小,然後才可以把更多CPU、內存等資源用於業務處理上。簡單來說,系統軟件在CPU、內存、磁盤、帶寬等計算機資源的使用上要做到平衡且極致。

golang代碼經過寫法上的優化,是可以達到接近C的性能的。現在早已出現了很多用golang完成的系統軟件,例如很優秀的etcd, VictoriaMetrics等。VictoriaMetrics是Metric處理領域優秀的TSDB存儲系統, 在閱讀其源碼後,結合其他一些golang代碼優化的知識,我將golang開發系統軟件的知識總結如下:

golang的第一性能殺手:GC

個人認為GC掃描對象、及其GC引起的STW,是golang最大的性能殺手。本小節討論優化golang GC的各種技巧。

壓艙物ballast

下面一段神奇的代碼,能夠減少GC的頻率,從而提升程序性能:

func main(){
    ballast := make([]byte, 10*1024*1024*1024)
    runtime.KeepAlive(ballast)
    // do other things
}

其原理是擴大golang runtime的堆內存,使得實際分配的內存不容易超過堆內存的一定比例,進而減少GC的頻率。GC的頻率低了,STW的次數和時間也就更少,從而程序的性能也提升了。

具體的細節請參考文章:

堆外內存

眾所周知,golang中分配太多對象,會給GC造成很大壓力,從而影響程序性能。
那麼,我在golang runtime的堆以外分配內存,就可以繞過GC了。
可以通過mmap系統調用來使用堆外內存,具體請見:《Go Mmap 文件內存映射簡明教程
對於堆外內存的應用,在此推薦一個非常經典的golang組件:fastcache。具體請看這篇我對fastcache的分析文章:《介紹一個golang庫:fastcache 》。

也需要注意,這裡有個坑:
如果使用mmap去映射一個文件,則某個虛擬地址沒有對應的物理地址時,操作系統會產生缺頁終端,並轉到內核態執行,把磁盤的內容load到page cache。如果此時磁盤IO高,可能會長時間的阻塞……進一步地,導致了golang調度器的阻塞。

對象復用

對象太多會導致GC壓力,但又不可能不分配對象。因此對象復用就是減少分配消耗和減少GC的釋放消耗的好辦法。

下面分別通過不同的場景來討論如何復用對象。

海量微型對象的情況

假設有很多幾個位元組或者幾十個位元組的,數以萬計的對象。那麼最好不要一個個的new出來,會有兩個壞處:

  • 對象的管理會需要額外的內存,考慮內存對齊等因素又會造成額外的內存浪費。因此海量微型對象需要的總內存遠遠大於其自身真實使用的位元組數;
  • GC的壓力源於對象的個數,而不是總位元組數。海量微型對象必然增大GC壓力。

海量微型對象的影響,請看我曾經遇到過的這個問題:《【筆記】對golang的大量小對象的管理真的是無語了……

因此,海量微型對象的場景,這樣解決:

  • 分配一大塊數組,在數組中索引微型對象
  • 考慮fastcache這樣的組件,通過堆外內存繞過GC

當然,也有缺點:不好縮容。

大量小型對象的情況

對於大量的小型對象,sync.Pool是個好選擇。

推薦閱讀這篇文章:《Go sync.Pool 保姆級教程

sync.Pool不如上面的方法節省內存,但好處是可以縮容。

數量可控的中型對象

有的時候,我們可能需要一些定額數量的對象,並且對這些對象復用。

這時可以使用channel來做內存池。需要時從channel取出,用完放回channel。

slice的復用

fasthttp, VictoriaMetrics等組件的作者 valyala可謂是把slice復用這個技巧玩上了天,具體可以看fasthttp主頁上的Tricks with []byte buffers這部分介紹。

概要的總結起來就是:[]byte這樣的數組分配後,不要釋放,然後下次使用前,用slice=slice[:0]來清空,繼續使用其上次分配好的cap指向的空間。

這篇中文的總結也非常不錯:《fasthttp對性能的優化壓榨

valyala大神還寫了個 bytebufferpool,對[]byte重用的場景進行了封裝。

避免容器空間動態增長

對於slice和map而言,在預先可以預估其空間佔用的情況下,通過指定大小來減少容器操作期間引起的空間動態增長。特別是map,不但要拷貝數據,還要做rehash操作。

func xxx(){
  slice := make([]byte, 0, 1024)  // 有的時候,golangci-lint會提示未指定空間的情況
  m := make(map[int64]struct{}, 1000)
}

大神技巧:用slice代替map

此技巧源於valyala大神。

假設有一個很小的map需要插入和查詢,那麼把所有key-value順序追加到一個slice中,然後遍歷查找——其性能損耗可能比分配map帶來的GC消耗還要小。

  1. map變成slice,少了很多動態調整的空間
  2. 如果整個slice能夠塞進CPU cache line,則其遍歷可能比從內存load更加快速

具體請見這篇:《golang第三方庫fasthttp為什麼要使用slice而不是map來存儲header?

避免棧逃逸

golang中非常酷的一個語法特點就是沒有堆和棧的區別。編譯器會自動識別哪些對象該放在堆上,哪些對象該放在棧上。

func xxx() *ABigStruct{
  a := new(ABigStruct)  // 看起來是在堆上的對象
  var b ABigStruct      // 看起來是棧上的對象
  // do something
  // not return a   // a雖然是對象指針,但僅限於函數內使用,所以編譯器可能把a放在棧上
  return &b   // b超出了函數的作用域,編譯器會把b放在堆上。
}

valyala大神的經驗:先找出程序的hot path,然後在hot path上做棧逃逸的分析。盡量避免hot path上的堆內存分配,就能減輕GC壓力,提升性能。

fasthttp首頁上的介紹:

Fast HTTP package for Go. Tuned for high performance. Zero memory allocations in hot paths. Up to 10x faster than net/http

這篇文章介紹了偵測棧逃逸的方法:

驗證某個函數的變量是否發生逃逸的方法有兩個:

  • go run -gcflags “-m -l” (-m打印逃逸分析信息,-l禁止內聯編譯);例:
➜  testProj go run -gcflags "-m -l" internal/test1/main.go
# command-line-arguments
internal/test1/main.go:4:2: moved to heap: a
internal/test1/main.go:5:11: main make([]*int, 1) does not escape
  • go tool compile -S main.go | grep runtime.newobject(彙編代碼中搜runtime.newobject指令,該指令用於生成堆對象),例:
➜  testProj go tool compile -S internal/test1/main.go | grep newobject
        0x0028 00040 (internal/test1/main.go:4) CALL    runtime.newobject(SB)

——《golang 逃逸分析詳解

逃逸的場景,這篇文章有詳細的介紹:《go逃逸場景有哪些

CPU使用層面的優化

聲明使用多核

強烈建議在main.go的import中加入下面的代碼:

import _ "go.uber.org/automaxprocs"

特別是在容器環境運行的程序,要讓程序利用上所有的CPU核。

在k8s的有的版本(具體記不得了),會有一個噁心的問題:容器限制了程序只能使用比如2個核,但是runtime.GOMAXPROCS(0)代碼卻獲取到了所有的物理核。這時就導致進程的物理線程數接近邏輯CPU的個數,而不是容器限制的核數。從而,大量的CPU時間消耗在物理線程切換上。我曾經在騰訊雲上測試過,這種現象發生時,容器內單核性能只有物理機上單核性能的43%。

因此,發現性能問題時,可以通過ls /proc/$(pidof xxx)/tasks | wc來查看進程的物理線程數,如果這個數量遠遠高於從容器要求的核數,那麼在部署的時候建議加上環境變量來解決:export -p GOMAXPROC=2

golang不適合做計算密集型的工作

協程的調度,本質上就是一個一直在運行的循環,不斷的調用各個協程函數。然後協程函數在適當的時機保存上下文,放棄執行,把程序流程再轉回到主循環。

這裡有幾個要點:

  • 主循環來負責喚起每個協程函數,如果存在很多協程函數,輪一遍的周期很長。
  • 協程函數一定不能阻塞
  • 協程函數也不能阻塞太長的時間
  • 主循環喚起協程函數,以及協程函數切換回主循環是有開銷的。協程越多,開銷越大

因此,每個協程函數:在做IO操作的時候一定會切換回主循環,編譯器也會在協程函數內編譯進去可以切換上下文的代碼。新版的golang runtime還存在強制調度的機制,如果某個正在執行的協程不會退出,會強制進行切換。

由於存在協程切換的調度機制,golang是不適合做計算密集型的工作的。例如:音視頻編解碼,壓縮算法等。以zstd壓縮庫為例,golang版本的性能不如cgo的版本,即便cgo調用存在一定開銷。(我舉的例子比較極端,當需要讓golang的性能達到與C同一個級別時,標題的結論才成立。)

克制使用協程數

由runtime的調度器原理可知,協程數不是越多越好,過多的協程會佔用很多內存,且佔用調度器的資源。

如何克制的使用協程,請參考我的這篇文章:《VictoriaMetrics中的golang代碼優化方法

總結起來就是:

  • 最合適情況:核心的工作協程的數量,與可用的CPU核數相當。
  • 區分IO協程和工作協程,把繁重的計算任務交給工作協程處理。

協程優先級機制

關於優先級的案例,請參考我寫的這篇文章:《VictoriaMetrics中協程優先級的處理方式

當業務環境需要區分重要和不太重要的情況時,要通過一定的機制來協調協程的優先級。比如存貯系統中,寫入的優先級高於查詢,當資源受限時,要讓查詢的協程主動讓出調度。

不能讓調度器來均勻調度,不能創建更多的某類協程來獲得爭搶優勢

要深入理解golang的runtime,推薦閱讀yifhao同學的這篇文章:《萬字長文帶你深入淺出 Golang Runtime

並發層面

並發層面的問題是通用性的知識,與語言的特性並無直接的關係。本節列出golang中處理並發的慣用方法,已經對golang的並發處理很熟悉的同學可以跳過本小節。

關於鎖的使用,VictoriaMetrics這個開源組件中有很多經典的案例。也可以移步參考這篇文章的總結:《VictoriaMetrics中的golang代碼優化方法》(本人)

盡量不加鎖

以生產者-消費者模型為例:如果多個消費者之間可以做到互不關聯的處理業務邏輯,那麼應該盡量避免他們之間產生關聯。其業務處理過程中需要的各個對象,宜各自一份。

對數據加鎖,而不是對過程加鎖

擁有JAVA經驗的同學要特別小心這一點:JAVA中,在方法上加上個關鍵字就能實現互斥,但這時非常不好的設計方式。只需要對並發環境下產生衝突的變量加鎖即可,代碼及其不衝突的變量都是不必要加鎖的。

更進一步,如果存在多個衝突的變量,且在程序中不同的位置發生衝突,那麼可以對特定的一組變量定義一個特定的鎖,而不是使用一把統一的大鎖來進行互斥——盡量使用多個鎖,讓衝突進一步減小

讀多寫少的場景考慮讀寫鎖

某些讀寫的場景下,讀是可以並發的,而寫是互斥的。這種場景下,讀寫鎖是比互斥鎖更好的選擇。

原子操作

基礎的原子操作技巧

	var value int64 = 0

	atomic.AddInt64(&value, 1)           // 原子加
	atomic.AddInt64(&value, -1)          // 原子減

  var n uint64 = 1
  atomic.AddUint64(&n, 1)
  atomic.AddUint64(&n, ^uint64(0))   // 原子減1,無符號類型,使用反碼來減

	newValue := atomic.LoadInt64(&value) // 內存屏障,避免亂序執行,並且同步CPU cache和內存
	atomic.StoreInt64(&value, newValue)
	
	oldValue := atomic.SwapInt64(&value, 0) // 獲取當前值,並清零

原子操作就能搞定的並發場景,就不要再使用鎖。

自旋鎖

golang裏面哪來的自旋鎖?

其實我們可以自己寫一個:

var globalValue int64 = 0
func xxx(newValue int64){
	oldValue := atomic.LoadInt64(&globalValue)  // 相當於使用 memory barrier 指令,避免指令亂序
	for !atomic.CompareAndSwapInt64(&globalValue, oldValue, newValue) {  // 自旋等待,直到成功
		oldValue = atomic.LoadInt64(&globalValue)  // 失敗後,說明那一瞬間值被修改了。需要重新獲取最新的值
		// 其他數值操作的準備
	}  
}

以上是無鎖數據結構的經典套路。

atomic.Value: 用於並發場景下需要切換的對象

有的對象很基礎,可能需要頻繁訪問,且有時又會發生引用的切換。比如程序中的全局配置,很多地方都會引用,有時配置更新後,又會切換為最新的配置。

這種情況下,加鎖的成本太高,不加鎖又會帶來風險。因此,使用sync.Value來保存全局配置的數據是個不錯的選擇。

type Configs map[string]string

var globalConfig atomic.Value

func GetConfig() Configs {
	v, ok := globalConfig.Load().(Configs)
	if ok{
		return v
	}
	return map[string]string{}
}

func SetConfig(cfg Configs){
	globalConfig.Store(cfg)
}

並發容器

sync.Map

並發map設計得很精巧,用起來也很簡單。不過很可惜,sync.Map沒有那麼快,要避免將sync.Map用在程序的關鍵路徑上。

當然,我上述的觀點的區分點是:這是業務程序還是系統程序,如果是系統程序,盡量不要用。我實際使用中發現,sync.Map會導致CPU消耗高,且GC壓力增大。

RoaringBitmap(或類似實現)

對某些特定的場景,可以做到很少的鎖,很小的內存,比如存儲大量UINT64類型的集合這一點,RoaringBitmap是個非常好的選型。

VictoriaMetrics中有一個RoaringBitmap實現的組件,叫做uint64set。具體介紹請見:《vm中仿照RoaringBitmap的實現:uint64set》(本人)。

channel

channel當然也算一種並發容器,其本質上是無鎖隊列。

需要注意兩點:

  • 為了在多讀多寫條件下維持隊列的數據結構,通常通過CAS+自旋等待來操作關鍵數據。

​ 因此在大並發下,入隊出隊操作是串行化的,CAS失敗+自旋重試又會帶來cpu使用率升高。

​ 同樣的,channel沒有那麼快。要避免在劇烈競爭的環境下使用channel。

  • 通常會使用channel來做生產者-消費者模式的並髮結構。數據數據可以按照一定的規律分區,則可以考慮每個消費者對應一個channel,然後生產者根據數據的key來決定放到哪個channel。這樣本質上減緩了鎖的競爭。

其他

用sync.Once來懶惰初始化

有的運算結果,有一定概率用到,但是又不必每次都計算。這種情況下,使用sync.Once來懶惰初始化是個好辦法:

var once sync.Once
var globalXXX *XXX
func GetXXX() *XXX{
  once.Do(func(){
    globalXXX = getXXX()
  })
  return globalXXX
}

不安全代碼

string與[]byte的轉換

string與slice的結構本質上是一樣的,可以直接強制轉換:

import (
	"reflect"
	"unsafe"
)

// copy from prometheus source code

// NoAllocString convert []byte to string
func NoAllocString(bytes []byte) string {
	return *(*string)(unsafe.Pointer(&bytes))
}

// NoAllocBytes convert string to []byte
func NoAllocBytes(s string) []byte {
	strHeader := (*reflect.StringHeader)(unsafe.Pointer(&s))
	sliceHeader := reflect.SliceHeader{Data: strHeader.Data, Len: strHeader.Len, Cap: strHeader.Len}
	return *(*[]byte)(unsafe.Pointer(&sliceHeader))
}

上面的代碼可以避免string和[]byte在轉換的時候發生拷貝。

注意:轉換後的對象一定要立即使用,不要進一步引用到更深的層次中去。牢記這是不安全代碼,謹慎使用。

強制類型轉換

懂C的人,請繞過……

例如一個[]int64的數組要轉換為[]uint64的數組,使用個指針強制轉換就行了。

package main

import (
	"testing"
	"unsafe"
)

func TestConvert(t *testing.T) {
	int64Slice := make([]int64, 0, 100)
	int64Slice = append(int64Slice, 1, 2, 3)
	uint64Slice := *(*[]uint64)(unsafe.Pointer(&int64Slice))
	t.Logf("%+v", uint64Slice)
}

還有一種使用場景,要比較兩個大數組是否完全一樣:可以把數組強制轉換為[]byte,然後使用bytes.Compare()。相當於C中的memcmp()函數。

類似的操作還很多,推薦這篇文章:《深度解密Go語言之unsafe

模糊記得一個golang(或是rust)的原則:
普通開發者可以使用安全代碼來無顧慮的使用,高手把不安全代碼包裝成安全代碼來提供高性能組件。

數組越界檢查的開銷

相比C的數組訪問,為什麼golang可以做到很安全?

答案是編譯器加了兩條越界檢查的指令。每次通過下標訪問數組,就像這樣:

if index<0 || index>=len(slice){
  panic("out of index")
}
return slice[index]

這兩條越界檢查指令是有開銷的,請看我的測試:《golang中數組邊界檢查的開銷大約是1.87%~3.12%

所以,當某些位置使用類似查表法的時候,可以用不安全代碼繞過越界檢查:

slice := make([]byte, 1024*1024)
offset = 100
b := (*(*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + uintptr(offset))))

編譯/鏈接階段

使用盡量新的golang版本

理論上,每個新版的golang,都有一定編譯器優化的提升。

編譯參數

  • -X importpath.name=value 編譯期設置變量的值

  • -s disable symbol table 禁用符號表

  • -w disable DWARF generation 禁用調試信息

    ——《golang編譯參數ldflags

理論上說 -s -w加上後,代碼段的長度會減小,理論上會提高CPU代碼cache的利用率。(還未親自測試過)

使用runtime中的非導出函數

runtime中有的底層函數是彙編實現的,性能很高,但是不是export類型。

這時候可以用鏈接聲明來使用這些函數:

//go:noescape
//go:linkname memmove runtime.memmove
//goland:noinspection GoUnusedParameterfunc memmove(to unsafe.Pointer, from unsafe.Pointer, n uintptr)
func memmove(to, from unsafe.Pointer, n uintptr)

// 通過上面的聲明後,就可以在代碼中使用底層的memmove函數了。這個函數相當於c中的memcpy()

具體的細節請看這篇文章:《Go的2個黑魔法技巧》(騰訊 pedrogao)

函數內聯

golang的小函數默認就是內聯的。

可以通過函數前的注釋 //go:noinline來取消內聯,不過似乎沒有理由這麼做。

關於函數內聯的深層知識還是值得學習的,推薦這篇文章:《詳解Go內聯優化

可以關注文章中的這個內聯優化技巧:

可通過-gcflags="-l"選項全局禁用內聯,與一個-l禁用內聯相反,如果傳遞兩個或兩個以上的-l則會打開內聯,並啟用更激進的內聯策略。

泛型

golang 1.18正式發佈了泛型。

泛型可以讓之前基於反射的代碼變得更加簡單,很多type assert的代碼可以去掉;基於interface的運行期動態分發,也可以轉成編譯期決定。

由於對具體的類型產生了具體的代碼,理論上指令cache命中會提高,分支預測失敗會降低,

不過,對於有一定體量的golang團隊而言,泛型的引入要考慮的問題比較多:如何避免濫用,如何找到與之匹配的基礎庫?

在整個團隊的能力還沒準備好迎接泛型以前,使用工具生產代碼的產生式編程或許是更容易駕馭的方法。

API使用

反射

編譯期決定當然是好於運行期決定的。

我的建議是:

  • 能不用就不用,可以用下面的方法代替:
    • 泛型
    • 代碼生成(產生式編程)
  • 非得要用
    • 緩存反射的到的結果
有的場景下,標準庫提供的API不夠好。下面列舉一些自己認識的fast-xx組件。

fasttime組件,低精度的time.Now()

源碼請見://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/lib/fasttime/fasttime.go

原理就是創建協程每秒一次獲取 time.Now(),然後一秒以內取時間戳就只是訪問全局變量。

我測試過:性能比直接使用time.Now()快三倍左右。

fastrand,繞開rand庫的鎖

源碼請見://github.com/valyala/fastrand

超長字符串輸出的優化:quicktemplate

假設一次要輸出幾兆位元組的JSON字符串,如何優化性能?

VictoriaMetrics中的vm-select就遇到了這個問題,當一個大查詢需要返回很多的metrics數據的時候,其輸出的json的體積非常可觀。

如果把數據先放到一個大數組,再使用json.Marsharl,則一方面要頻繁申請釋放內存,另一方面會帶來內存使用量的劇烈抖動。vm-select的解決方式是使用quicktemplate庫——把json看成是字符串流的輸出。

具體代碼請看://github.com/valyala/quicktemplate

其他

總有很多人想把某個細分領域做到極致:

歡迎推薦更好好用的庫給我,謝謝。

其他高級主題

彙編/SIMD

一些涉及大量計算的熱點,可以採用彙編來優化。

golang使用plan 9彙編的語法,門檻還是比較高的。(經過半年斷斷續續的學習,我已經知道怎麼看注釋了)

所幸的是,懂C的人可以通過工具一步步把C代碼翻譯成plan 9彙編。

我自己做了個嘗試:《玩一玩golang彙編》(師從於這篇:《Go的2個黑魔法技巧》)

注意://github.com/Maratyszcza/PeachPy這個庫的代碼翻譯能力有限,我就發現有的代碼無法翻譯的情況。
且,只支持amd64平台下的翻譯。
如果大家遇到更好的彙編翻譯工具,請推薦給我。

使用彙編的最佳理由是SIMD指令集。

通常,一條指令只處理一條數據。而simd中,一條指令可以處理多條數據,當數據由多個128bit或者256bit構成的時候,使用SIMD指令可以取得較好的收益。

以strcmp()函數為例,傳統的寫法是逐個字符比較;而使用SIMD的話,可以把連續的16位元組或者32位元組(AVX2) load 到寄存器中,然後一次性比較。

這塊知識體系較為龐大,有興趣請自行搜索。

JIT技術

當前流行的OLAP數據庫clickhouse為何性能如此卓絕?其兩個核心技術點就是SIMD和JIT。

計算機技術中,即時編譯(英語:just-in-time compilation,縮寫為JIT;又譯及時編譯[1]實時編譯[2]),也稱為動態翻譯運行時編譯[3],是一種執行計算機代碼的方法,這種方法涉及在程序執行過程中(在執行期)而不是在執行之前進行編譯。[4]通常,這包括源代碼或更常見的位元組碼機器碼的轉換,然後直接執行。實現JIT編譯器的系統通常會不斷地分析正在執行的代碼,並確定代碼的某些部分,在這些部分中,編譯或重新編譯所獲得的加速將超過編譯該代碼的開銷。

JIT編譯是兩種傳統的機器代碼翻譯方法——提前編譯(英語:ahead-of-time compilation)(AOT)和解釋——的結合,它結合了兩者的優點和缺點。[4]大致來說,JIT編譯,以解釋器的開銷以及編譯和鏈接(解釋之外)的開銷,結合了編譯代碼的速度與解釋的靈活性。JIT編譯是動態編譯的一種形式,允許自適應優化(英語:adaptive optimization),比如動態重編譯和特定於微架構的加速[nb 1][5]——因此,在理論上,JIT編譯比靜態編譯能夠產生更快的執行速度。解釋和JIT編譯特別適合於動態編程語言,因為運行時系統可以處理後期綁定(英語:Late binding)的數據類型並實施安全保證。

——維基百科-即時編譯

JIT在JAVA圈耳熟能詳,通常指把位元組碼編譯為機器碼。但是golang沒有機器碼,所以golang中JIT並不用於位元組碼翻譯。

我覺得golang中的JIT可以這樣定義:為特定的功能點,動態生成特定的機器碼,以提高程序性能。

關於如何實現一個golang中的JIT,可以閱讀這篇:《使用 Go 語言寫一個即時編譯器(JIT)

像把大象放進冰箱里一樣總結一下:
1.把一些機器碼,放到一個數組中;(已經知道這些機器碼是幹啥的了)
2.使用mmap系統調用分配一塊內存,把內存設置為可執行,把上面的機器碼拷貝進去;(然後這片內存就成為了程序的代碼段)
3.定義一個函數指針指向mmap的內存;
4.執行函數。

也有golang庫提供動態生成機器碼的能力://github.com/goccy/go-jit。支持的指令有限,而且,猜測沒人願意這麼寫代碼。

(讀者一定在想這麼雞肋的東西介紹給我幹啥……)

golang的JIT的一個精彩應用是bytedance開源的sonic庫,從測試數據來看,應該是golang圈子裡最快的JSON解析庫。

怎麼做到的呢?

例如有這樣一個json:

{"a":123, "b":"abc"}

要把它解析到結構體:

type Data struct{
  A int64
  B string
}

一般來說,這個過程需要很多的判斷:源字段名是什麼?源字段什麼類型?目的字段名的反射對象在哪裡?目的對象的內存指針在哪裡?如果想要讓解析過程變快,最好是直接去掉這些判斷:遇到”a”, 在目的內存的偏移位置0,寫入8位元組整形值……

但是上面的做法又沒有通用性。如何直接的解析一個類型,又滿足通用性?JIT就是個好辦法。

針對類型Data,通過JIT產生一段最直接最高效的解析代碼,並且以後都通過這段代碼來解析。進而推演到每個類型都有專門的解析代碼。如此:針對特定結構,有特定的最優解析代碼。這樣的做法絕對是最優的,無法被別的方法超越。

就像ClickHouse一樣,相信未來會有越來越多的系統應用會添置JIT的能力。

CGO

關於cgo的性能,我認為主要是golang runtime中的物理線程(GMP模型中的M),與運行CGO的物理線程之間的通訊造成了遠高於直接函數調用的損耗。

內部顯示 如果是單純的 emtpy call,使用 cgo 耗時 55.9 ns/op, 純 go 耗時 0.29 ns/op,相差了 192 倍。

而實際上我們在使用 cgo 的時候不太可能進行空調用,一般來說會把性能影響較大,計算耗時較長的計算放在 cgo 中,如果是這種情況,每次條用額外 55.9 ns 的額外耗時應該是可以接受的訪問。

——CGO 和 CGO 性能之謎

golang為了保障runtime的協程調度不被阻塞,就需要所有被調度的協程函數都是不阻塞的。一旦加入CGO,就無法保障函數不阻塞了,因此只有額外開闢物理線程來執行CGO的函數。

這裡特別需要注意的一個坑是:
調用CGO的次數越多,時間越長,golang runtime開啟的物理線程就越多。
我曾在VictoriaNetrics中的vm-storage中發現,因為大量調用ZSTD壓縮庫,導致物理線程數是允許核數的10倍。
並且,在目前的golang版本中,這些物理線程沒有明確的銷毀機制。
遠多餘可用核數的物理線程,會導致大量CPU時間消耗在無意義的線程切換上。建議運營中加上runtime的metric上報,一旦發現物理線程過多,定期重啟來減少這種損耗。

其他的不高級主題

panic

不要用panic來反饋異常,不要用recover()來接收異常。

除了程序初始化的錯誤,不要在業務的任何地方使用panic。

對於錯誤,存在可預見的error,和不可預見的panic。絕大多數情況都要通過error來針對性的識別並管理錯誤。recover()僅僅用於維護框架穩定的非預期的錯誤捕獲。

目前還未測試過使用recover()是否會導致性能受損。
就我閱讀VictoriaMetrics的源碼看來,他們一個recover()都沒用——也就是說,他們自信的認為組件只會產生可預見的error。
如果我們處處都想着加上recover()來捕獲panic,是否意味着設計和測試上存在問題?

for循環避免拷貝

VictoriaMetrics中,幾乎所有的for循環都是一種風格:

var slice []int64
for i := range slice{
  item := &slice[i]
}

我想這就是為了避免for循環中的第二個變量產生拷貝。就如同寫C/C++的人,for循環中的循環變量要求寫成 ++i 而不是 i++。規範好寫法,避免在細節之處有不必要的損耗。

內存對齊

golang中聲明的每個變量默認都是位元組對齊的,這點很好。

需要額外注意兩點:

  • 一個大的struct數組,要注意位元組對齊帶來的不必要消耗。內存敏感的話,調整字段的順序以節約空間。
  • 一個大的struct數組,可以故意加些padding的字段,然item儘可能的按照cache line的長度對齊,可以提升訪問性能。

分支預測優化

這種優化點很難找。

關於分支預測的案例,可以看看我寫的這個分析文章:《用重複寫入代替if判斷,減少程序分支

golang標準庫中也有個很好的例子:《How does ConstantTimeByteEq work?

​ 一個簡單的if x==y,考慮了攻擊者對計算時間的猜測,考慮了分支預測的損耗。

其他的關於分支預測的優化技巧,這篇也不錯:《淺談利用分支預測提高效率

在日常的開發中,換個寫法是有可能會提高性能的:

switch variable{
  case "a":    // 根據業務特點,把最可能的分支放在最前。提高分支預測的成功率
     // do something
  case "b":
     // do something
}

OK,文章到這裡就結束了。

本人也才寫了兩年的golang,難免有很多錯誤之處,還請讀者不吝賜教,謝謝!

Tags: