这是我参与「第五届青训营 」伴学笔记创作活动的第六天
一、本堂课重点内容:
- 关于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
测试结果:
这个
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)
}
}
测试结果:
对比看下两种情况的性能表现,上边是没有提供初始化容量信息,下边是设置了容量大小 结果中可以看出执行时间相差很多,预分配只有一次内存分配
那么为什么会出现这么大的性能差异呢?
首先我们看看slice的结构
type slice struct{
array unsafe.Pointer
len int
cap int
}
切片本质是一个数组片段的描述
包括数组指针
片段的长度
片段的容量(不改变内存分配情况下的最大长度)
切片操作并不复制切片指向的元素
创建一个新的切片会复用原来切片的底层数组
以切片的append 为例,append时有两种场景:
当 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)
}
}
测试结果
所以不断向map中添加元素会直接触发map的扩容机制,提前分配好空间可以减少内存拷贝和Rehash的消耗,建议根据实际需求提前预估好需要的空间
字符串处理
编程过程中除了slice和map,平时很多编码功能都和字符串处理相关的,字符串处理也是高频操作,那么不同字符串处理方式的性能表现会有什么差异吗?
结论:使用
+的拼接性能最差,strings.Builder和bytes.Buffer相近,stringd.Buffer更快
空结构体
性能优化有时是时间和空间的平衡,之前提到的都是提高时间效率的点,对于空间上是否有优化的手段呢? 空结构体是节省内存空间的一个手段
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{}})
}
}
运行结果:
结论:
三、课后个人总结:
总体来说,性能优化时要注意以下几点:
- 避免常见的性能陷阱可以保证大部分的程序的性能
- 普通应用代码,不要一味地追求程序的性能
- 越高级的性能优化手段越容易出问题
- 在满足正确可靠、简洁清晰的质量要求的前提下尽量提高程序的性能