(五)性能优化建议|青训营笔记

152 阅读5分钟

1.5 性能优化建议

1.5.1 Go基准性能测试工具: benchmark

b *testing.B表示benchmark测试

指令:go test -bench=. -benchmem filename ,点表示当前package

➜  lessonThree go test -bench=. -benchmem fib_benchmark_test.go
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
BenchmarkFib10-8         3949941               307.6 ns/op             0 B/op          0 allocs/op
PASS
ok      command-line-arguments  1.886s

· BenchmarkFib10-8表示测试名为BenchmarkFib10,-8为GOMAXSPROCX的值为8,该值在v>1.5后默认为CPU核数

CPU核数可通过-CPU=指定,且支持列表如-CPU=2,4

· 3949941表示b.N的值,即执行次数

b.N的确定:初始值为1,如果一次运行能够在1s内完成,则增加该值后继续执行,大概是以不到2倍的速度增加。

· 307.6 ns/op为每次执行花费时间,0 B/op为每次执行申请内存大小,0 allocs/op为每次执行申请内存次数

1.5.2 slice

  • 预分配内存

尽可能在make()初始化切片时提供容量信息,提前分配消耗时间为原来的1/3左右

/*
BenchmarkPreAlloc-8     139949324                9.056 ns/op          44 B/op          0 allocs/op
PASS
ok      command-line-arguments  3.341s
*/
func NoPreAlloc(size int) {
  data := make([]int, 0)
  for k := 0; k < size; k++ {
    data = append(data, k)
  }
}
​
/*
BenchmarkPreAlloc-8     303202378                3.694 ns/op           8 B/op          0 allocs/op
PASS
ok      command-line-arguments  2.061s
*/
func PreAlloc(size int) {
  data := make([]int, 0, size)
  for k := 0; k < size; k++ {
    data = append(data, k)
  }
}

slice底层数据结构:对数组片段的描述,包含数组指针、片段长度(len)、片段容量(cap)等。

切片操作不复制原来元素,复用原来底层数组:对slice的操作回导致数组的变化

  • copy代替re-slice

存在问题:基于提到的slice的特点,如果原始切片较大,创造了小的切片,在使用过程中原始数组存在引用,一直不会被释放。

代码如下:

func GetLastBySlice(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 generateWithCap(n int) []int {
  rand.Seed(time.Now().UnixNano())
  nums := make([]int, 0, n)
  for i := 0; i < n; i++ {
    nums = append(nums, rand.Int())
  }
  return nums
}
func printMem(t *testing.T) {
  t.Helper()
  var rtm runtime.MemStats
  runtime.ReadMemStats(&rtm)
  t.Logf("%.2f MB", float64(rtm.Alloc)/1024./1024.)
}

测试结果如下:

➜  lessonThree go test -run=. -v
=== RUN   TestLastCharsBySlice
    get_last_test.go:43: 100.14 MB
--- PASS: TestLastCharsBySlice (0.28s)
=== RUN   TestLastCharsByCopy
    get_last_test.go:43: 3.14 MB
--- PASS: TestLastCharsByCopy (0.26s)
PASS
ok      lessonThree     1.134s

明显可以看到,使用copy方法节省了大量内存。

优化:显式地调用GC进行空间回收runtime.GC(),结果如下

➜  lessonThree go test -run=. -v
=== RUN   TestLastCharsBySlice
    get_last_test.go:44: 100.14 MB
--- PASS: TestLastCharsBySlice (0.31s)
=== RUN   TestLastCharsByCopy
    get_last_test.go:44: 0.15 MB
--- PASS: TestLastCharsByCopy (0.28s)
PASS
ok      lessonThree     1.397s

1.5.3 map

  • 预分配内存

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

1.5.4 string

  • 对比使用+, string.Builder,bytes.Buffer的情况,结果如下:

    BenchmarkPlus-8                       22          58738147 ns/op        530997411 B/op     10021 allocs/op
    BenchmarkStrBuilder-8              11007            110900 ns/op          505842 B/op         24 allocs/op
    BenchmarkByteBuffer-8               9998            115830 ns/op          423538 B/op         13 allocs/op
    

    分析:字符串在go中是不可变类型,大小固定。

    使用+性能最差,因为每次都会重新分配内存。

    string.Builder,bytes.Buffer差不多,底层都是[]byte数组。strings.Buffer更快,最快的应该是preByteConcat。因为这几个在分配内存时都是以2的指数分配,有一定预留,可减少分配次数。

    strings.Builderbytes.Buffer 底层都是 []byte 数组,但 strings.Builder 性能比 bytes.Buffer 略快约 10% 。一个比较重要的区别在于,bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,而 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回了回来。

    func preByteConcat(n int, str string) string {
      buf := make([]byte, 0, n*len(str))
      for i := 0; i < n; i++ {
        buf = append(buf, str...)
      }
      return string(buf)
    }
    
  • 综合一般使用string.Builder。并且`strBuilderbyteBuffer都提供了预分配内存的方式 Grow(),优化后:

    func StrBuilderWithGrow(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 ByteBufferWithGrow(n int, str string) string {
      buf := new(bytes.Buffer)
      buf.Grow(n * len(str))
      for i := 0; i < n; i++ {
        buf.WriteString(str)
      }
      return buf.String()
    }
    
    BenchmarkPlus-8                               22          53229208 ns/op        530997206 B/op     10020 allocs/op
    BenchmarkStrBuilder-8                      10000            108898 ns/op          505842 B/op         24 allocs/op
    BenchmarkStrBuilderWithGrow-8              24224             49554 ns/op          106496 B/op          1 allocs/op
    BenchmarkByteBuffer-8                       9909            104994 ns/op          423537 B/op         13 allocs/op
    BenchmarkByteBufferWithGrow-8              16585             76483 ns/op          212993 B/op          2 allocs/op
    

1.5.5 struct

  • 使用空结构体节省内存,空结构体实例不占用任何内存空间

    func main() {
    fmt.Println(unsafe.Sizeof(struct{}{}))
    }
    /*
    0
    */
    
  • 核心作用就是作为占位符使用 节省空间 本身就具备很强的语义

    1. 实现Set

      通常用map来代替,但其实只需要key值,因此浪费了空间。因此,用struct实现set,可将value设为空

      type Set map[string]struct{}
      ​
      func (s Set) Has(key string) bool {
        _, ok := s[key]
        return ok
      }
      ​
      func (s Set) Add(key string) {
        s[key] = struct{}{}
      }
      ​
      func (s Set) Delete(key string) {
        delete(s, key)
      }
      ​
      func main() {
        s := make(Set)
        s.Add("Tom")
        s.Add("Sam")
        fmt.Println(s.Has("Tom"))
        fmt.Println(s.Has("Jack"))
      }
      

      更完整的开源实现

    2. 不发送数据的信道(channel)

      只用来通知子协程(goroutine)执行任务,或只用来控制协程并发度

      func worker(ch chan struct{}) {
        <-ch
        fmt.Println("do something")
        close(ch)
      }
      ​
      func main() {
        ch := make(chan struct{})
        go worker(ch)
        ch <- struct{}{}
      }
      

1.5.6 atomic包

应用于多线程的时候

相较于mutex使用系统调用加锁,atomic使用硬件,速度更快,效率更高。

mutex应适用于保护一段逻辑而不仅仅是一个变量。