Go性能优化建议

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

前言

上一篇文章介绍了如何写出高质量的代码,高质量的代码能够完成功能,但是在大规模程序部署的场景,除了完成正常的功能,还要尽可能提升性能来节省资源成本,本文主要介绍性能优化相关的建议。

重点内容

  • 性能优化建议-Benchmark

  • 性能优化建议-Slice

  • 性能优化建议-Map

  • 性能优化建议-字符串

  • 性能优化建议-空结构体

  • 性能优化建议- atomic 包

知识点介绍

性能优化的前提是满足正确可靠、简洁清晰等质量因素,性能优化需要综合评估,有时候时间效率和空间效率可能对立。

性能优化建议-Benchmark

性能表现需要实际数据衡量,Go 语言提供了支持基准性能测试的 Benchmark 工具,在 Go 语言工程实践-测试一文中就使用过 Benchmark 工具进行测试,通过以下命令进行测试

go test xxx_test.go -benchmem

性能优化建议-Slice

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

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)
	}
}
BenchmarkNoPreAlloc-16    	 2547324	       483.4 ns/op	    2040 B/op	       8 allocs/op
BenchmarkPreAlloc-16    	 7085184	       175.4 ns/op	     896 B/op	       1 allocs/op

底层原理:

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

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

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

  • 当 append 之后的长度小于等于 cap,将会直接利用原底层数组剩余的空间

  • 当 append 后的长度大于 cap 时,则会分配一块更大的区域来容纳新的底层数组

  • 因此,为了避免内存发生拷贝,如果能够知道最终的切片的大小,预先设置 cap 的值能够获得最好的性能

另一个陷阱:大内存未释放

  • 在已有切片的基础上进行切片,不会创建新的底层数组。因为原来的底层数组没有发生变化,内存会一直占用,直到没有变量引用该数组

  • 因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放

  • 推荐的做法,使用 copy 替代 re-slice

性能优化建议-Map

Map 同样有预分配的性能优化点

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
	}
}
BenchmarkNoPreAlloc-16    	   21512	     56223 ns/op	   86553 B/op	      64 allocs/op
BenchmarkPreAlloc-16    	   53217	     22862 ns/op	   41097 B/op	       6 allocs/op

原理:

  • 不断向 map 中添加元素的操作会触发 map 的扩容

  • 根据实际需求提前预估好需要的空间

  • 提前分配好空间可以减少内存拷贝和 Rehash 的消耗

性能优化建议-字符串

不同的字符串处理方式的性能表现有一定的差异。

常见的字符串拼接方式

  • +

  • strings.Builder

  • bytes.Buffer

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()
}
BenchmarkPlus-16    	      2588	        491930 ns/op	 3212601 B/op	     999 allocs/op
BenchmarkStrBuilder-16    	  227828	      5081 ns/op	   24824 B/op	      14 allocs/op
BenchmarkByteBuffer-16    	  166554	      7312 ns/op	   22464 B/op	       9 allocs/op

其中 strings.Builder 最快,bytes.Buffer 较快,+ 最慢

底层原理:

  • 字符串在 Go 语言中是不可变类型,占用内存大小是固定的,当使用 + 拼接 2 个字符串时,生成一个新的字符串,那么就需要开辟一段新的空间,新空间的大小是原来两个字符串的大小之和

  • strings.Builder,bytes.Buffer 的内存是以倍数申请的

  • strings.Builder 和 bytes.Buffer 底层都是 []byte 数组,bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,而 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回

性能优化建议-空结构体

性能优化有时是时间和空间的平衡,之前的优化建议都是针对提高时间效率,而使用空结构体可以节省内存,空结构体 struct{} 不占据内存空间,可作为占位符使用。

比如实现简单的 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
	}
}
BenchmarkEmptyStructMap-16    	    2568	    444713 ns/op	  389413 B/op	     255 allocs/op
BenchmarkBoolMap-16    	    	    2502	    463557 ns/op	  427518 B/op	     319 allocs/op

性能优化建议- atomic 包

实际工程中,一定会遇到多线程编程的场景,比如实现一个多线程共用的计数器,有多种方式保证技术准确且线程安全,他们的性能存在差异。

// atomic
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()
}
BenchmarkAtomicAddOne-16    	122205795	         9.741 ns/op	       4 B/op	       1 allocs/op
BenchmarkMutexAddOne-16    	    50787201	         21.77 ns/op	      16 B/op	       1 allocs/op

原理:

  • 锁的实现是通过操作系统来实现,属于系统调用,atomic 操作是通过硬件实现的,效率比锁高很多

  • sync.Mutex 应该用来保护一段逻辑,不仅仅用于保护一个变量

  • 对于非数值系列,可以使用 atomic.Value,atomic.Value 能承载一个 interface{}

总结

本文主要提出了性能优化的多条建议,从 Benchmark、Slice、Map、字符串、空结构体及 atomic 包等方面进行了阐述,避免常见的性能陷阱可以保证大部分程序的性能,同时也要注意,针对普通应用代码,不要一味地追求程序的性能,应当在满足正确可靠、简洁清晰等质量要求的前提下提高程序性能。

你可能感兴趣的:(青训营笔记,性能优化,golang,服务器)