go语言程序性能优化的思考与实践 | 青训营笔记

1,725 阅读9分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第1篇笔记

前言

性能优化可以说是软件开发中必不可少的一环,今天我想就课堂上的内容结合自己的思考感悟就go语言程序的性能调优展开谈一谈。

go语言程序性能优化

简介

  • 性能优化的前提是满足正确可靠、简洁清晰等质量因素。
  • 性能优化是综合评估与利弊权衡,有时候时间效率与空间效率是对立关系。
  • 针对go语言特性,介绍go语言相关的性能优化方法。

基准测试之benchmark

go语言的标准库提供了相应的测试框架testing,其中也包含了基准测试benchmark的能力。

benchmark示例

以斐波那契数列为例,创建fib.go文件写入如下内容:

func Fib(n int) int {
	if n < 2 {
		return n
	}
	return Fib(n-1) + Fib(n-2)
}

再创建fib_test.go文件写入用于测试的代码,在go语言中,测试文件应以xxx_test.go形式命名,测试函数应以TestXxx/BenchmarkXxx的形式命名,Xxx为被测试函数名,fib_test.go文件内容如下:

import "testing"

func BenchmarkFib10(b *testing.B) {
        //运行Fib函数b.N次
	for i := 0; i < b.N; i++ {
		Fib(10)
	}
}

测试函数写好后,在测试文件目录下打开终端运行如下命令即可进行基准测试:

go test -bench=. -benchmem
  • -bench=.表示在当前目录进行基准测试
  • -benchmem表示统计内存信息

运行结果如下:

goos: linux
goarch: amd64
pkg: byteDance/5_8
cpu: Intel(R) Core(TM) i5-4210H CPU @ 2.90GHz
BenchmarkFib10-2         3275048               338.8 ns/op             0 B/op          0 allocs/op
PASS
ok      byteDance/5_8   1.510s
  • BenchmarkFib10-2中的-2GOPMAXPROCS,在go1.5版本后默认等于cpu核数,可通过-cpu参数进行更改,比如-cpu=1,2,3,4
  • 3275048表示总共执行的次数,即b.N的值
  • 338.8 ns/op表示每次执行耗时
  • 0 B/op表示每次执行申请的内存大小
  • 0 allocs/op表示每次执行分配内存的次数

性能优化之slice

使用slice时进行适当的预分配提高性能,代码对比如下:

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

func PreAlloc(size int) {
	data := make([]int, 0, size)//预分配
	for i := 0; i < size; i++ {
		data = append(data, i)
	}
}
  • PreAlloc函数里对切片的cap进行了预分配,cap是切片隐含的一个属性,表示切片的最大容量

对两个函数采用同样的基准测试逻辑:

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

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

测试所用命令如下:

go test -bench="Alloc$" -benchmem
  • "-bench=Alloc$"表示测试对象只包括以Alloc结尾的

基准测试结果如下:

BenchmarkNoPreAlloc-2            1872620               599.9 ns/op          1016 B/op          7 allocs/op
BenchmarkPreAlloc-2              6538225               180.7 ns/op           416 B/op          1 allocs/op
PASS
ok      byteDance/5_8   3.954s
  • 可以发现,两种策略每次执行所耗费的时间差距较大,采用预分配策略使得执行速度提高3倍多
  • 预分配策略每次执行所申请的内存大小仅为416B,而没有预分配的策略每次执行申请的内存达到1016B
  • 预分配策略每次执行只申请1次内存,而没有预分配的策略每次执行要申请7次内存之多

性能优化之map

同理,map也可采用类似的优化策略,测试代码与slice同理,不同的地方如下:

...
    data := make(map[int]int)
...

...
    data := make(map[int]int, size)
...

测试逻辑与slice采用同样的方式,不同的是将size大小作了调整,如下:

...
    NoPreAlloc(30)
...

...
    PreAlloc(30)
...

测试结果如下:

BenchmarkNoPreAlloc-2             340780              3303 ns/op            2218 B/op          6 allocs/op
BenchmarkPreAlloc-2               895104              1300 ns/op            1166 B/op          1 allocs/op
PASS
ok      byteDance/5_8/map_test  3.618s
  • 可见,预分配策略同样发挥了相当显著的作用

性能优化之string

string这种数据类型不同于slice和map,因此它的优化思路也略有不同。我们不妨先认识一下go语言中string的底层实现。

string的底层实现

string类型的数据结构如下:

datalen
指向内存中字符串开始位置的指针表示字符串的字节(非字符)个数
  • golang将string类型分配到只读内存段,因此不能通过下标的方式对内容进行修改
  • 多个string变量可共用统一字符串的某个部分,即多个string的data域指向同一块内存空间的某个位置
  • 如需改动字符串的内容,需要开辟新的内存空间

优化思路

结合go语言string数据结构的特点,我们意识到对string的优化离不开对内存操作的优化。

在日常的使用中,结合go语言的特点,人们通常对字符串的操作是直接用运算符来进行,比如拼接两个字符串采用+,然而经过基准测试可以发现,这是一种非常低效的方法。那什么是更佳的方法呢?其实,在go语言标准库里的strings包和bytes包就提供了这样的方法——strings.Builderbytes.Buffer

口说无凭,具体写个测试来实践实践吧!代码如下:

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

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

func BytesBuffer(n int, str string) string {
	var buffer bytes.Buffer
	for i := 0; i < n; i++ {
		buffer.WriteString(str)
	}
	return buffer.String()
}

测试逻辑如下:

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

func BenchmarkStringsBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		StringsBuilder(1000, "¥")
	}
}
func BenchmarkBytesBuffer(b *testing.B) {
	for i := 0; i < b.N; i++ {
		BytesBuffer(1000, "¥")
	}
}
  • 采用相同的测试逻辑,对字符累加1000次

测试结果如下:

BenchmarkPlus-2                     1716            696485 ns/op         1602936 B/op        999 allocs/op
BenchmarkStringsBuilder-2         172130              6532 ns/op            8440 B/op         11 allocs/op
BenchmarkBytesBuffer-2             97269             11586 ns/op           12464 B/op          8 allocs/op
PASS
ok      byteDance/5_8/string_test       7.158s

  • 可以发现,采用+号拼接字符串的效率明显低于另两种方法,效率甚至慢了数十倍
  • 而对于另外两种方法,它们也各有特点,其中strings.Builder方法每次操作会申请内存的次数会更多,而bytes.Buffer方法每次操作申请的内存会更大,但从执行效率来讲,strings.Builder会更胜一筹

另,通过将修改为$以后(其余参数不变),再进行测试会发现一些微妙的变化,如下:

BenchmarkPlus-2                     3513            326302 ns/op          530275 B/op        999 allocs/op
BenchmarkStringsBuilder-2         314575              4014 ns/op            3320 B/op          9 allocs/op
BenchmarkBytesBuffer-2            137330              8859 ns/op            3248 B/op          6 allocs/op
PASS
ok      byteDance/5_8/string_test       5.968s
  • 可以发现,strings.Builder方法这时除了allocs/op更大以外,B/op字段也更大了,这一点与之前的测试有所不同
  • 不难想到,以上的微妙变化与$字符在内存中占用不同的字节数有关

关于string优化的进一步探索

前面有谈到预分配的优化策略,那能不能用到string的优化当中呢?经过分析发现,对string进行的拼接操作本质上也是对内存空间的操作,那运用上预分配策略是否能奏效呢?我们不妨试一试!

所改动部分的代码如下:

func PreAllocStringsBuilder(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 PreAllocBytesBuffer(n int, str string) string {
	var buffer bytes.Buffer
	
        //预分配
        buffer.Grow(n * len(str))
	
        for i := 0; i < n; i++ {
		buffer.WriteString(str)
	}
	return buffer.String()
}
  • 使用Grow方法对内存进行预分配

测试逻辑不变,依然对$字符串进行1000次操作,得到的测试结果如下:

BenchmarkPlus-2                             4584            295038 ns/op          530274 B/op        999 allocs/op
BenchmarkStringsBuilder-2                 268663              4427 ns/op            3320 B/op          9 allocs/op
BenchmarkBytesBuffer-2                    146083              8803 ns/op            3248 B/op          6 allocs/op
BenchmarkPreAllocStringsBuilder-2         202309              5794 ns/op            1024 B/op          1 allocs/op
BenchmarkPreAllocBytesBuffer-2            165926              7960 ns/op            2048 B/op          2 allocs/op
PASS
ok      byteDance/5_8/string_test       10.494s
  • 得到的测试结果令我们既惊喜又惊讶,惊喜的是bytes.Buffer运用上预分配策略达到了预期的效果,而strings.Builder运用上预分配策略却不如预期,反而在效率上退步了!
  • 可以发现,采用预分配策略后,B/opallocs/op都得到明显进步,那么致使strings.Builder采用预分配策略后性能退步的原因是什么呢?因此我对这个问题进行了摸索,见下方内容

StringsBuilder“负优化”的摸索与尝试

使用pprof性能分析工具对这两对测试逻辑进行分析。

无预分配策略的结果如下:

File: string_test.test
Type: cpu
Time: May 30, 2022 at 2:13pm (CST)
Duration: 2.21s, Total samples = 2.19s (98.93%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top 
Showing nodes accounting for 1710ms, 78.08% of 2190ms total
Dropped 34 nodes (cum <= 10.95ms)
Showing top 10 nodes out of 87
      flat  flat%   sum%        cum   cum%
     960ms 43.84% 43.84%     1710ms 78.08%  strings.(*Builder).WriteString (inline)
     160ms  7.31% 51.14%     1870ms 85.39%  byteDance/5_8/string_test.StringsBuilder
     140ms  6.39% 57.53%      150ms  6.85%  strings.(*Builder).copyCheck
     100ms  4.57% 62.10%      100ms  4.57%  runtime.futex
      80ms  3.65% 65.75%      590ms 26.94%  runtime.growslice
      70ms  3.20% 68.95%      370ms 16.89%  runtime.mallocgc
      70ms  3.20% 72.15%       70ms  3.20%  runtime.memclrNoHeapPointers
      50ms  2.28% 74.43%       70ms  3.20%  runtime.scanobject
      40ms  1.83% 76.26%       40ms  1.83%  runtime.madvise
      40ms  1.83% 78.08%       40ms  1.83%  runtime.memmove

有预分配策略的结果如下:

File: string_test.test
Type: cpu
Time: May 30, 2022 at 2:13pm (CST)
Duration: 2.22s, Total samples = 2.14s (96.28%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 2.04s, 95.33% of 2.14s total
Dropped 36 nodes (cum <= 0.01s)
Showing top 10 nodes out of 37
      flat  flat%   sum%        cum   cum%
     0.83s 38.79% 38.79%      1.51s 70.56%  strings.(*Builder).WriteString
     0.52s 24.30% 63.08%      0.52s 24.30%  runtime.memmove
     0.40s 18.69% 81.78%      1.99s 92.99%  byteDance/5_8/string_test.PreAllocStringsBuilder
     0.11s  5.14% 86.92%      0.13s  6.07%  strings.(*Builder).copyCheck (inline)
     0.07s  3.27% 90.19%      0.07s  3.27%  runtime.asyncPreempt
     0.03s  1.40% 91.59%      0.03s  1.40%  runtime.futex
     0.02s  0.93% 92.52%      0.02s  0.93%  runtime.memclrNoHeapPointers
     0.02s  0.93% 93.46%      0.02s  0.93%  runtime.nanotime
     0.02s  0.93% 94.39%      0.02s  0.93%  runtime.nanotime1
     0.02s  0.93% 95.33%      0.02s  0.93%  runtime.osyield
  • 可以发现,问题出在0.52s 24.30% 63.08% 0.52s 24.30% runtime.memmove0.40s 18.69% 81.78% 1.99s 92.99% byteDance/5_8/string_test.PreAllocStringsBuilder这两行

在pprof中运行list命令,继续对比分析StringsBuilder函数和PreAllocStringsBuilder函数,结果如下:

(pprof) list StringsBuilder
...
         .          .     16:func StringsBuilder(n int, str string) string {
         .          .     17:   var builder strings.Builder
     150ms      150ms     18:   for i := 0; i < n; i++ {
      10ms      1.72s     19:           builder.WriteString(str)
         .          .     20:   }
         .          .     21:   return builder.String()
         .          .     22:}
...

(pprof) list PreAllocStringsBuilder
...
         .          .     32:func PreAllocStringsBuilder(n int, str string) string {
      10ms       10ms     33:   var builder strings.Builder
      20ms       80ms     34:   builder.Grow(n * len(str))
     330ms      350ms     35:   for i := 0; i < n; i++ {
      40ms      1.55s     36:           builder.WriteString(str)
         .          .     37:   }
         .          .     38:   return builder.String()
         .          .     39:}
...
  • 对比发现,builder.WriteString的耗时满足预期,但是对于同样一段代码for i := 0; i < n; i++ {,且传入同样的参数,为什么PreAllocStringsBuilder中的耗时多出那么多,让我摸不着头脑T_T

另,对于PreAllocStringsBuilder中的runtime.memmove为什么比StringsBuilder中的多出那么多,暂时也没有头绪,不太能理解采用预分配策略为什么会导致runtime.memmove更多了...T_T,希望能有大佬帮我解答疑惑吧!

总结

在前述内容里,我们探讨了go语言程序性能优化的一个思路——预分配策略,因为程序的运行离不开对内存的操作,如何更好更高效地操作内存必然会一定程度得提升程序运行的效率。

另外对于像string这样的数据类型,也有特殊的优化思路,针对于字符串的拼接操作,可以使用更高效的库函数或者工具如strings.Builderbytes.Buffer等,当然它的本质一定程度也是在避免对内存的低效操作啦~

那这次的笔记分享就到这里了,至于在对strings.Builder采用预分配策略遇到的“负优化”问题,还需要接下来继续探讨和研究,也恳请大佬能给我指点迷津!

参考链接

geektutu.com/post/hpg-be…