Go性能优化以及benchmark使用 | 青训营笔记

83 阅读5分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 18 天

本文主要介绍了go语言基准测试手段和一些进行性能优化的小技巧。

Go benchmark简要介绍

日常编程中我们可以使用基准测试(benchmark)来度量程序性能,在Go语言中benchmark由go testing库提供,通过计算单位时间内程序运行的次数来衡量程序的性能。需要注意的是:在进行基准测试时,硬件资源直接影响测试结果,为了保证测试结果的可重复性,需要尽可能地保证硬件资源一致。

go testing的使用

  • 首先,根据程序代码编写测试文件,一般这两个文件位于同一个package中,测试文件以_test.go结尾,比如,程序名称为hello.go,那么测试文件为hello_test.go。

  • 测试函数的命名格式:func BenchmarkXxx(b *testing.B),函数采用驼峰命名法,Xxx的首字母大写。

  • 测试函数内的被测函数循环b.N次

  • 使用时,根据需求在文件夹下的命令行窗口内执行go test命令,不过 go test 命令默认不执行 benchmark 测试,需要加上 -bench 参数,该参数支持正则表达式,只有匹配到的测试用例才会执行,使用 . 则运行所有测试用例

  • 一些其他的参数:

    • -benchtime=t,go benchmark 默认测试时间是 1s,我们可以使用该参数适当增加时长。该参数还支持特殊的形式 Nx,用来指定被测程序的运行次数。
    • -count=n,默认测试一次,加了该参数以后便会测试n次
    • -cpu=n,设置测试所使用的cpu核数,一般情况下,随着 CPU 核数的增加,性能逐步提升,但是到一定阈值后,性能趋于稳定,此时再增加 CPU 核数,性能反而下降,因为 CPU 核心之间的切换也是需要成本的。
    • -benchmem用来显示程序运行期间内存的分配情况

性能优化方案

slice 预分配内存

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

  • 原理

    • 切片本质是一个数组片段的描述,包括了数组的指针,这个片段的长度和容量(不改变内存分配情况下的最大长度)

    • 切片操作并不复制切片指向的元素,创建一个新的切片会复用原来切片的底层数组,因此切片操作是非常高效的

    • 切片有三个属性,指针(ptr)、长度(len) 和容量(cap)。append 时有两种场景:

      • 当 append 之后的长度小于等于 cap,将会直接利用原底层数组剩余的空间
      • 当 append 后的长度大于 cap 时,则会分配一块更大的区域来容纳新的底层数组
    • 因此,为了避免内存发生拷贝,如果能够知道最终的切片的大小,预先设置 cap 的值能够获得最好的性能

  • 另一个陷阱:大内存得不到释放

    • 在已有切片的基础上进行切片,不会创建新的底层数组。因为原来的底层数组没有发生变化,内存会一直占用,直到没有变量引用该数组
    • 因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放
    • 推荐的做法,使用 copy 替代 re-slice

下面测试对比了有无预先分配内存的两个程序之间的性能差异

func NoPreAlloc(size int) {
	data := make([]int, 0)
	for k := 0; k < size; k++ {
		data = append(data, k)
	}
}

func PreAlloc(size int) {
	data := make([]int, 0, size)
	for k := 0; k < size; k++ {
		data = append(data, k)
	}
}

测试代码

func BenchmarkNoPreAlloc(b *testing.B) {
	for n := 0; n < b.N; n++ {
		NoPreAlloc(100)
	}
}

func BenchmarkPreAlloc(b *testing.B) {
	for n := 0; n < b.N; n++ {
		PreAlloc(100)
	}
}

1.jpeg

在上图的结果中,xxx-8表示CPU的核数为8,ns/op表示函数运行一次耗费的时间,B/op表示函数运行一次使用的内存大小,allocs/op表示函数运行过程中内存分配的次数。

map 预分配内存

  • 原理

    • 不断向 map 中添加元素的操作会触发 map 的扩容
    • 根据实际需求提前预估好需要的空间
    • 提前分配好空间可以减少内存拷贝和 Rehash 的消耗

相关测试代码

func NoPreAlloc(size int) {
	data := make(map[int]int)
	for i := 0; i < size; i++ {
		data[i] = 1
	}
}

func PreAlloc(size int) {
	data := make(map[int]int, size)
	for i := 0; i < size; i++ {
		data[i] = 1
	}
}
func BenchmarkNoPreAlloc(b *testing.B) {
	for n := 0; n < b.N; n++ {
		NoPreAlloc(1000)
	}
}

func BenchmarkPreAlloc(b *testing.B) {
	for n := 0; n < b.N; n++ {
		PreAlloc(1000)
	}
}

测试结果如下所示:

image.png

使用 strings.Builder

  • 常见的字符串拼接方式

    • “+”
    • strings.Builder
    • bytes.Buffer
  • strings.Builder 最快,bytes.Buffer 较快,+ 最慢

  • 原理

    • 字符串在 Go 语言中是不可变类型,占用内存大小是固定的,当使用 + 拼接 2 个字符串时,生成一个新的字符串,那么就需要开辟一段新的空间,新空间的大小是原来两个字符串的大小之和
    • strings.Builder,bytes.Buffer 的内存是以倍数申请的
    • strings.Builder 和 bytes.Buffer 底层都是 []byte 数组,bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,而 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回

测试代码如下所示:

func Plus(n int, str string) string {
	s := ""
	for i := 0; i < n; i++ {
		s += str
	}
	return s
}

func StrBuilder(n int, str string) string {
	var builder strings.Builder
	for i := 0; i < n; i++ {
		builder.WriteString(str)
	}
	return builder.String()
}

func ByteBuffer(n int, str string) string {
	buf := new(bytes.Buffer)
	for i := 0; i < n; i++ {
		buf.WriteString(str)
	}
	return buf.String()
}
func BenchmarkPlus(b *testing.B) {
	for n := 0; n < b.N; n++ {
		Plus(1000, "string")
	}
}

func BenchmarkStrBuilder(b *testing.B) {
	for n := 0; n < b.N; n++ {
		StrBuilder(1000, "string")
	}
}

func BenchmarkByteBuffer(b *testing.B) {
	for n := 0; n < b.N; n++ {
		ByteBuffer(1000, "string")
	}
}

结果如下所示:

image.png

使用空结构体节省内存

  • 空结构体不占据内存空间,可作为占位符使用

  • 比如实现简单的 Set

    • Go 语言标准库没有提供 Set 的实现,通常使用 map 来代替。对于集合场景,只需要用到 map 的键而不需要值

测试代码如下所示:

func EmptyStructMap(n int) {
	m := make(map[int]struct{})

	for i := 0; i < n; i++ {
		m[i] = struct{}{}
	}
}

func BoolMap(n int) {
	m := make(map[int]bool)

	for i := 0; i < n; i++ {
		m[i] = false
	}
}
func BenchmarkEmptyStructMap(b *testing.B) {
	for n := 0; n < b.N; n++ {
		EmptyStructMap(10000)
	}
}

func BenchmarkBoolMap(b *testing.B) {
	for n := 0; n < b.N; n++ {
		BoolMap(10000)
	}
}

image.png

为了不失一般性,每个函数测试了三次,测试结果如上图所示,可见使用struct空结构体的结果在内存分配大小和内存分配次数上都要好于bool类型。

使用 atomic 包

  • 原理

    • 锁的实现是通过操作系统来实现,属于系统调用,atomic 操作是通过硬件实现的,效率比锁高很多
    • sync.Mutex 应该用来保护一段逻辑,不仅仅用于保护一个变量
    • 对于非数值系列,可以使用 atomic.Value,atomic.Value 能承载一个 interface{}

测试代码如下所示:

type atomicCounter struct {
	i int32
}

func AtomicAddOne(c *atomicCounter) {
	atomic.AddInt32(&c.i, 1)
}

type mutexCounter struct {
	i int32
	m sync.Mutex
}

func MutexAddOne(c *mutexCounter) {
	c.m.Lock()
	c.i++
	c.m.Unlock()
}
func BenchmarkAtomicAddOne(b *testing.B) {
	for n := 0; n < b.N; n++ {
		var counter = atomicCounter{}
		AtomicAddOne(&counter)
	}
}

func BenchmarkMutexAddOne(b *testing.B) {
	for n := 0; n < b.N; n++ {
		var counter = mutexCounter{}
		MutexAddOne(&counter)
	}
}

image.png

从结果中可以看出,在对数值进行加1的操作中,atomic的效率比mutex高一倍。