这是我参与「第三届青训营 -后端场」笔记创作活动的第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中的-2即GOPMAXPROCS,在go1.5版本后默认等于cpu核数,可通过-cpu参数进行更改,比如-cpu=1,2,3,43275048表示总共执行的次数,即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类型的数据结构如下:
| data | len |
|---|---|
| 指向内存中字符串开始位置的指针 | 表示字符串的字节(非字符)个数 |
- golang将string类型分配到只读内存段,因此不能通过下标的方式对内容进行修改
- 多个string变量可共用统一字符串的某个部分,即多个string的
data域指向同一块内存空间的某个位置 - 如需改动字符串的内容,需要开辟新的内存空间
优化思路
结合go语言string数据结构的特点,我们意识到对string的优化离不开对内存操作的优化。
在日常的使用中,结合go语言的特点,人们通常对字符串的操作是直接用运算符来进行,比如拼接两个字符串采用+,然而经过基准测试可以发现,这是一种非常低效的方法。那什么是更佳的方法呢?其实,在go语言标准库里的strings包和bytes包就提供了这样的方法——strings.Builder和bytes.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/op和allocs/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.memmove和0.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.Builder和bytes.Buffer等,当然它的本质一定程度也是在避免对内存的低效操作啦~
那这次的笔记分享就到这里了,至于在对strings.Builder采用预分配策略遇到的“负优化”问题,还需要接下来继续探讨和研究,也恳请大佬能给我指点迷津!