性能优化 | 青训营笔记

56 阅读5分钟

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

一、本堂课重点内容:

  • 关于Go相关的性能优化建议

二、详细知识点介绍:

高质量的代码能够完成功能,但是在大规模程序部署的场景,仅仅支持正常功能还不够,我们还要尽可能的提升性能,节省资源成本,接下来就主要介绍性能相关的建议

高性能代码为了效率会用到许多技巧,没有相关背景的人难以理解,不过有些基础性能问题是和语言本身相关的,接下来主要介绍这类内容,对应的调整对可读性和可维护性影响不大 在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的性能

有时候时间效率和空间效率可能对立,此时应当分析哪个更重要,作出适当的折衷。例如多花费一些内存来提高性能

Benchmark

在介绍具体建议之前,我们首先看一下如何评估性能,性能表现要用数据说话,实际情况和想象中的并不一定一致,要用数据来验证我们写的代码是否真的有性能提升 Go自带了性能评估工具

以计算斐波拉契数列的函数为例,分两个文件,fib.go编写函数代码,fib_test.go编写benchmark的逻辑,通过命令运行benchmark可以得到测试结果 -benchmem表示也统计内存信息

//待测试的Fib.go
func Fib(n int)  int{
   if n < 2 {
      return n
   }
   return Fib(n-1)+Fib(n-2)
}
//测试代码Fib_test.go
func BenchmarkFib10(b *testing.B) {
   for i := 0; i < b.N; i++ {
      Fib(10)
   }
}

命令行的命令是go test -bench=. -benchmem

测试结果:

image.png

这个BenchmarkFib10是测试的函数名;

这个-8就是表示GOMAXPROCS(1.5版本之后默认值为CPU核数)的值为8;

第二个数字是一共执行4798150次,即b.N的值;

第三个数字表示每次执行花费多少时间;

第四个数字表示每次执行申请了多大的内存;

第五个数字表示每次执行申请了几次内存

Slice

slice是go中最常用的结构,也很方便,那么在使用过程中有哪些点需要注意呢?

第一条建议就是预分配,尽可能在使用 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)
   }
}
//没有预分配内存的测试代码
func BenchmarkNoPreAlloc(b *testing.B) {
   for i := 0; i < b.N; i++ {
      NoPreAlloc(10)

   }
}
//预分配了内存的测试代码
func BenchmarkPreAlloc(b *testing.B) {
   for i := 0; i < b.N; i++ {
      PreAlloc(10)
   }
}

测试结果: image.png

对比看下两种情况的性能表现,上边是没有提供初始化容量信息,下边是设置了容量大小 结果中可以看出执行时间相差很多,预分配只有一次内存分配

那么为什么会出现这么大的性能差异呢?

首先我们看看slice的结构

type slice struct{
    array unsafe.Pointer
    len int
    cap int
}

切片本质是一个数组片段的描述

包括数组指针

片段的长度

片段的容量(不改变内存分配情况下的最大长度)

切片操作并不复制切片指向的元素

创建一个新的切片会复用原来切片的底层数组

以切片的append 为例,append时有两种场景: image.png 当 append 之后的长度小于等于 cap,将会直接利用原底层数组剩余的空间。

当 append 后的长度大于 cap 时,则会分配一块更大的区域来容纳新的底层数组。 因此,为了避免内存发生拷贝,如果能够知道最终的切片的大小,预先设置 cap 的值能够避免额外的内存分配,获得更好的性能

了解slice的基本结构之后,还有个问题需要注意

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

可使用 copy 替代 re-slice

Map

除了slice,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
   }
}
//没有预分配内存的测试代码
func BenchmarkNoPreAlloc(b *testing.B) {
   for i := 0; i < b.N; i++ {
      NoPreAlloc(10)

   }
}
//预分配内存的测试代码
func BenchmarkPreAlloc(b *testing.B) {
   for i := 0; i < b.N; i++ {
      PreAlloc(10)
   }
}

测试结果

image.png

所以不断向map中添加元素会直接触发map的扩容机制,提前分配好空间可以减少内存拷贝和Rehash的消耗,建议根据实际需求提前预估好需要的空间

字符串处理

编程过程中除了slice和map,平时很多编码功能都和字符串处理相关的,字符串处理也是高频操作,那么不同字符串处理方式的性能表现会有什么差异吗?

结论:使用+的拼接性能最差,strings.Builderbytes.Buffer相近,stringd.Buffer更快

image.png

空结构体

性能优化有时是时间和空间的平衡,之前提到的都是提高时间效率的点,对于空间上是否有优化的手段呢? 空结构体是节省内存空间的一个手段

image.png

image.png

atomic包

在工作中迟早会遇到多线程编程的场景,比如实现一个多线程共用的计数器,如何保证计数准确,线程安全,有不同的方式

//使用atomic包
type atomicCounter struct {
   i int32
}
func AtomicAddOne(c *atomicCounter)  {
   atomic.AddInt32(&c.i,1)
}

//使用锁
type muterCounter struct {
   i int32
   m sync.Mutex
}
func MuterAddOne(c *muterCounter)  {
   c.m.Lock()
   c.i++
   c.m.Unlock()
}
//atomic包的测试代码
func BenchmarkAtomicAddOne(b *testing.B) {
   for i := 0; i < b.N; i++ {
      AtomicAddOne(&atomicCounter{10})
   }
}
//锁的测试代码
func BenchmarkMuterAddOne(b *testing.B) {
   for i := 0; i < b.N; i++ {
      MuterAddOne(&muterCounter{10,sync.Mutex{}})
   }
}

运行结果:

image.png

结论:

image.png

三、课后个人总结:

总体来说,性能优化时要注意以下几点:

  • 避免常见的性能陷阱可以保证大部分的程序的性能
  • 普通应用代码,不要一味地追求程序的性能
  • 越高级的性能优化手段越容易出问题
  • 在满足正确可靠、简洁清晰的质量要求的前提下尽量提高程序的性能