day4 性能优化建议 | 青训营笔记

88 阅读3分钟

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

一.简介

  • 性能优化的前提是满足正确可靠,简洁清晰等质量因素
  • 性能优化是综合评估,有时候时间效率和空间效率可能对立(不能同时获得)
  • 针对Go语言特性,介绍Go相关的性能优化建议

二.性能优化建议-Benchmark

1.如何使用

  • 性能表现需要实际数据衡量
  • Go语言提供了支持基准性能测试的benchmark工具

go test -bench=. -benchmem
如果使用IDE的话,可以用IDE直接执行

如:

func Fib(n int) int {
   if n < 2 {
      return n
   }
   return Fib(n-1) + Fib(n-2)
}
func BenchmarkFib(b *testing.B) {
   fmt.Println(b.N)
   for n := 0; n < b.N; n++ {
      Fib(10)
   }
}

输出结果:
image.png

  • BenchmarkFib-12:前面是测试的函数名,后面是GOMAXPROCS的值为12(与cpu核数一致).
  • 5430518代表执行的次数,即b.N的值,前面的100~5430518这些数字为fmt.Println(b.N)输出的值.
  • 220.5 ns/op 表示每次执行花费的时间
  • 0 B/op 每次执行申请多大内存
  • 0 allocs/op 每次执行申请几次内存

2.slice预分配内存

  • 仅可能在使用make()初始化切片时提供容量信息
func Slice(size int) {
   data := make([]int, 0)
   for k := 0; k < size; k++ {
      data = append(data, k)
   }
}

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

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

测试:

func BenchmarkSlice(b *testing.B) {
   for i := 0; i < b.N; i++ {
      Slice(10)
   }
}
func BenchmarkSlice2(b *testing.B) {
   for i := 0; i < b.N; i++ {
      Slice3(10)
   }
}

func BenchmarkSlice3(b *testing.B) {
   for i := 0; i < b.N; i++ {
      Slice2(10)
   }
}

image.png 可以清晰得看到执行消耗的时间更少,分配的内存次数,分配占用的内存也更少.(按我的理解,理论上第三个应该是 1 callocs/op,不知道为什么为0,猜测make([]int, 0, 10)情况下,可能先预估内存大小,make([]int, 10)情况下,可能直接分配内存)
结构:

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

slice的append操作和相关知识在我的第一篇笔记中已经写了,就不在此赘述.

slice内存陷阱:
  • 在已有切片的基础上创建切片,不会创建新的底层数组
  • 场景:
    • 原数组较大,代码在原切片基础上新建小切片
    • 原底层数组在内存中有引用,得不到释放
  • 可使用copy替代re-slice

例子:

func GetLastSlice(origin []int) []int {
   return origin[len(origin)-2:]
}

func GetLastByCopy(origin []int) []int {
   result := make([]int, 2)
   copy(result, origin[len(origin)-2:])
   return result
}

测试代码:

func TestGetLastSlice(t *testing.T) {
   result := make([][]int, 0)
   for i := 0; i < 100; i++ {
      origin := make([]int, 128*1024)
      f(origin)
      result = append(result, GetLastSlice(origin))// 内存:+ 1 MB
   }
   printMem()
}

func TestGetLastByCopy(t *testing.T) {
   result := make([][]int, 0)
   for i := 0; i < 100; i++ {
      origin := make([]int, 128*1024)
      f(origin)
      result = append(result, GetLastByCopy(origin))// 内存:+ 2 * sizeof(int)
   }
   printMem()
}

func f(origin []int) {
   for i := range origin {
      origin[i] = i
   }
}

func printMem() {
   var rtm runtime.MemStats
   runtime.ReadMemStats(&rtm)
   fmt.Printf("%f MB\n", float64(rtm.Alloc)/1024./1024.)
}

运行命令:go test -run=. -v

image.png
可以看出,切片占用的内存是copy的近百倍,切片占用的内存等于100 * 1 MB,与预期结果相同.

3.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 n := 0; n < b.N; n++ {
      NoPreAlloc(100)
   }
}

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

image.png
可以看出预分配内存比需要再分配各项值更低.

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

4.使用strings.Builder

常见的字符串拼接字符串:

使用"+",使用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 {
   var buf bytes.Buffer
   for i := 0; i < n; i++ {
      buf.WriteString(str)
   }
   return buf.String()
}

测试代码:

func BenchmarkPlus(b *testing.B) {
   for i := 0; i < b.N; i++ {
      Plus(100, "hello ")
   }
}

func BenchmarkStrBuilder(b *testing.B) {
   for i := 0; i < b.N; i++ {
      StrBuilder(100, "hello ")
   }
}

func BenchmarkByteBuffer(b *testing.B) {
   for i := 0; i < b.N; i++ {
      ByteBuffer(100, "hello ")
   }
}

image.png
可以看出,"+"最差,bytes.Buffer其次,strings.Builder最好.

原因:

  • string是一个线程安全类型,因为它建立以后就不会再改变,每次执行"+"相当于创建一个新的string,strings.Builder使用一个 slice 来存储数据,数据实际上是被append到了其内部的 slice 上,strings.Builderbytes.Buffer底层都是[]byte数组.
  • strings.Builderbytes.Buffer都有内存扩容测量
  • bytes.Buffer转化为字符串时重新申请了一块内存
  • strings.Builder直接将底层的[]byte转换为了字符串类型返回

strings.Builder预分配

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

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

结果:
数据量小(100)时:
image.png
数据量大(1000)时:
image.png 可以看出,随数据增大,预分配的效果越好

5.使用空结构体节省内存

  • 空结构体struct{}实际不占用任何的内存空间
  • 可作为各种场景下的占位符使用
    • 节省资源 空结构体本身具有很强的语义,即这里不需要任何值,仅作为占位符

例如:因为Go语言没有set,可以用map[T]struct{}和map[T]bool充当set

func EmptyStructSet(n int) {
   data := make(map[int]struct{})
   for i := 0; i < n; i++ {
      data[i] = struct{}{}
   }
}

func BoolSet(n int) {
   data := make(map[int]bool)
   for i := 0; i < n; i++ {
      data[i] = false
   }
}

image.png
虽然空结构体struct{}比bool占用的空间更少,但是使用空结构体struct{}会更加麻烦.

6.atomic包

type atomicCounter struct {
   i int32
}

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

type mutexCounter struct {
   i int32 
   sync.Mutex
}

func MutexAddOne(c *mutexCounter){
   c.Lock()
   defer c.Unlock()
   c.i++
}
  • atomic比起mutex少了一个加锁,因为atomic是一个原子操作,是通过硬件实现,不用额外加锁,且效率比锁高.
  • Mutex操作系统实现,属于系统调用,而atomic包中的原子操作则由硬件实现。
  • sync.Mutex应该用来保护一段逻辑,而不是一个变量
  • 对于非数值操作,可以使用atomic.Value,能承载一个interface{}
  • atomic包中除了atomic.Value外,其余都是早期由汇编写成的
  • 原子操作:指不会在CPU执行的过程被中断的操作.

7.建议

  • 避免常见的性能陷阱可以保证大部分程序的性能
  • 不要一味地追求程序的性能,越高级的性能优化手段越容易出问题