Go 语言入门指南:内存性能测试方法与常见内存陷阱问题 | 青训营

54 阅读4分钟

前面的话

本篇针对的是 Go 语言常见的性能陷阱,通过 Go 自带的 Benchmark 进行性能测试。

  • Slice 预内存分配
  • 字符串处理
  • 空结构体 struct{}{} 的使用

Benchmark 内存分配情况探索

测试文件为 _test.go 结尾的文件,测试其中函数签名格式如 func BenchmarkXXX(b *testing.B) { funcBody } 的函数。测试时在该文件夹下输入 go test -bench . -benchmem 即可显示测试函数的内存分配的统计信息。 以一个简单的 Slice 内存分配函数为例

func BenchmarkSlice(b *testing.B) {
    size := 1000
    for i := 0; i < b.N; i++ {
        var s []int
        for j := 0; j < size; j++ {
            s = append(s, j)
        }
    }
}
命令行输入 go test -bench . -benchmem 得到以下结果
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-8300H CPU @ 2.30GHz
BenchmarkSlice-8                          283892              4397 ns/op           25208 B/op         12 allocs/op

前三行为系统信息无需分析。

  • BenchmarkSlice 为测试的函数名
  • -8GOMAXPROCS 为测试的线程数
  • 283892 是总共执行的测试,即在函数测试中使用的 b.N 的值
  • 4397 ns/op 是每次执行的平均操作时间
  • 25208/op 表示每次操作申请的内存
  • 12 allocs/op 为每次操作申请的内存次数

Slice 预内存分配

Go 语言中切片的底层是一个长度可管理的数组,在执行 slice = make([]T, m, n) 时,会分配一个长度为 m,容量为 n 的数组,通过内置函数 len/cap 可以观察到 len(slice) = m / len(slice) = n

当对函数执行 append 扩容 x 长度时,如果 (n-m) > x 即剩余空间足够大,则在原数组上新添 x 个参数,并将 slice 对外显示长度改为 m+x,即 len(slice) = m+x, 但容量是不会变的即 cap(slice) = n

如果剩余空间不够大时,根据原容量大小再扩容:

  • 当原容量 cap(slice) < 1024 时,会请求一个二倍空间的新数组。
  • 当原容量 cap(slice) > 1024 时,会请求一个1.25倍空间的新数组。
  • 以上两步循环执行至数组空间足够满足 append 函数的需求

因此预先为 Slice 分配出足够的容量是十分必要的。

func BenchmarkSlice(b *testing.B) {
    size := 1000
    for i := 0; i < b.N; i++ {
        var s []int
        for j := 0; j < size; j++ {
            s = append(s, j)
        }
    }
}

func BenchmarkSliceWithPreAllocated(b *testing.B) {
    size := 1000
    for i := 0; i < b.N; i++ {
        var s []int = make([]int, 0, size)
        for j := 0; j < size; j++ {
            s = append(s, j)
        }
    }
}

// go test bench . -benchmem 
// output: 
// BenchmarkSlice-8                          283892              4397 ns/op           25208 B/op         12 allocs/op
// BenchmarkSliceWithPreAllocated-8         1844385               633.8 ns/op             0 B/op          0 allocs/op

字符串拼接

Go 语言中的字符串是常量,不可修改的,即使通过 []byte 强制转换,也只是获得一个复制的字节数组,因此字符串拼接的各种方式需要进行实验测试。常见的拼接方式如下所示

  • +
  • fmt.Sprintf
  • strings.Builder
  • bytes.Buffer
  • []byte
const stringSize = 1000

func BenchmarkStringConcatPlus(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := ""
        for j := 0; j < stringSize; j++ {
            s += "a"
        }
    }
}

func BenchmarkStringConcatFmtSprintf(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := ""
        for j := 0; j < stringSize; j++ {
            s = fmt.Sprintf("%s%s", s, "a")
        }
    }
}

func BenchmarkStringConcatStringsBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := strings.Builder{}
        for j := 0; j < stringSize; j++ {
            s.WriteString("a")
        }
        _ = s.String()
    }
}

func BenchmarkStringConcatStringsBuilderWithPreAllocated(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := strings.Builder{}
        s.Grow(stringSize)
        for j := 0; j < stringSize; j++ {
            s.WriteString("a")
        }
        _ = s.String()
    }
}

func BenchmarkStringConcatBytesBuffer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := bytes.Buffer{}
        for j := 0; j < stringSize; j++ {
            s.WriteString("a")
        }
        _ = s.String()
    }
}

func BenchmarkStringConcatBytesBufferWithPreAllocated(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := bytes.Buffer{}
        s.Grow(stringSize)
        for j := 0; j < stringSize; j++ {
            s.WriteString("a")
        }
        _ = s.String()
    }
}

func BenchmarkStringConcatByteSlice(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var s []byte
        for j := 0; j < stringSize; j++ {
            s = append(s, []byte("a")...)
        }
        _ = string(s)
    }
}

func BenchmarkStringConcatByteSliceWithPreAllocated(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var s = make([]byte, 0, stringSize)
        for j := 0; j < stringSize; j++ {
            s = append(s, []byte("a")...)
        }
        _ = string(s)
    }
}

// output:
// BenchmarkStringConcatPlus-8                                         8773            121408 ns/op          530275 B/op        999 allocs/op
// BenchmarkStringConcatFmtSprintf-8                                   4812            218469 ns/op          546536 B/op       1998 allocs/op
// BenchmarkStringConcatStringsBuilder-8                             546016              2410 ns/op            3320 B/op          9 allocs/op
// BenchmarkStringConcatStringsBuilderWithPreAllocated-8             752048              1687 ns/op            1024 B/op          1 allocs/op
// BenchmarkStringConcatBytesBuffer-8                                206568              5390 ns/op            3248 B/op          6 allocs/op
// BenchmarkStringConcatBytesBufferWithPreAllocated-8                246604              4780 ns/op            2048 B/op          2 allocs/op
// BenchmarkStringConcatByteSlice-8                                  650638              1608 ns/op            4344 B/op         10 allocs/op
// BenchmarkStringConcatByteSliceWithPreAllocated-8                 1501183               822.5 ns/op          1024 B/op          1 allocs/op
  • +fmt.Sprintf 最低效,因为 fmt.Sprintf 本身就是用于格式字符串,而 + 则是低效的不断申请新的内存空间。
  • []byte 最快, 因为没有任何其他管理消耗。
  • strings.Builderbytes.Buffer 相近,但也有两倍的差距。实际上底层也是在管理 []byte,不过还有其他管理参数并实现了一些管理方法。 两种方法的底层实现也基本相近,但在转换成字符串时 strings.Builder 是直接将 []byte 数组强制转换为 string, 而 bytes.Buffer 方法则是创建新内存转换为 string

空结构体 struct{}{} 的使用

fmt.Println(unsafe.Sizeof(struct{}{}), unsafe.Sizeof(true), unsafe.Sizeof(false))
// output:
// 0 1 1
  • 空结构体在 Go 语言中不占用任何额外空间
    • 在需要 Set 集合时,即使使用布尔值也额外需要一个字节的空间,而空结构体不需要
  • 空结构体本身具有很强的语义性,即表示不需要任何值,仅作为占位符
    • chan 传输数据时,空结构体既不占用空间也不会造成二义性