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

2022-09-30 21:00:27

用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()

原始碼請見:https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/lib/fasttime/fasttime.go

原理就是建立協程每秒一次獲取 time.Now(),然後一秒以內取時間戳就只是存取全域性變數。

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

fastrand,繞開rand庫的鎖

原始碼請見:https://github.com/valyala/fastrand

超長字串輸出的優化:quicktemplate

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

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

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

具體程式碼請看:https://github.com/valyala/quicktemplate

其他

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

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

其他高階主題

組合/SIMD

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

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

所幸的是,懂C的人可以通過工具一步步把C程式碼翻譯成plan 9組合。

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

注意:https://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庫提供動態生成機器碼的能力:https://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,難免有很多錯誤之處,還請讀者不吝賜教,謝謝!