Go高性能编程技法解读(完整版)

542次阅读  |  发布于2年以前

导语 | 代码的稳健、可读和高效是我们每一个coder的共同追求。本文将结合Go语言特性,为书写高效的代码,力争从常用数据结构、内存管理两个方面给出相关建议。话不多说,让我们一起学习Go高性能编程的技法吧。

一、常用数据结构

(一)反射虽好,切莫贪杯

标准库reflect为Go语言提供了运行时动态获取对象的类型和值以及动态创建对象的能力。反射可以帮助抽象和简化代码,提高开发效率。

Go语言标准库以及很多开源软件中都使用了Go语言的反射能力,例如用于序列化和反序列化的json、ORM框架 gorm、xorm等。

基本数据类型与字符串之间的转换,优先使用strconv而不是fmt,因为前者性能更佳。

// Bad
for i := 0; i < b.N; i++ {
  s := fmt.Sprint(rand.Int())
}

BenchmarkFmtSprint-4    143 ns/op    2 allocs/op

// Good
for i := 0; i < b.N; i++ {
  s := strconv.Itoa(rand.Int())
}

BenchmarkStrconv-4    64.2 ns/op    1 allocs/op

为什么性能上会有两倍多的差距,因为fmt实现上利用反射来达到范型的效果,在运行时进行类型的动态判断,所以带来了一定的性能损耗。

有时,我们需要一些工具函数。比如从uint64切片过滤掉指定的元素。利用反射,我们可以实现一个类型泛化支持扩展的切片过滤函数。

// DeleteSliceElms 从切片中过滤指定元素。注意:不修改原切片。
func DeleteSliceElms(i interface{}, elms ...interface{}) interface{} {
  // 构建 map set。
  m := make(map[interface{}]struct{}, len(elms))
  for _, v := range elms {
    m[v] = struct{}{}
  }
  // 创建新切片,过滤掉指定元素。
  v := reflect.ValueOf(i)
  t := reflect.MakeSlice(reflect.TypeOf(i), 0, v.Len())
  for i := 0; i < v.Len(); i++ {
    if _, ok := m[v.Index(i).Interface()]; !ok {
      t = reflect.Append(t, v.Index(i))
    }
  }
  return t.Interface()
}

很多时候,我们可能只需要操作一个类型的切片,利用反射实现的类型泛化扩展的能力压根没用上。退一步说,如果我们真地需要对uint64以外类型的切片进行过滤,拷贝一次代码又何妨呢?可以肯定的是,绝大部份场景,根本不会对所有类型的切片进行过滤,那么反射带来好处我们并没有充分享受,但却要为其带来的性能成本买单。


// DeleteU64liceElms 从 []uint64 过滤指定元素。注意:不修改原切片。
func DeleteU64liceElms(i []uint64, elms ...uint64) []uint64 {
  // 构建 map set。
  m := make(map[uint64]struct{}, len(elms))
  for _, v := range elms {
    m[v] = struct{}{}
  }
  // 创建新切片,过滤掉指定元素。
  t := make([]uint64, 0, len(i))
  for _, v := range i {
    if _, ok := m[v]; !ok {
      t = append(t, v)
    }
  }
  return t
}

下面看一下二者的性能对比。


func BenchmarkDeleteSliceElms(b *testing.B) {
  slice := []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9}
  elms := []interface{}{uint64(1), uint64(3), uint64(5), uint64(7), uint64(9)}
  for i := 0; i < b.N; i++ {
    _ = DeleteSliceElms(slice, elms...)
  }
}

func BenchmarkDeleteU64liceElms(b *testing.B) {
  slice := []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9}
  elms := []uint64{1, 3, 5, 7, 9}
  for i := 0; i < b.N; i++ {
    _ = DeleteU64liceElms(slice, elms...)
  }
}

运行上面的基准测试。


go test -bench=. -benchmem main/reflect 
goos: darwin
goarch: amd64
pkg: main/reflect
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkDeleteSliceElms-12              1226868               978.2 ns/op           296 B/op         16 allocs/op
BenchmarkDeleteU64liceElms-12            8249469               145.3 ns/op            80 B/op          1 allocs/op
PASS
ok      main/reflect    3.809s

可以看到,反射涉及了额外的类型判断和大量的内存分配,导致其对性能的影响非常明显。随着切片元素的递增,每一次判断元素是否在map中,因为map的key是不确定的类型,会发生变量逃逸,触发堆内存的分配。所以,可预见的是当元素数量增加时,性能差异会越来大。

当使用反射时,请问一下自己,我真地需要它吗?

binary.Read和binary.Write使用反射并且很慢。如果有需要用到这两个函数的地方,我们应该手动实现这两个函数的相关功能,而不是直接去使用它们。

encoding/binary包实现了数字和字节序列之间的简单转换以及varints的编码和解码。varints是一种使用可变字节表示整数的方法。其中数值本身越小,其所占用的字节数越少。Protocol Buffers对整数采用的便是这种编码方式。

其中数字与字节序列的转换可以用如下三个函数:

// Read 从结构化二进制数据 r 读取到 data。data 必须是指向固定大小值的指针或固定大小值的切片。
func Read(r io.Reader, order ByteOrder, data interface{}) error
// Write 将 data 的二进制表示形式写入 w。data 必须是固定大小的值或固定大小值的切片,或指向此类数据的指针。
func Write(w io.Writer, order ByteOrder, data interface{}) error
// Size 返回 Wirte 函数将 v 写入到 w 中的字节数。
func Size(v interface{}) int

下面以我们熟知的C标准库函数ntohl()函数为例,看看Go利用binary包如何实现。


// Ntohl 将网络字节序的 uint32 转为主机字节序。
func Ntohl(bys []byte) uint32 {
  r := bytes.NewReader(bys)
  err = binary.Read(buf, binary.BigEndian, &num)
}

// 如将 IP 127.0.0.1 网络字节序解析到 uint32
fmt.Println(Ntohl([]byte{0x7f, 0, 0, 0x1})) // 2130706433 <nil>

如果我们针对uint32类型手动实现一个ntohl()呢?


func NtohlNotUseBinary(bys []byte) uint32 {
  return uint32(bys[3]) | uint32(bys[2])<<8 | uint32(bys[1])<<16 | uint32(bys[0])<<24
}

// 如将 IP 127.0.0.1 网络字节序解析到 uint32
fmt.Println(NtohlNotUseBinary([]byte{0x7f, 0, 0, 0x1})) // 2130706433

该函数也是参考了encoding/binary包针对大端字节序将字节序列转为uint32类型时的实现。

下面看下剥去反射前后二者的性能差异。

func BenchmarkNtohl(b *testing.B) {
  for i := 0; i < b.N; i++ {
    _, _ = Ntohl([]byte{0x7f, 0, 0, 0x1})
  }
}

func BenchmarkNtohlNotUseBinary(b *testing.B) {
  for i := 0; i < b.N; i++ {
    _ = NtohlNotUseBinary([]byte{0x7f, 0, 0, 0x1})
  }
}

运行上面的基准测试,结果如下:

go test -bench=BenchmarkNtohl.* -benchmem main/reflect
goos: darwin
goarch: amd64
pkg: main/reflect
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkNtohl-12                       13026195                81.96 ns/op           60 B/op          4 allocs/op
BenchmarkNtohlNotUseBinary-12           1000000000               0.2511 ns/op          0 B/op          0 allocs/op
PASS
ok      main/reflect    1.841s

可见使用反射实现的encoding/binary包的性能相较于针对具体类型实现的版本,性能差异非常大。

(二)避免重复的字符串到字节切片的转换

不要反复从固定字符串创建字节slice,因为重复的切片初始化会带来性能损耗。相反,请执行一次转换并捕获结果。


func BenchmarkStringToByte(b *testing.B) {
   for i := 0; i < b.N; i++ {
      by := []byte("Hello world")
      _ = by
   }
}

func BenchmarkStringToByteOnce(b *testing.B) {
   bys := []byte("Hello world")
   for i := 0; i < b.N; i++ {
      by := bys
      _ = by
   }
}

看一下性能差异,注意需要禁止编译器优化,不然看不出差别。


go test -bench=BenchmarkStringToByte -gcflags="-N" main/perf
goos: windows
goarch: amd64
pkg: main/perf
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkStringToByte-8         748467979                1.582 ns/op
BenchmarkStringToByteOnce-8     878246492                1.379 ns/op
PASS
ok      main/perf       2.962s

(三)指定容器容量

尽可能指定容器容量,以便为容器预先分配内存。这将在后续添加元素时减少通过复制来调整容器大小。

在尽可能的情况下,在使用make()初始化的时候提供容量信息。

make(map[T1]T2, hint)

向make()提供容量提示会在初始化时尝试调整map的大小,这将减少在将元素添加到map时为map重新分配内存。

注意,与slice不同。map capacity提示并不保证完全的抢占式分配,而是用于估计所需的hashmap bucket的数量。因此,在将元素添加到map时,甚至在指定map容量时,仍可能发生分配。

// Bad
m := make(map[string]os.FileInfo)

files, _ := ioutil.ReadDir("./files")
for _, f := range files {
    m[f.Name()] = f
}
// m 是在没有大小提示的情况下创建的;在运行时可能会有更多分配。

// Good
files, _ := ioutil.ReadDir("./files")

m := make(map[string]os.FileInfo, len(files))
for _, f := range files {
    m[f.Name()] = f
}
// m 是有大小提示创建的;在运行时可能会有更少的分配。

在尽可能的情况下,在使用make()初始化切片时提供容量信息,特别是在追加切片时。

make([]T, length, capacity)

与map不同,slice capacity不是一个提示:编译器将为提供给make()的slice的容量分配足够的内存,这意味着后续的append() 操作将导致零分配(直到slice的长度与容量匹配,在此之后,任何append都可能调整大小以容纳其他元素)。

const size = 1000000

// Bad
for n := 0; n < b.N; n++ {
  data := make([]int, 0)
    for k := 0; k < size; k++ {
      data = append(data, k)
  }
}

BenchmarkBad-4    219    5202179 ns/op

// Good
for n := 0; n < b.N; n++ {
  data := make([]int, 0, size)
    for k := 0; k < size; k++ {
      data = append(data, k)
  }
}

BenchmarkGood-4   706    1528934 ns/op

执行基准测试:


go test -bench=^BenchmarkJoinStr -benchmem 
BenchmarkJoinStrWithOperator-8    66930670    17.81 ns/op    0 B/op    0 allocs/op
BenchmarkJoinStrWithSprintf-8      7032921    166.0 ns/op    64 B/op   4 allocs/op

(四)字符串拼接方式的选择

行内拼接字符串为了书写方便快捷,最常用的两个方法是:

行内字符串的拼接,主要追求的是代码的简洁可读。fmt.Sprintf()能够接收不同类型的入参,通过格式化输出完成字符串的拼接,使用非常方便。但因其底层实现使用了反射,性能上会有所损耗。

运算符+只能简单地完成字符串之间的拼接,非字符串类型的变量需要单独做类型转换。行内拼接字符串不会产生内存分配,也不涉及类型地动态转换,所以性能上优于fmt.Sprintf()。

从性能出发,兼顾易用可读,如果待拼接的变量不涉及类型转换且数量较少<=5),行内拼接字符串推荐使用运算符+,反之使用fmt.Sprintf()。

下面看下二者的性能对比。


// Good
func BenchmarkJoinStrWithOperator(b *testing.B) {
  s1, s2, s3 := "foo", "bar", "baz"
  for i := 0; i < b.N; i++ {
    _ = s1 + s2 + s3
  }
}

// Bad
func BenchmarkJoinStrWithSprintf(b *testing.B) {
  s1, s2, s3 := "foo", "bar", "baz"
  for i := 0; i < b.N; i++ {
    _ = fmt.Sprintf("%s%s%s", s1, s2, s3)
  }
}

执行基准测试结果如下:

go test -bench=^BenchmarkJoinStr -benchmem .
BenchmarkJoinStrWithOperator-8    70638928    17.53 ns/op     0 B/op    0 allocs/op
BenchmarkJoinStrWithSprintf-8      7520017    157.2 ns/op    64 B/op    4 allocs/op

字符串拼接还有其他的方式,比如strings.Join()、strings.Builder、bytes.Buffer和byte[],这几种不适合行内使用。当待拼接字符串数量较多时可考虑使用。

先看下其性能测试的对比。


func BenchmarkJoinStrWithStringsJoin(b *testing.B) {
  s1, s2, s3 := "foo", "bar", "baz"
  for i := 0; i < b.N; i++ {
    _ = strings.Join([]string{s1, s2, s3}, "")
  }
}

func BenchmarkJoinStrWithStringsBuilder(b *testing.B) {
  s1, s2, s3 := "foo", "bar", "baz"
  for i := 0; i < b.N; i++ {
    var builder strings.Builder
    _, _ = builder.WriteString(s1)
    _, _ = builder.WriteString(s2)
    _, _ = builder.WriteString(s3)
  }
}

func BenchmarkJoinStrWithBytesBuffer(b *testing.B) {
  s1, s2, s3 := "foo", "bar", "baz"
  for i := 0; i < b.N; i++ {
    var buffer bytes.Buffer
    _, _ = buffer.WriteString(s1)
    _, _ = buffer.WriteString(s2)
    _, _ = buffer.WriteString(s3)
  }
}

func BenchmarkJoinStrWithByteSlice(b *testing.B) {
  s1, s2, s3 := "foo", "bar", "baz"
  for i := 0; i < b.N; i++ {
    var bys []byte
    bys= append(bys, s1...)
    bys= append(bys, s2...)
    _ = append(bys, s3...)
  }
}

func BenchmarkJoinStrWithByteSlicePreAlloc(b *testing.B) {
  s1, s2, s3 := "foo", "bar", "baz"
  for i := 0; i < b.N; i++ {
    bys:= make([]byte, 0, 9)
    bys= append(bys, s1...)
    bys= append(bys, s2...)
    _ = append(bys, s3...)
  }
}

基准测试结果如下:

go test -bench=^BenchmarkJoinStr .
goos: windows
goarch: amd64
pkg: main/perf
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkJoinStrWithStringsJoin-8               31543916                36.39 ns/op
BenchmarkJoinStrWithStringsBuilder-8            30079785                40.60 ns/op
BenchmarkJoinStrWithBytesBuffer-8               31663521                39.58 ns/op
BenchmarkJoinStrWithByteSlice-8                 30748495                37.34 ns/op
BenchmarkJoinStrWithByteSlicePreAlloc-8         665341896               1.813 ns/op

从结果可以看出,strings.Join()、strings.Builder、bytes.Buffer和byte[]的性能相近。如果结果字符串的长度是可预知的,使用 byte[] 且预先分配容量的拼接方式性能最佳。

所以如果对性能要求非常严格,或待拼接的字符串数量足够多时,建议使用byte[]预先分配容量这种方式。

综合易用性和性能,一般推荐使用strings.Builder来拼接字符串。

string.Builder也提供了预分配内存的方式Grow:

func BenchmarkJoinStrWithStringsBuilderPreAlloc(b *testing.B) {
  s1, s2, s3 := "foo", "bar", "baz"
  for i := 0; i < b.N; i++ {
    var builder strings.Builder
    builder.Grow(9)
    _, _ = builder.WriteString(s1)
    _, _ = builder.WriteString(s2)
    _, _ = builder.WriteString(s3)
  }
}

使用了Grow优化后的版本的性能测试结果如下。可以看出相较于不预先分配空间的方式,性能提升了很多。

BenchmarkJoinStrWithStringsBuilderPreAlloc-8    60079003                20.95 ns/op

(五)遍历[]struct{}使用下标而不是range

Go中遍历切片或数组有两种方式,一种是通过下标,一种是range。二者在功能上没有区别,但是在性能上会有区别吗?

首先看一下遍历基本类型切片时二者的性能差别,以[]int为例。

// genRandomIntSlice 生成指定长度的随机 []int 切片
func genRandomIntSlice(n int) []int {
  rand.Seed(time.Now().UnixNano())
  nums := make([]int, 0, n)
  for i := 0; i < n; i++ {
    nums = append(nums, rand.Int())
  }
  return nums
}

func BenchmarkIndexIntSlice(b *testing.B) {
  nums := genRandomIntSlice(1024)
  for i := 0; i < b.N; i++ {
    var tmp int
    for k := 0; k < len(nums); k++ {
      tmp = nums[k]
    }
    _ = tmp
  }
}

func BenchmarkRangeIntSlice(b *testing.B) {
  nums := genRandomIntSlice(1024)
  for i := 0; i < b.N; i++ {
    var tmp int
    for _, num := range nums {
      tmp = num
    }
    _ = tmp
  }
}

运行测试结果如下:

go test -bench=IntSlice$ .
goos: windows
goarch: amd64
pkg: main/perf
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkIndexIntSlice-8         5043324               236.2 ns/op
BenchmarkRangeIntSlice-8         5076255               239.1 ns/op

genRandomIntSlice()函数用于生成指定长度元素类型为int的切片。从最终的结果可以看到,遍历[]int类型的切片,下标与range遍历性能几乎没有区别。

那么对于稍微复杂一点的[]struct类型呢?

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

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

func BenchmarkRangeIndexStructSlice(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 BenchmarkRangeStructSlice(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
  }
}

运行测试结果如下:

go test -bench=StructSlice -benchmem -gcflags=-N main/range
goos: darwin
goarch: amd64
pkg: main/range
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkIndexStructSlice-12              556436              2165 ns/op               1 B/op          0 allocs/op
BenchmarkRangeIndexStructSlice-12         535705              2124 ns/op               1 B/op          0 allocs/op
BenchmarkRangeStructSlice-12               38799             30914 ns/op              27 B/op          0 allocs/op
PASS
ok      main/range      5.097s 

可以看出,两种通过index遍历[]struct性能没有差别,但是range遍历[]struct中元素时,性能非常差。

range只遍历[]struct下标时,性能比range遍历[]struct值好很多。从这里我们应该能够知道二者性能差别之大的原因。

Item是一个结构体类型,Item由两个字段构成,一个类型是int,一个是类型是[1024]byte,如果每次遍历[]Item,都会进行一次值拷贝,所以带来了性能损耗。

此外,因为range时获取的是值拷贝的副本,对副本的修改,是不会影响到原切片。

需要注意的时,上面运行基准测试时,使用编译选项-gcflags=-N禁用了编译器对切片遍历的优化,如果没有该选项,那么上面三种遍历方式没有性能差别。

为什么编译会对上面的测试代码进行优化呢?因为代码实际上只取最后一个切片元素的值,所以前面的循环操作是可以跳过,这样便带来性能的提升。如果是下面的代码,那么编器将无法优化,必须进行每一个元素的拷贝。

func BenchmarkRange1(b *testing.B) {
  items := make([]Item, 1024)
  tmps := make([]int, 1024)
  for i := 0; i < b.N; i++ {
    for j := range items {
      tmps[j] = items[j].id
    }
  }
}

func BenchmarkRange2(b *testing.B) {
  items := make([]Item, 1024)
  tmps := make([]int, 1024)
  for i := 0; i < b.N; i++ {
    for j, item := range items {
      tmps[j] = item.id
    }
  }
}

无需去除编译器优化,基准测试结果如下:


go test -bench=BenchmarkRange -benchmem  main/range
goos: darwin
goarch: amd64
pkg: main/range
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkRange1-12       2290372               534.8 ns/op             0 B/op          0 allocs/op
BenchmarkRange2-12         46161             27169 ns/op              22 B/op          0 allocs/op
PASS
ok      main/range      3.378s

那如果切片中是指向结构体的指针,而不是结构体呢?


// genItems 生成指定长度 []*Item 切片
func genItems(n int) []*Item {
  items := make([]*Item, 0, n)
  for i := 0; i < n; i++ {
    items = append(items, &Item{id: i})
  }
  return items
}

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

func BenchmarkRangePointer(b *testing.B) {
  items := genItems(1024)
  for i := 0; i < b.N; i++ {
    var tmp int
    for _, item := range items {
      tmp = item.id
    }
    _ = tmp
  }
}

执行性能测试结果:

go test -bench=Pointer$ main/perf
goos: windows
goarch: amd64
pkg: main/perf
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkIndexPointer-8           773634              1521 ns/op
BenchmarkRangePointer-8           752077              1514 ns/op

切片元素从结构体Item替换为指针*Item后,for和range的性能几乎是一样的。而且使用指针还有另一个好处,可以直接修改指针对应的结构体的值。

range在迭代过程中返回的是元素的拷贝,index则不存在拷贝。

如果range迭代的元素较小,那么index和range的性能几乎一样,如基本类型的切片[]int。但如果迭代的元素较大,如一个包含很多属性的struct结构体,那么index的性能将显著地高于range,有时候甚至会有上千倍的性能差异。对于这种场景,建议使用index。如果使用range,建议只迭代下标,通过下标访问元素,这种使用方式和index就没有区别了。如果想使用range同时迭代下标和值,则需要将切片/数组的元素改为指针,才能不影响性能。

二、内存管理

(一)使用空结构体节省内存

在Go中,我们可以使用unsafe.Sizeof计算出一个数据类型实例需要占用的字节数。

package main

import (
  "fmt"
  "unsafe"
)

func main() {
  fmt.Println(unsafe.Sizeof(struct{}{}))
}

运行上面的例子将会输出:

go run main.go
0

可以看到,Go中空结构体struct{}是不占用内存空间,不像C/C++中空结构体仍占用1字节。

因为空结构体不占据内存空间,因此被广泛作为各种场景下的占位符使用。一是节省资源,二是空结构体本身就具备很强的语义,即这里不需要任何值,仅作为占位符,达到的代码即注释的效果。

Go语言标准库没有提供Set的实现,通常使用map来代替。事实上,对于集合来说,只需要map的键,而不需要值。即使是将值设置为bool类型,也会多占据1个字节,那假设map中有一百万条数据,就会浪费1MB的空间。

因此呢,将map作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。


type Set map[string]struct{}

func (s Set) Has(key string) bool {
  _, ok := s[key]
  return ok
}

func (s Set) Add(key string) {
  s[key] = struct{}{}
}

func (s Set) Delete(key string) {
  delete(s, key)
}

func main() {
  s := make(Set)
  s.Add("foo")
  s.Add("bar")
  fmt.Println(s.Has("foo"))
  fmt.Println(s.Has("bar"))
}

如果想使用Set的完整功能,如初始化(通过切片构建一个Set)、Add、Del、Clear、Contains等操作,可以使用开源库golang-set。

func worker(ch chan struct{}) {
  <-ch
  fmt.Println("do something")
}

func main() {
  ch := make(chan struct{})
  go worker(ch)
  ch <- struct{}{}
  close(ch)
}

有时候使用channel不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程的并发。这种情况下,使用空结构体作为占位符就非常合适了。


type Door struct{}

func (d Door) Open() {
  fmt.Println("Open the door")
}

func (d Door) Close() {
  fmt.Println("Close the door")
}

在部分场景下,结构体只包含方法,不包含任何的字段。例如上面例子中的Door,在这种情况下,Door事实上可以用任何的数据结构替代。

type Door int
type Door bool

无论是int还是bool都会浪费额外的内存,因此呢,这种情况下,声明为空结构体最合适。

(二)struct布局要考虑内存对齐

CPU访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如32位的CPU,字长为4字节,那么CPU访问内存的单位也是4字节。

这么设计的目的,是减少CPU访问内存的次数,加大CPU访问内存的吞吐量。比如同样读取8个字节的数据,一次读取4个字节那么只需要读取2次。

CPU始终以字长访问内存,如果不进行内存对齐,很可能增加CPU访问内存的次数,例如:

变量a、b各占据3字节的空间,内存对齐后,a、b占据4字节空间,CPU读取b变量的值只需要进行一次内存访问。如果不进行内存对齐,CPU读取b变量的值需要进行2次内存访问。第一次访问得到b变量的第1个字节,第二次访问得到b变量的后两个字节。

从这个例子中也可以看到,内存对齐对实现变量的原子性操作也是有好处的,每次内存访问是原子的,如果变量的大小不超过字长,那么内存对齐后,对该变量的访问就是原子的,这个特性在并发场景下至关重要。

简言之:合理的内存对齐可以提高内存读写的性能,并且便于实现变量操作的原子性。

编译器一般为了减少CPU访存指令周期,提高内存的访问效率,会对变量进行内存对齐。Go作为一门追求高性能的后台编程语言,当然也不例外。

Go Language Specification 中Size and alignment guarantees描述了内存对齐的规则。

For a variable x of any type: unsafe.Alignof(x) is at least 1.

For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.

For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array's element type.

其中函数unsafe.Alignof用于获取变量的对齐系数。对齐系数决定了字段的偏移和变量的大小,两者必须是对齐系数的整数倍。

因为内存对齐的存在,合理的struct布局可以减少内存占用,提高程序性能。

type demo1 struct {
  a int8
  b int16
  c int32
}

type demo2 struct {
  a int8
  c int32
  b int16
}

func main() {
  fmt.Println(unsafe.Sizeof(demo1{})) // 8
  fmt.Println(unsafe.Sizeof(demo2{})) // 12
}

可以看到,同样的字段,因字段排列顺序不同,最终会导致不一样的结构体大小。

每个字段按照自身的对齐系数来确定在内存中的偏移量,一个字段因偏移而浪费的大小也不同。

接下来逐个分析,首先是demo1:a是第一个字段,默认是已经对齐的,从第0个位置开始占据1字节。b是第二个字段,对齐系数为2,因此,必须空出1个字节,偏移量才是2的倍数,从第2个位置开始占据2字节。c是第三个字段,对齐倍数为4,此时,内存已经是对齐的,从第4个位置开始占据4字节即可。

因此demo1的内存占用为8字节。

对于demo2:a是第一个字段,默认是已经对齐的,从第0个位置开始占据1字节。c是第二个字段,对齐倍数为4,因此,必须空出3个字节,偏移量才是4的倍数,从第4个位置开始占据4字节。b是第三个字段,对齐倍数为2,从第8个位置开始占据2字节。

demo2的对齐系数由c的对齐系数决定,也是4,因此,demo2的内存占用为12字节。

因此,在对内存特别敏感的结构体的设计上,我们可以通过调整字段的顺序,将字段宽度从小到大由上到下排列,来减少内存的占用。

空结构与空数组在Go中比较特殊。没有任何字段的空struct{}和没有任何元素的array占据的内存空间大小为0。

因为这一点,空struct{}或空array作为其他struct的字段时,一般不需要内存对齐。但是有一种情况除外:即当struct{}或空array作为结构体最后一个字段时,需要内存对齐。因为如果有指针指向该字段,返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放)。

type demo3 struct {
  a struct{}
  b int32
}
type demo4 struct {
  b int32
  a struct{}
}

func main() {
  fmt.Println(unsafe.Sizeof(demo3{})) // 4
  fmt.Println(unsafe.Sizeof(demo4{})) // 8
}

可以看到,demo3{}的大小为4字节,与字段b占据空间一致,而 demo4{}的大小为8字节,即额外填充了4字节的空间。

(三)减少逃逸,将变量限制在栈上

变量逃逸一般发生在如下几种情况:

知道变量逃逸的原因后,我们可以有意识地控制变量不发生逃逸,将其控制在栈上,减少堆变量的分配,降低GC成本,提高程序性能。

如果使用局部的切片时,已知切片的长度或容量,请使用常量或数值字面量来定义。

package main

func main() {
  number := 10
  s1 := make([]int, 0, number)
  for i := 0; i < number; i++ {
    s1 = append(s1, i)
  }
  s2 := make([]int, 0, 10)
  for i := 0; i < 10; i++ {
    s2 = append(s2, i)
  }
}

我们来看一下编译器编译时对上面两个切片的优化决策。


 go build -gcflags="-m -m -l" main.go
# command-line-arguments
./main.go:5:12: make([]int, 0, number) escapes to heap:
./main.go:5:12:   flow: {heap} = &{storage for make([]int, 0, number)}:
./main.go:5:12:     from make([]int, 0, number) (non-constant size) at ./main.go:5:12
./main.go:5:12: make([]int, 0, number) escapes to heap
./main.go:9:12: make([]int, 0, 10) does not escape

从输出结果可以看到,使用变量(非常量)来指定切片的容量,会导致切片发生逃逸,影响性能。指定切片的长度时也是一样的,尽可能使用常量或数值字面量。

下面看下二者的性能差异。


// sliceEscape 发生逃逸,在堆上申请切片
func sliceEscape() {
  number := 10
  s1 := make([]int, 0, number)
  for i := 0; i < number; i++ {
    s1 = append(s1, i)
  }
}

// sliceNoEscape 不逃逸,限制在栈上
func sliceNoEscape() {
  s1 := make([]int, 0, 10)
  for i := 0; i < 10; i++ {
    s1 = append(s1, i)
  }
}

func BenchmarkSliceEscape(b *testing.B) {
  for i := 0; i < b.N; i++ {
    sliceEscape()
  }
}

func BenchmarkSliceNoEscape(b *testing.B) {
  for i := 0; i < b.N; i++ {
    sliceNoEscape()
  }
}

运行上面的基准测试结果如下:


go test -bench=BenchmarkSlice -benchmem main/copy  
goos: darwin
goarch: amd64
pkg: main/copy
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkSliceEscape-12         43268738                27.40 ns/op           80 B/op          1 allocs/op
BenchmarkSliceNoEscape-12       186127288                6.454 ns/op           0 B/op          0 allocs/op
PASS
ok      main/copy       4.402s

值传递会拷贝整个对象,而指针传递只会拷贝地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,返回指针导致的GC开销可能会严重影响性能。

一般情况下,对于需要修改原对象,或占用内存比较大的对象,返回指针。对于只读或占用内存较小的对象,返回值能够获得更好的性能。

下面以一个简单的示例来看下二者的性能差异。


type St struct {
  arr [1024]int
}

func retValue() St {
  var st St
  return st
}

func retPtr() *St {
  var st St
  return &st
}

func BenchmarkRetValue(b *testing.B) {
  for i := 0; i < b.N; i++ {
    _ = retValue()
  }
}

func BenchmarkRetPtr(b *testing.B) {
  for i := 0; i < b.N; i++ {
    _ = retPtr()
  }
}

运行基准测试,结果如下:

go test -gcflags="-l" -bench=BenchmarkRet -benchmem main/copy
goos: darwin
goarch: amd64
pkg: main/copy
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkRetValue-12             5194722               216.2 ns/op             0 B/op          0 allocs/op
BenchmarkRetPtr-12               1342947               893.6 ns/op          8192 B/op          1 allocs/op
PASS
ok      main/copy       3.865s

我们都知道Go里面的Array以pass-by-value方式传递后,再加上其长度不可扩展,考虑到性能我们一般很少使用它。实际上,凡事无绝对。有时使用数组进行拷贝传递,比使用切片要好。


func BenchmarkArray(b *testing.B) {
  for i := 0; i < b.N; i++ {
    _ = arrayFibonacci()
  }
}

func BenchmarkSlice(b *testing.B) {
  for i := 0; i < b.N; i++ {
    _ = sliceFibonacci()
  }
}

下面看一下性能对比。


func BenchmarkArray(b *testing.B) {
  for i := 0; i < b.N; i++ {
    _ = arrayFibonacci()
  }
}

func BenchmarkSlice(b *testing.B) {
  for i := 0; i < b.N; i++ {
    _ = sliceFibonacci()
  }
}

运行上面的基准测试,将得到如下结果。


go test -bench=. -benchmem -gcflags="-l" main/copy
goos: darwin
goarch: amd64
pkg: main/copy
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkArray-12         692400              1708 ns/op               0 B/op          0 allocs/op
BenchmarkSlice-12         464974              2242 ns/op            8192 B/op          1 allocs/op
PASS
ok      main/copy       3.908s

从测试结果可以看出,对数组的拷贝性能却比使用切片要好。为什么会这样呢?

sliceFibonacci()函数中分配的局部变量切片因为要返回到函数外部,所以发生了逃逸,需要在堆上申请内存空间。从测试也过也可以看出,arrayFibonacci()函数没有内存分配,完全在栈上完成数组的创建。这里说明了对于一些短小的对象,栈上复制的成本远小于在堆上分配和回收操作。

需要注意,运行上面基准测试时,传递了禁止内联的编译选项“-l”,如果发生内联,那么将不会出现变量的逃逸,就不存在堆上分配内存与回收的操作了,二者将看不出性能差异。

编译时可以借助选项-gcflags=-m查看编译器对上面两个函数的优化决策。


go build  -gcflags=-m copy/copy.go
# command-line-arguments
copy/copy.go:5:6: can inline arrayFibonacci
copy/copy.go:17:6: can inline sliceFibonacci
copy/copy.go:18:11: make([]int, capacity) escapes to heap

可以看到,arrayFibonacci()和sliceFibonacci()函数均可内联。sliceFibonacci()函数中定义的局部变量切片逃逸到了堆。

那么多大的变量才算是小变量呢? 对Go编译器而言,超过一定大小的局部变量将逃逸到堆上,不同Go版本的大小限制可能不一样。一般是< 64KB,局部变量将不会逃逸到堆上。

如果变量类型不确定,那么将会逃逸到堆上。所以,函数返回值如果能确定的类型,就不要使用interface{}。

我们还是以上面斐波那契数列函数为例,看下返回值为确定类型和interface{}的性能差别。

const capacity = 1024

func arrayFibonacci() [capacity]int {
  var d [capacity]int
  for i := 0; i < len(d); i++ {
    if i <= 1 {
      d[i] = 1
      continue
    }
    d[i] = d[i-1] + d[i-2]
  }
  return d
}

func arrayFibonacciIfc() interface{} {
  var d [capacity]int
  for i := 0; i < len(d); i++ {
    if i <= 1 {
      d[i] = 1
      continue
    }
    d[i] = d[i-1] + d[i-2]
  }
  return d
}

func BenchmarkArray(b *testing.B) {
  for i := 0; i < b.N; i++ {
    _ = arrayFibonacci()
  }
}

func BenchmarkIfc(b *testing.B) {
  for i := 0; i < b.N; i++ {
    _ = arrayFibonacciIfc()
  }
}

运行上面的基准测试结果如下:

go test -bench=. -benchmem main/copy
goos: darwin
goarch: amd64
pkg: main/copy
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkArray-12         832418              1427 ns/op               0 B/op          0 allocs/op
BenchmarkIfc-12           380626              2861 ns/op            8192 B/op          1 allocs/op
PASS
ok      main/copy       3.742s

可见,函数返回值使用interface{}返回时,编译器无法确定返回值的具体类型,导致返回值逃逸到堆上。当发生了堆上内存的申请与回收时,性能会差一点。

(四)sync.Pool复用对象

sync.Pool是sync包下的一个组件,可以作为保存临时取还对象的一个“池子”。个人觉得它的名字有一定的误导性,因为Pool里装的对象可以被无通知地被回收,可能sync.Cache是一个更合适的名字。

sync.Pool是可伸缩的,同时也是并发安全的,其容量仅受限于内存的大小。存放在池中的对象如果不活跃了会被自动清理。

对于很多需要重复分配、回收内存的地方,sync.Pool是一个很好的选择。频繁地分配、回收内存会给GC带来一定的负担,严重的时候会引起CPU的毛刺,而sync.Pool可以将暂时不用的对象缓存起来,待下次需要的时候直接使用,不用再次经过内存分配,复用对象的内存,减轻GC的压力,提升系统的性能。

一句话总结:用来保存和复用临时对象,减少内存分配,降低GC压力。

sync.Pool的使用方式非常简单,只需要实现New函数即可。对象池中没有对象时,将会调用New函数创建。

假设我们有一个“学生”结构体,并复用改结构体对象。

type Student struct {
  Name   string
  Age    int32
  Remark [1024]byte
}

var studentPool = sync.Pool{
    New: func() interface{} { 
        return new(Student) 
    },
}

然后调用Pool的Get()和Put()方法来获取和放回池子中。

stu := studentPool.Get().(*Student)
json.Unmarshal(buf, stu)
studentPool.Put(stu)

我们以bytes.Buffer字节缓冲器为例,利用sync.Pool复用bytes.Buffer对象,避免重复创建与回收内存,来看看对性能的提升效果。


var bufferPool = sync.Pool{
  New: func() interface{} {
    return &bytes.Buffer{}
  },
}

var data = make([]byte, 10000)

func BenchmarkBufferWithPool(b *testing.B) {
  for n := 0; n < b.N; n++ {
    buf := bufferPool.Get().(*bytes.Buffer)
    buf.Write(data)
    buf.Reset()
    bufferPool.Put(buf)
  }
}

func BenchmarkBuffer(b *testing.B) {
  for n := 0; n < b.N; n++ {
    var buf bytes.Buffer
    buf.Write(data)
  }
}

测试结果如下:

go test -bench=. -benchmem main/pool
goos: darwin
goarch: amd64
pkg: main/pool
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkBufferWithPool-12      11987966                97.12 ns/op            0 B/op          0 allocs/op
BenchmarkBuffer-12               1246887              1020 ns/op           10240 B/op          1 allocs/op
PASS
ok      main/pool       3.510s

这个例子创建了一个bytes.Buffer对象池,每次只执行Write操作,及做一次数据拷贝,耗时几乎可以忽略。而内存分配和回收的耗时占比较多,因此对程序整体的性能影响更大。从测试结果也可以看出,使用了Pool复用对象,每次操作不再有内存分配。

Go标准库也大量使用了sync.Pool,例如fmt和encoding/json。以fmt包为例,我们看下其是如何使用sync.Pool的。

我们可以看一下最常用的标准格式化输出函数Printf()函数。


// Printf formats according to a format specifier and writes to standard output.
// It returns the number of bytes written and any write error encountered.
func Printf(format string, a ...interface{}) (n int, err error) {
  return Fprintf(os.Stdout, format, a...)
}

继续看Fprintf()的定义。


// Fprintf formats according to a format specifier and writes to w.
// It returns the number of bytes written and any write error encountered.
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
  p := newPrinter()
  p.doPrintf(format, a)
  n, err = w.Write(p.buf)
  p.free()
  return
}

Fprintf()函数的参数是一个io.Writer,Printf()传的是os.Stdout,相当于直接输出到标准输出。这里的newPrinter用的就是sync.Pool。


// go version go1.17 darwin/amd64

// pp is used to store a printer's state and is reused with sync.Pool to avoid allocations.
type pp struct {
    buf buffer
    ...
}

var ppFree = sync.Pool{
  New: func() interface{} { return new(pp) },
}

// newPrinter allocates a new pp struct or grabs a cached one.
func newPrinter() *pp {
  p := ppFree.Get().(*pp)
  p.panicking = false
  p.erroring = false
  p.wrapErrs = false
  p.fmt.init(&p.buf)
  return p
}

// free saves used pp structs in ppFree; avoids an allocation per invocation.
func (p *pp) free() {
  // Proper usage of a sync.Pool requires each entry to have approximately
  // the same memory cost. To obtain this property when the stored type
  // contains a variably-sized buffer, we add a hard limit on the maximum buffer
  // to place back in the pool.
  //
  // See https://golang.org/issue/23199
  if cap(p.buf) > 64<<10 {
    return
  }

  p.buf = p.buf[:0]
  p.arg = nil
  p.value = reflect.Value{}
  p.wrappedErr = nil
  ppFree.Put(p)
}

fmt.Printf()的调用是非常频繁的,利用sync.Pool复用pp对象能够极大地提升性能,减少内存占用,同时降低GC压力。

参考资料:

1.github.com/uber-go/guide

2.go-proverbs

3.github/dgryski/go-perfbook

4.High Performance Go Workshop - Dave Cheney

5.atomic 的原理与使用场景

6.极客兔兔.Go 语言高性能编程

7.深度解密Go 语言之sync.Pool-Stefno-博客园

8.Golang内存分配逃逸分析-Gopherzhang

9.Go语言的内存逃逸分析-Golang梦工厂

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8