Golang 高性能編程實踐

作者:coly

go 中高性能編程是一個經久不衰的話題,本文嘗試從實踐及源碼層面對 go 的高性能編程進行解析。

1. 爲什麼要進行性能優化

服務上線前,爲什麼要進行壓測和性能的優化?

一個例子,content-service 在壓測的時候發現過一個問題: 舊邏輯爲了簡化編碼,在進行協議轉換前,會對某些字段做一個 DeepCopy,因爲轉換過程需要原始數據,但我們完全可以通過一些處理邏輯的調整,比如調整先後順序等移除 DeepCopy。優化前後性能對比如下:

GhwIuv

性能有 7 倍左右提升,改動很小,但折算到成本上的收益是巨大的。

在性能優化上任何微小的投入,都可能會帶來巨大的收益

那麼,如何對 go 程序的性能進行度量和分析?

2. 度量和分析工具

2.1 Benchmark

2.1.1 Benchmark 示例
func BenchmarkConvertReflect(b *testing.B) {
    var v interface{} = int32(64)
    for i:=0;i<b.N;i++{
        f := reflect.ValueOf(v).Int()
        if f != int64(64){
            b.Error("errror")
        }
    }
}

函數固定以 Benchmark 開頭,其位於_test.go 文件中,入參爲 testing.B 業務邏輯應放在 for 循環中,因爲 b.N 會依次取值 1, 2, 3, 5, 10, 20, 30, 50,100.........,直至執行時間超過 1s

可通過go test --bench命令執行 benchmark,其結果如下:

➜  gotest666 go test --bench='BenchmarkConvertReflect' -run=none
goos: darwin
goarch: amd64
pkg: gotest666
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkConvertReflect-12      520200014            2.291 ns/op

--bench='BenchmarkConvertReflect', 要執行的 benchmark。需注意: 該參數支持模糊匹配,如 --bench='Get|Set' ,支持./...-run=none,只進行 Benchmark,不執行單測

BenchmarkConvertReflect, 在 1s 內執行了 520200014 次,每次約 2.291ns

2.1.2 高級用法
➜  gotest666 go test --bench='Convert' -run=none -benchtime=2s -count=3 -benchmem -cpu='2,4' -cpuprofile=cpu.profile -memprofile=mem.profile -trace=xxx -gcflags=all=-l
goos: darwin
goarch: amd64
pkg: gotest666
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkConvertReflect-2       1000000000           2.286 ns/op           0 B/op          0 allocs/op
BenchmarkConvertReflect-2       1000000000           2.302 ns/op           0 B/op          0 allocs/op
BenchmarkConvertReflect-2       1000000000           2.239 ns/op           0 B/op          0 allocs/op
BenchmarkConvertReflect-4       1000000000           2.244 ns/op           0 B/op          0 allocs/op
BenchmarkConvertReflect-4       1000000000           2.236 ns/op           0 B/op          0 allocs/op
BenchmarkConvertReflect-4       1000000000           2.247 ns/op           0 B/op          0 allocs/op
PASS

-benchtime=2s', 依次遞增 b.N 直至運行時間超過 2s-count=3,執行 3 輪-benchmem,b.ReportAllocs,展示堆分配信息,0 B/op, 0 allos/op 分別代表每次分配了多少空間,每個 op 有多少次空間分配-cpu='2,4',依次在 2 核、4 核下進行測試-cpuprofile=xxxx -memprofile=xxx -trace=trace.out,benmark 時生成 profile、trace 文件-gcflags=all=-l,停止編譯器的內聯優化b.ResetTimer, b.StartTimer/b.StopItmer,重置定時器b.SetParallelism、b.RunParallel, 併發執行,設置併發的協程數

目前對 go 性能進行分析的主要工具包含: profile、trace,以下是對二者的介紹

2.2 profile

目前 go 中 profile 包括: cpu、heap、mutex、goroutine。要在 go 中啓用 profile 主要有以下幾種方式:

  1. 運行時函數,如 pprof.StartCPUProfile、pprof.WriteHeapProfile 等

  2. 導入 net/http/pprof 包

  3. go test 中使用 - cpuprofile、-memprofile

go 中提供了 pprof 工具對 profile 進行解析,以 cpuprofile 爲例,如下:

go tool pprofile cpu.profile
(pprof) top 15
Showing nodes accounting for 14680ms, 99.46% of 14760ms total
Dropped 30 nodes (cum <= 73.80ms)
      flat  flat%   sum%        cum   cum%
    2900ms 19.65% 19.65%     4590ms 31.10%  reflect.unpackEface (inline)
    2540ms 17.21% 36.86%    13280ms 89.97%  gotest666.BenchmarkConvertReflect
    1680ms 11.38% 48.24%     1680ms 11.38%  reflect.(*rtype).Kind (inline)

(pprof) list gotest666.BenchmarkConvertReflect
Total: 14.76s
ROUTINE ======================== gotest666.BenchmarkConvertReflect in /Users/zhangyuxin/go/src/gotest666/a_test.go
     2.54s     13.28s (flat, cum) 89.97% of Total
         .          .      8:func BenchmarkConvertReflect(b *testing.B) {
         .          .      9:   var v interface{} = int32(64)
     1.30s      1.41s     10:   for i:=0;i<b.N;i++{
         .     10.63s     11:       f := reflect.ValueOf(v).Int()
     1.24s      1.24s     12:       if f != int64(64){
         .          .     13:           b.Error("errror")
         .          .     14:       }
         .          .     15:   }
         .          .     16:}
         .          .     17:
(pprof)

flat,cum 分別代表了當前函數、當前函數調用函數的統計信息top、list、tree是用的最多的命令

go 也提供了 web 界面用以對各種調用進行圖像化展示,可以通過 - http 打開內置的 http 服務,該服務可以展示包含調用圖、火焰圖等信息

go tool pprof -http=":8081" cpu.profile

對於調用圖,邊框、字體的顏色越深,代表消耗資源越多。實線代表直接調用,虛線代表非直接調用(中間還有其他調用) 火焰圖代表了調用層級,函數調用棧越長,火焰越高。同一層級,框越長、顏色越深佔用資源越多 profile 是通過採樣實現,存在精度問題、且會對性能有影響 (比如 go routine 的 profile 採樣會導致 STW)

此外,目前 123 中已經有 profile 相關的插件,具體可搜索:查看火焰圖、GoMemProfile

2.3 trace

profile 可以通過採樣,確定系統運行中的熱點,但其基於採樣的處理也有精度等問題。 因此,go 提供了 trace 工具,其基於事件的統計爲解決問題提供了更詳細的數據,此外 go trace 還把 P、G、Heap 等相關信息聚合在一起按照時間進行展示,如下圖:

go 中啓用 trace,可以通過以下方式:

  1. 通過 runtime/trace 包中相應函數,主要是 trace.Start、trace.Stop

  2. 通過導入 net/http/pprof

  3. 通過 go test 中 trace 參數

以 runtime/trace 爲例,如下:

import (
    "os"
    "runtime/trace"
)

func main() {
    f, _ := os.Create("trace.out")
    trace.Start(f)
    defer trace.Stop()

    ch := make(chan string)
    go func() {
        ch <- "EDDYCJY"
    }()

    <-ch
}

go tool trace trace.out,會打開頁面,結果包含如下信息:

View trace // 按照時間查看thread、goroutine分析、heap等相關信息
Goroutine analysis // goroutine相關分析
Syscall blocking profile // syscall 相關
Scheduler latency profile // 調度相關
........

實際中經常先通過 Goroutine analysis、Scheduler latency profile 等查找可能的問題點,再通過 View trace 進行全面分析。

3. 常用類型和結構

3.1 interface、reflect

通常 go 中較多的 interface、reflect 會對性能有一定影響,interface、reflect 爲什麼會對性能有影響?

3.1.1 interface 和 eface

go 中 interface 包含 2 種,eface、iface。eface 用於標識不含方法的 interface,iface 用於標識帶方法的 interface,其相關機制不在本文介紹範圍。

eface 的定義位於 runtime2.gotype.go,其定義如下:

type eface struct {
    _type *_type                 // 類型信息
    data  unsafe.Pointer // 數據
}

type _type struct {
    size       uintptr  // 大小信息
    .......
    hash       uint32     // 類型信息
    tflag      tflag
    align      uint8        // 對齊信息
    .......
}

因爲同時包含類型、數據,go 中所有類型都可以轉換爲 interface。go 中爲 interface 賦值的過程,即爲 eface 變量生成的過程,通過彙編可以發現,其主要通過 convT * 完成位於 iface.go,具體分發邏輯位於 convert.go。 以指針類型爲例,其轉換邏輯如下:

// dataWordFuncName returns the name of the function used to convert a value of type "from"
// to the data word of an interface.
func dataWordFuncName(from *types.Type) (fnname string, argType *types.Type, needsaddr bool) {
    .............
    switch {
    case from.Size() == 2 && uint8(from.Alignment()) == 2:
        return "convT16", types.Types[types.TUINT16], false
    case from.Size() == 4 && uint8(from.Alignment()) == 4 && !from.HasPointers():
        return "convT32", types.Types[types.TUINT32], false
    case from.Size() == 8 && uint8(from.Alignment()) == uint8(types.Types[types.TUINT64].Alignment()) && !from.HasPointers():
        return "convT64", types.Types[types.TUINT64], false
    }

    .............
    if from.HasPointers() {
        return "convT", types.Types[types.TUNSAFEPTR], true
    }
    return "convTnoptr", types.Types[types.TUNSAFEPTR], true
}

// convT converts a value of type t, which is pointed to by v, to a pointer that can
// be used as the second word of an interface value.
func convT(t *_type, elem unsafe.Pointer) (e eface) {
    .....
    x := mallocgc(t.size, t, true)  // 空間的分配
    typedmemmove(t, x, elem)                // memove
    e._type = t
    e.data = x
    return
}

很多對 interface 類型的賦值 (並非所有),都會導致空間的分配和拷貝,這也是 Interface 函數爲什麼可能會導致逃逸的原因 go 這麼做的主要原因:逃逸的分析位於編譯階段,對於不確定的類型在堆上分配最爲合適。

3.1.2 Reflect.Value

go 中 reflect 機制涉及到 2 個類型,reflect.Type 和 reflect.Value,reflect.Type 是一個 Interface,其不在本章介紹範圍內。

reflect.Value 定義位於 value.gotype.go,其定義與 eface 類似:

type Value struct {
    typ *rtype  // type._type
    ptr unsafe.Pointer
    flag
}

// rtype must be kept in sync with ../runtime/type.go:/^type._type.
type rtype struct {
    ....
}
相似的實現,即爲interface和reflect可以相互轉換的原因

reflect.Value 是通過 reflect.ValueOf 獲得,reflect.ValueOf 也會導致數據逃逸 (interface 接口),其定義位於 value.go 中,如下:

func ValueOf(i interface{}) Value {
    if i == nil {
        return Value{}
    }
    // TODO: Maybe allow contents of a Value to live on the stack.
    // For now we make the contents always escape to the heap.
  // .....
    escapes(i) // 此處沒有逃逸
    return unpackEface(i) // 轉換eface爲emtpyInterface
}

// go1.18中,dummy.b沒有賦值操作
func escapes(x any) {
    if dummy.b {
        dummy.x = x
    }
}

reflect.ValueOf 仍然會導致逃逸,但其逃逸還是由 interface 的入參導致

一個簡單的例子:

func main() {
    var x = "xxxx"
    _ = reflect.ValueOf(x)
}

結果如下:

➜  gotest666 go build -gcflags=-m main.go
# command-line-arguments
./main.go:26:21: inlining call to reflect.ValueOf
./main.go:26:21: inlining call to reflect.escapes
./main.go:26:21: inlining call to reflect.unpackEface
./main.go:26:21: inlining call to reflect.(*rtype).Kind
./main.go:26:21: inlining call to reflect.ifaceIndir
./main.go:26:22: x escapes to heap

需要注意,x會逃逸到堆上

3.1.3 類型的選擇:interface、強類型如何選

爲降低不必要的空間分配、拷貝,建議只在必要情況下使用 interface、reflect,針對函數定義,測試如下:

type testStruct struct {
    Data [4096]byte
}

func StrongType(t testStruct) {
    t.Data[0] = 1
}

func InterfaceType(ti interface{}) {
    ts := ti.(testStruct)
    ts.Data[0] = 1
}

func BenchmarkTypeStrong(b *testing.B) {
    t := testStruct{}
    t.Data[0] = 2
    for i := 0; i < b.N; i++ {
        StrongType(t)
    }
}

func BenchmarkTypeInterface(b *testing.B) {
    t := testStruct{}
    t.Data[0] = 2
    for i := 0; i < b.N; i++ {
        InterfaceType(t)
    }
}
➜  test go test --bench='Type' -run=none -benchmem
goos: darwin
goarch: amd64
pkg: gotest666/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkTypeStrong-12          1000000000           0.2550 ns/op          0 B/op          0 allocs/op
BenchmarkTypeInterface-12        1722150           709.0 ns/op      4096 B/op          1 allocs/op
PASS
ok      gotest666/test  2.714s
需要注意,當入參參數佔用空間不大時(比如基礎類型),二者性能對比並不十分明顯

強類型函數調用性能遠優於基於 interface 的調用,優化後 content-service 只使用了少量的 interface。

目前一些常用的基於 interface(可能會導致逃逸) 的函數:

PhyOEb

3.1.4 類型轉換: 強轉 vs 斷言 vs reflect

目前 go 中數據類型轉換,存在以下幾種方式:

  1. 強轉,如 int 轉 int64,可用 int64(intData)。強轉是對底層數據進行語意上的重新解釋

  2. 斷言 (interface),根據已有信息,對變量類型進行斷言,如 interfaceData.(int64),會利用 eface.type 中相關信息,對類型進行校驗、轉換。

  3. reflect 相關函數,如 reflect.Valueof(intData).Int(),其中 intData 可以爲各種 int 相關類型,具有較大的靈活性。

針對此的測試如下:

type testStruct struct {
    Data [4096]byte
}

func BenchmarkConvertForce(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var v = int32(64)
        f := int64(v)
        if f != int64(64) {
            b.Error("errror")
        }
    }
}

func BenchmarkConvertReflect(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var v = int32(64)
        f := reflect.ValueOf(v).Int()
        if f != int64(64) {
            b.Error("errror")
        }
    }
}

func BenchmarkConvertAssert(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var v interface{} = int32(64)
        f := v.(int32)
        if f != int32(64) {
            b.Error("error")
        }
    }
}
➜  test go test --bench='Convert' -run=none -benchmem -gcflags=all=-l
goos: darwin
goarch: amd64
pkg: gotest666/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkConvertForce-12            1000000000           0.2843 ns/op          0 B/op          0 allocs/op
BenchmarkConvertReflect-12          84957760            13.66 ns/op        0 B/op          0 allocs/op
BenchmarkConvertAssert-12           1000000000           0.2586 ns/op          0 B/op          0 allocs/op

可以看出性能上:強類型轉換 / assert>reflect 沒有逃逸的原因參見:iface.go

content-service 中已經不再使用 reflect 相關的轉換處理

3.2 常用 map

go 中常用的 map 包含,runtime.map、sync.map 和第三方的 ConcurrentMap,go 中 map 的定義位於 map.go,典型的基於 bucket 的 map 的實現,如下:

type hmap struct {
    ......
    B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
    hash0     uint32 // hash seed

    buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
    oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
  ......
}

其查找、刪除、rehash 機制參見 https://juejin.cn/post/7056290831182856205

sync.map 定義位於 map.go 中,其是典型的以空間換時間的處理,具體如下:

type readOnly struct {
    m       map[interface{}]*entry
    amended bool // true if the dirty map contains some key not in m.
}

type entry struct {
    p unsafe.Pointer // *interface{}
}

type Map struct {
    mu Mutex
    read atomic.Value // readOnly數據
    dirty map[interface{}]*entry
    misses int
}

read 中存儲的是 dirty 數據的一個副本 (通過指針),在讀多寫少的情況下,基本可以實現無鎖的數據讀取。

Sync.map 相關機制參見: https://juejin.cn/post/6844903895227957262

go 中還有一個第三方的 ConcurrentMap,其採用分段鎖的原理,通過降低鎖的粒度提升性能,參見:current-map

針對 map、sync.map、ConcurrentMap 的測試如下:

const mapCnt = 20
func BenchmarkStdMapGetSet(b *testing.B) {
    mp := map[string]string{}
    keys := []string{"a", "b", "c", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r"}
    for i := range keys {
        mp[keys[i]] = keys[i]
    }
    var m sync.Mutex
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            for i := 0; i < mapCnt; i++ {
                for j := range keys {
                    m.Lock()
                    _ = mp[keys[j]]
                    m.Unlock()
                }
            }

            m.Lock()
            mp["d"] = "d"
            m.Unlock()
        }
    })
}

func BenchmarkSyncMapGetSet(b *testing.B) {
    var mp sync.Map
    keys := []string{"a", "b", "c", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r"}
    for i := range keys {
        mp.Store(keys[i], keys[i])
    }
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            for i := 0; i < mapCnt; i++ {
                for j := range keys {
                    _, _ = mp.Load(keys[j])
                }
            }

            mp.Store("d", "d")
        }
    })
}

func BenchmarkConcurrentMapGetSet(b *testing.B) {
    m := cmap.New[string]()
    keys := []string{"a", "b", "c", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r"}
    for i := range keys {
        m.Set(keys[i], keys[i])
    }
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            for i := 0; i < mapCnt; i++ {
                for j := range keys {
                    _, _ = m.Get(keys[j])
                }
            }

            m.Set("d", "d")
        }
    })
}
➜  test go test --bench='GetSet' -run=none -benchmem
goos: darwin
goarch: amd64
pkg: gotest666/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStdMapGetSet-12               49065         24976 ns/op           0 B/op          0 allocs/op
BenchmarkSyncMapGetSet-12             722704          1756 ns/op          16 B/op          1 allocs/op
BenchmarkConcurrentMapGetSet-12       227001          5206 ns/op           0 B/op          0 allocs/op
PASS

需要注意此測試,讀寫併發比 20:1 讀多寫少,建議使用 sync.Map。如果業務場景中,很明確只有對 map 的讀操作,建議使用 runtime.Map

目前 content-service 中 runtime.map、sync.map 都有涉及

3.3 hash 的實現: index vs map

在使用到 hash 的場景,除了 map,我們還可以基於 slice 或者數組的索引,content-service 基於此實現另外一種 map。其性能對比如下:

func BenchmarkHashIdx(b *testing.B) {
    var data = [10]int{0: 1, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9}
    for i := 0; i < b.N; i++ {
        tmp := data[b.N%10]
        _ = tmp
    }
}
func BenchmarkHashMap(b *testing.B) {
    var data = map[int]int{0: 1, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9}
    for i := 0; i < b.N; i++ {
        tmp := data[b.N%10]
        _ = tmp
    }
}
  test go test --bench='Hash' -run=none -benchmem
goos: darwin
goarch: amd64
pkg: gotest666/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkHashIdx-12     1000000000           1.003 ns/op           0 B/op          0 allocs/op
BenchmarkHashMap-12     196543544            7.665 ns/op           0 B/op          0 allocs/op
PASS

性能有 5 倍左右提升,content-service 在解析正排數據時,即採用此種處理。

3.4 string 和 slice

3.4.1 string 和 slice 的定義

在 go 中 string、slice 都是基於 buf、len 的定義,二者定義都位於 value.go 中:

type StringHeader struct
    Data uintptr
    Len  int
}

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

通過二者定義可以得出:

  1. 在值拷貝背景下,string、slice 的賦值操作代價都不大,最多有 24Byte

  2. slice 因爲涉及到 cap,會涉及到預分配、惰性刪除,其具體位於 slice.go

3.4.2 String、[]byte 轉換

go 中 string 和 []byte 間相互轉換包含 2 種:

  1. 採用原生機制,比如 string 轉 slice 可採用,[]byte(strData)

  2. 基於對底層數據結構重新解釋

以 string 轉換爲 byte 爲例,原生轉換的轉換會進行如下操作,其位於 string.go 中:

func stringtoslicebyte(buf *tmpBuf, s string) []byte {
    var b []byte
    if buf != nil && len(s) <= len(buf) { // 如果可以在tmpBuf中保存
        *buf = tmpBuf{}
        b = buf[:len(s)]
    } else {
        b = rawbyteslice(len(s)) // 如果32字節不夠存儲數據,則調用mallocgc分配空間
    }
    copy(b, s)  // 數據拷貝
    return b
}

// rawbyteslice allocates a new byte slice. The byte slice is not zeroed.
func rawbyteslice(size int) (b []byte) {
    cap := roundupsize(uintptr(size))
    p := mallocgc(cap, nil, false)  // 空間分配
    if cap != uintptr(size) {
        memclrNoHeapPointers(add(p, uintptr(size)), cap-uintptr(size))
    }

    *(*slice)(unsafe.Pointer(&b)) = slice{p, size, int(cap)}
    return
}

其中 tmpBuf 定義爲 type tmpBuf [32]byte。 當長度超過 32 字節時,會進行空間的分配、拷貝

同理,byte 轉換爲 string,原生處理位於 slicebytetostring 函數,也位於 string.go

針對多餘的空間分配、拷貝問題,content-service 對此進行了封裝,具體參見 tools.go,該實現通過對底層數據重新解釋進行,具有較高的效率。

以 byteToString 爲例,相關 benchMark 如下:

func BenchmarkByteToStringRaw(b *testing.B) {
   bytes := getByte(34)
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      v := string(bytes)
      if len(v) <= 0 {
         b.Error("error")
      }
   }
}
// 認爲對底層數據進行重新解釋
func Bytes2String(b []byte) string {
   x := (*[3]uintptr)(unsafe.Pointer(&b))
   s := [2]uintptr{x[0], x[1]}
   return *(*string)(unsafe.Pointer(&s))
}

func BenchmarkByteToStringPointer(b *testing.B) {
   bytes := getByte(34)
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      v := Bytes2String(bytes)
      if len(v) <= 0 {
         b.Error("error")
      }
   }
}
➜  gotest666 go test --bench='ByteToString' -run=none -benchmem
goos: darwin
goarch: amd64
pkg: gotest666
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkByteToStringRaw-12         47646651            23.37 ns/op       48 B/op          1 allocs/op
BenchmarkByteToStringPointer-12     1000000000           0.7539 ns/op          0 B/op          0 allocs/op

其性能有較大提升,性能提升的主要原因,0 gc 0拷貝需要注意,本處理只針對轉換,不涉及 append 等可能引起擴容的處理

3.4.3 string 的拼接

當前 golang 中字符串拼接方式,主要包含:

  1. 使用 + 連接字符串

  2. 使用 fmt.Sprintf

  3. 使用運行時工具類,strings.Builder 或者 bytes.Buffer

  4. 預分配機制

目前對 + 的處理,其處理函數位於 string.go,當要連接的字符串長度 > 32 時,每次會進行空間的分配和拷貝處理,其處理如下:

func concatstrings(buf *tmpBuf, a []string) string {
    idx := 0
    l := 0
    count := 0
    for i, x := range a {  // 計算+鏈接字符的長度
        n := len(x)
        if n == 0 {
            continue
        }
        if l+n < l {
            throw("string concatenation too long")
        }
        l += n
        count++
        idx = i
    }
    if count == 0 {
        return ""
    }
    .....
  s, b := rawstringtmp(buf, l) // 如果長度小於len(buf)(32),則分配空間,否則使用buf
    for _, x := range a {
        copy(b, x)
        b = b[len(x):]
    }
    return s
}
type tmpBuf [32]byte

fmt.Sprinf,涉及大量的 interface 相關操作,會導致逃逸。

針對 +、fmt.Sprintf 等的對比測試如下:

func BenchmarkStringJoinAdd(b *testing.B) {
   var s string
   for i := 0; i < b.N; i++ {
      for i := 0; i < count; i++ {
         s += "10"
      }
   }
}

func BenchmarkStringJoinSprintf(b *testing.B) {
   var s string
   for i := 0; i < b.N; i++ {
      for i := 0; i < count; i++ {
         s = fmt.Sprintf("%s%s", s, "10")
      }
   }
}

func BenchmarkStringJoinStringBuilder(b *testing.B) {
   var sb strings.Builder
   sb.Grow(count * 2) // 預分配了空間
   b.ResetTimer()

   for i := 0; i < b.N; i++ {
      for i := 0; i < count; i++ {
         sb.WriteString("10")
      }
   }
}
➜  gotest666 go test --bench='StringJoin' -run=none -benchmem
goos: darwin
goarch: amd64
pkg: gotest666
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStringJoinAdd-12                    124      11992891 ns/op    127697864 B/op      1006 allocs/op
BenchmarkStringJoinSprintf-12                100      19413234 ns/op    195832744 B/op      2808 allocs/op
BenchmarkStringJoinStringBuilder-12       189568          7335 ns/op       12392 B/op          0 allocs/op

可以看出,空間預分配擁有非常高的性能指標。目前,Content-service 中都採用了空間預分配的方式,其他的一些測試參見:string 連接

3.5 循環的處理: for vs range

go 中常用的循環有 2 種 for 和 range, 如下:

  1. 按位置進行遍歷,for 和 range 都支持,如 for i:=range a{}, for i:=0;i<len(a);i++

  2. 同時對位置、值進行遍歷,range 支持,如 for i,v := range a {}

go 中循環經過一系列的編譯、優化後,僞代碼如下:

ta := a     // 容器的拷貝
i := 0
l := len(ta) // 獲取長度
for ; i < l; i++ {
    v := ta[i]  // 容器中元素的拷貝
}

此處理可能會導致以下問題:

  1. 遍歷前,會進行值的拷貝,如果是數組,會有大量數據拷貝,slice 和 map 等引用的拷貝較少

  2. for range value 在遍歷中存在對容器元素的拷貝

  3. 遍歷開始,已經確定了容器長度,中間添加的數據,不會遍歷到

針對此測試如下:

type Item struct {
    id  int
    val [4096]byte
}

func BenchmarkLoopFor(b *testing.B) {
    var items [1024]Item
    for i := 0; i < b.N; i++ {
        length := len(items)
        var tmp int
        for k := 0; k < length; k++ {
            tmp = items[k].id
        }
        _ = tmp
    }
}

func BenchmarkLoopRangeIndex(b *testing.B) {
    var items [1024]Item
    for i := 0; i < b.N; i++ {
        var tmp int
        for k := range items {
            tmp = items[k].id
        }
        _ = tmp
    }
}

func BenchmarkLoopRangeValue(b *testing.B) {
    var items [1024]Item
    for i := 0; i < b.N; i++ {
        var tmp int
        for _, item := range items {
            tmp = item.id
        }
        _ = tmp
    }
}
➜  test go test --bench='Loop' -run=none -benchmem
goos: darwin
goarch: amd64
pkg: gotest666/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkLoopFor-12              4334842           270.8 ns/op         0 B/op          0 allocs/op
BenchmarkLoopRangeIndex-12       4436786           272.7 ns/op         0 B/op          0 allocs/op
BenchmarkLoopRangeValue-12          7310        211009 ns/op           0 B/op          0 allocs/op
PASS

注意,對於所需空間較小,如指針類型數組等此問題並不嚴重 在需要較大存儲空間、元素需要較大存儲空間時,建議不要採用 range value 的方式

content_service 中目前基本都是基於 for index、range index 的處理

3.6 重載

目前 go 中重載的實現包含 2 種,泛型 (1.18)、基於 interface 的定義。泛型的優點在於預編譯,即編譯期間即可確定類型,對比基於 interface 的逃逸會有一定收益,具體測試如下:

func AddGeneric[T int | int16 | int32 | int64](a, b T) T {
    return a + b
}

func AddInterface(a, b interface{}) interface{} {
    switch a.(type) {
    case int:
        return a.(int) + b.(int)
    case int32:
        return a.(int32) + b.(int32)
    case int64:
        return a.(int64) + b.(int64)
    }
    return 0
}

func BenchmarkOverLoadGeneric(b *testing.B) {
    for i := 0; i < b.N; i++ {
        x := AddGeneric(i, i)
        _ = x
    }
}
func BenchmarkOverLoadInterface(b *testing.B) {
    for i := 0; i < b.N; i++ {
        x := AddInterface(i, i)
        _ = x.(int)
    }
}
➜  test go test --bench='OverLoad' -run=none -benchmem
goos: darwin
goarch: amd64
pkg: gotest666/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkOverLoadGeneric-12         1000000000           0.2778 ns/op          0 B/op          0 allocs/op
BenchmarkOverLoadInterface-12       954258690            1.248 ns/op           0 B/op          0 allocs/op
PASS

對比 interface 類型的處理,泛型有一定的性能的提升,目前在 content-service 中已經得到了大量的使用。

4 空間與佈局

4.1 棧與堆空間的分配

在棧上分配空間爲什麼會比堆上快?

通過彙編,可觀察棧空間分配機制,如下:

package main

func test(a, b int) int {
    return a + b
}

其對應彙編代碼如下:

main.test STEXT nosplit size=49 args=0x10 locals=0x10 funcid=0x0 align=0x0
        0x0000 00000 (/Users/zhangyuxin/go/src/gotest666/test.go:3)     TEXT    main.test(SB), NOSPLIT|ABIInternal, $16-16
        0x0000 00000 (/Users/zhangyuxin/go/src/gotest666/test.go:3)     SUBQ    $16, SP         // 棧擴容
                ......
        0x002c 00044 (/Users/zhangyuxin/go/src/gotest666/test.go:4)     ADDQ    $16, SP         // 棧釋放
        0x0030 00048 (/Users/zhangyuxin/go/src/gotest666/test.go:4)     RET

在 go 中棧的擴容、釋放只涉及到了 SUBQ、ADDQ 2 條指令。

對應的基於堆的內存分配,位於 malloc.go 中 mallocgc 函數,p 的定義、mheap 的定義分別位於 runtime2.gomcache.gomheap.go,其分配流程具體如下(<32K,>8B):

其中,直接從 p.mcache 獲取空間不需要加鎖(單協程),mheap.mcentral 獲取空間需要加鎖 (全局變量)、mmap 需要系統調用。此外,堆上分配還需要考慮 gc 導致的 stw 等的影響,因此建議所需空間不是特別大時還是在棧上進行空間的分配。

content-service 開發中有一個共識: 能在棧上處理的數據,不會放到堆上。

4.2 Zero GC

Zero GC 能夠避免 gc 帶來的掃描、STW 等,具有一定的性能收益。

當前 zero gc 的處理,主要包含 2 種:

  1. 無 gc,通過 mmap 或者 cgo.malloc 分配空間,繞過 go 的內存分配機制,如 fastcache 的實現

  2. 避免或者減少 gc,通過 []byte 等避免因爲指針導致的掃描、stw,bigCache 的實現即爲此。

Zero GC 的優點在於,避免了 go gc 處理帶來的標記掃描、STW 等,相對於常規堆上數據分配,其性能有較大提升。content-service 在重構中,使用了大量的基於 0 gc 的庫,比如 fastcache,對一些常用函數、機制,如 strings.split 也進行了 0 gc 的優化,其實現如下:

在 content-service 中其實現位於 string_util.go,如下:

type StringSplitter struct {
    Idx [8]int  // 存儲splitter對應的位置信息
    src string
    cnt int
}

// Split 分割
func (s *StringSplitter) Split(str string, sep byte) bool {
    s.src = str
    for i := 0; i < len(str); i++ {
        if str[i] == sep {
            s.Idx[s.cnt] = i
            s.cnt++

            // 超過Idx數據長度則返回空
            if int(s.cnt) >= len(s.Idx) {
                return false
            }
        }
    }

    return true
}

與常規 strings.split 對比如下,其性能有近 4 倍左右提升:

➜  test go test --bench='Split' -run=none -benchmem
goos: darwin
goarch: amd64
pkg: gotest666/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkQSplitRaw-12       13455728            76.43 ns/op       64 B/op          1 allocs/op
BenchmarkQSplit-12          59633916            20.08 ns/op        0 B/op          0 allocs/op
PASS

4.3 GC 的優化

gc 優化相關,主要涉及 GOGC、GOMEMLIMIT,參見:Golang 垃圾回收介紹及參數調整

需要注意,此機制只在 1.20 以上版本生效

4.4 逃逸

對於一些處理比較複雜操作,go 在編譯器會在編譯期間將相關變量逃逸至堆上。目前可能導致逃逸的機制包含:

  1. 基於指針的逃逸

  2. 棧空間不足,超過了 os 的限制 8M

  3. 閉包

  4. 動態類型

目前逃逸分析,可採用 - gcflags=-m 進行查看,如下:

type test1 struct {
    a int32
    b int
    c int32
}

type test2 struct {
    a int32
    c int32
    b int
}

func getData() *int {
    a := 10
    return &a
}

func main() {
    fmt.Println(unsafe.Sizeof(test1{}))
    fmt.Println(unsafe.Sizeof(test2{}))
    getData()
}
➜  gotest666 go build -gcflags=-m main.go
# command-line-arguments
./main.go:20:6: can inline getData
./main.go:26:13: inlining call to fmt.Println
./main.go:27:13: inlining call to fmt.Println
./main.go:28:9: inlining call to getData
./main.go:21:2: moved to heap: a        // 返回指針導致逃逸
./main.go:26:13: ... argument does not escape
./main.go:26:27: unsafe.Sizeof(test1{}) escapes to heap // 動態類型導致逃逸
./main.go:27:13: ... argument does not escape
./main.go:27:27: unsafe.Sizeof(test2{}) escapes to heap // 動態類型導致逃逸

在日常業務處理過程中,建議儘量避免逃逸到堆上的情況

4.5 數據的對齊

go 中同樣存在數據對齊,適當的佈局調整,能夠節省大量的空間,具體如下:

type test1 struct {
    a int32
    b int
    c int32
}

type test2 struct {
    a int32
    c int32
    b int
}

func main() {
    fmt.Println(unsafe.Alignof(test1{}))
    fmt.Println(unsafe.Alignof(test2{}))
    fmt.Println(unsafe.Sizeof(test1{}))
    fmt.Println(unsafe.Sizeof(test2{}))
}
➜  gotest666 go run main.go
8
8
24
16

4.6 空間預分配

空間預分配,可以避免大量不必要的空間分配、拷貝,目前 slice、map、strings.Builder、byte.Builder 等都涉及到預分配機制。

以 map 爲例,測試結果如下:

func BenchmarkConcurrentMapAlloc(b *testing.B) {
    m := map[int]int{}
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        m[i] = i
    }
}

func BenchmarkConcurrentMapPreAlloc(b *testing.B) {
    m := make(map[int]int, b.N)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        m[i] = i
    }
}
➜  test go test --bench='Alloc' -run=none -benchmem
goos: darwin
goarch: amd64
pkg: gotest666/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkConcurrentMapAlloc-12           6027334           186.0 ns/op        60 B/op          0 allocs/op
BenchmarkConcurrentMapPreAlloc-12       15499568            89.68 ns/op        0 B/op          0 allocs/op
PASS

預分配能夠極大提升,相關性能, 建議在使用時都進行空間的預分配。content-service 在開發中基本都做到了空間的預分配。

5 併發編程

5.1 鎖

golang 中 mutex 定義位於 mutex.go,其定義如下:

type Mutex struct {
    state int32         // 狀態字,標識鎖是否被鎖定、是否starving等
    sema  uint32        // 信號量
}

golang 的讀寫鎖基於 mutex,其定義位於 rwmutex.go, 其定義如下:

type RWMutex struct {
    w           Mutex  // 用於阻塞寫協程
    writerSem   uint32 // 寫信號量,用於實現寫阻塞隊列
    readerSem   uint32 // 讀信號量,用於實現讀阻塞隊列
    readerCount int32  // 當前正在讀操作的個數
    readerWait  int32  // 防止寫操作被餓死,標記排在寫操作前讀操作的個數
}

RWMutex 基於 Mutex 實現,在加寫鎖上,RWMutex 性能略差於 Mutex。但在讀操作較多情況下,RWMutex 性能是優於 Mutex 的,因爲 RWMutex 對於讀的操作只是通過 readerCount 計數進行, 其相關處理位於 rwmutex.go,如下:

func (rw *RWMutex) RLock() {
    if race.Enabled {
        _ = rw.w.state
        race.Disable()
    }
    if rw.readerCount.Add(1) < 0 {  // readCount < 0,表示有寫操作正在進行
        runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)
    }
    if race.Enabled {
        race.Enable()
        race.Acquire(unsafe.Pointer(&rw.readerSem))
    }
}

func (rw *RWMutex) Lock() {
    if race.Enabled {
        _ = rw.w.state
        race.Disable()
    }

    rw.w.Lock()                                                                         // 加寫鎖
    r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders // 統計當前讀操作的個數,
    if r != 0 && rw.readerWait.Add(r) != 0 {                                                // 並等待讀操作
        runtime_SemacquireRWMutex(&rw.writerSem, false, 0)
    }
    if race.Enabled {
        race.Enable()
        race.Acquire(unsafe.Pointer(&rw.readerSem))
        race.Acquire(unsafe.Pointer(&rw.writerSem))
    }
}

按照讀寫比例的不同,進行了如下測試:

var mut sync.Mutex
var rwMut sync.RWMutex
var t int

const cost = time.Microsecond

func WRead() {
    mut.Lock()
    _ = t
    time.Sleep(cost)
    mut.Unlock()
}

func WWrite() {
    mut.Lock()
    t++
    time.Sleep(cost)
    mut.Unlock()
}

func RWRead() {
    rwMut.RLock()
    _ = t
    time.Sleep(cost)
    rwMut.RUnlock()
}

func RWWrite() {
    rwMut.Lock()
    t++
    time.Sleep(cost)
    rwMut.Unlock()
}

func benchmark(b *testing.B, readFunc, writeFunc func(), read, write int) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            var wg sync.WaitGroup
            for k := 0; k < read*100; k++ {
                wg.Add(1)
                go func() {
                    readFunc()
                    wg.Done()
                }()
            }
            for k := 0; k < write*100; k++ {
                wg.Add(1)
                go func() {
                    writeFunc()
                    wg.Done()
                }()
            }
            wg.Wait()
        }
    })
}

func BenchmarkReadMore(b *testing.B)         { benchmark(b, WRead, WWrite, 9, 1) }
func BenchmarkReadMoreRW(b *testing.B)       { benchmark(b, RWRead, RWWrite, 9, 1) }
func BenchmarkWriteMore(b *testing.B)        { benchmark(b, WRead, WWrite, 1, 9) }
func BenchmarkWriteMoreRW(b *testing.B)      { benchmark(b, RWRead, RWWrite, 1, 9) }
func BenchmarkReadWriteEqual(b *testing.B)   { benchmark(b, WRead, WWrite, 5, 5) }
func BenchmarkReadWriteEqualRW(b *testing.B) { benchmark(b, RWRead, RWWrite, 5, 5) }
➜  test go test --bench='Read|Write' -run=none -benchmem
goos: darwin
goarch: amd64
pkg: gotest666/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkReadMore-12                     207       5713542 ns/op      114190 B/op       2086 allocs/op
BenchmarkReadMoreRW-12                  1237        904307 ns/op      104683 B/op       2007 allocs/op
BenchmarkWriteMore-12                    211       5799927 ns/op      110360 B/op       2067 allocs/op
BenchmarkWriteMoreRW-12                  222       5490282 ns/op      110666 B/op       2070 allocs/op
BenchmarkReadWriteEqual-12               213       5752311 ns/op      111017 B/op       2065 allocs/op
BenchmarkReadWriteEqualRW-12             386       3088603 ns/op      106810 B/op       2030 allocs/op

在讀寫比例爲 9:1 時,RWMute 性能約爲 Mutex 的 6 倍。

6. 其他

需要注意:語言層面只能解決單點的性能問題,良好的架構設計才能從全局解決問題

本文所有 benchmark、源碼都是基於 1.18。

7. 參考資料

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