在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的效率
1.slice 预分配内存
在尽可能的情况下,在使用
make()
初始化切片时提供容量信息,特别是在追加切片时
测试程序:
package benchslice // 没有指定切片的容量 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 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 }
单元测试:
package benchslice import ( "math/rand" "runtime" "testing" "time" ) 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) } } 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.) } func testGetLast(t *testing.T, f func([]int) []int) { result := make([][]int, 0) for k := 0; k < 100; k++ { origin := generateWithCap(128 * 1024) // 1M result = append(result, f(origin)) } printMem(t) _ = result } func TestLastBySlice(t *testing.T) { testGetLast(t, GetLastBySlice) } func TestLastByCopy(t *testing.T) { testGetLast(t, GetLastByCopy) }
测试结果:
goos: windows goarch: amd64 cpu: AMD Ryzen 5 3500U with Radeon Vega Mobile Gfx BenchmarkNoPreAlloc BenchmarkNoPreAlloc-8 860905 1165 ns/op BenchmarkPreAlloc BenchmarkPreAlloc-8 3290400 389.3 ns/op PASS
可以看到,明显提供容量信息的切片拥有更好的性能🧇
切片本质是一个数组片段的描述,包括了数组的指针,这个片段的长度和容量(不改变内存分配情况下的最大长度)
切片操作并不复制切片指向的元素,创建一个新的切片会复用原来切片的底层数组,因此切片操作是非常高效的
切片有三个属性,指针(ptr)、长度(len) 和容量(cap)。append 时有两种场景:
当 append 之后的长度小于等于 cap,将会直接利用原底层数组剩余的空间
当 append 后的长度大于 cap 时,则会分配一块更大的区域来容纳新的底层数组
因此,为了避免内存发生拷贝,如果能够知道最终的切片的大小,预先设置 cap 的值能够获得最好的性能
另一个陷阱:大内存得不到释放
在已有切片的基础上进行切片,不会创建新的底层数组。因为原来的底层数组没有发生变化,内存会一直占用,直到没有变量引用该数组
因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放
推荐的做法,使用 copy 替代 re-slice
2.map 预分配内存
测试程序:
package benchmap // 没有预分配内存 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 } }
单元测试:
package benchmap import "testing" func BenchmarkNoPreAlloc(b *testing.B) { for n := 0; n < b.N; n++ { NoPreAlloc(1000) } } func BenchmarkPreAlloc(b *testing.B) { for n := 0; n < b.N; n++ { PreAlloc(1000) } }
执行单元测试,可以看到结果如下:
goos: windows goarch: amd64 cpu: AMD Ryzen 5 3500U with Radeon Vega Mobile Gfx BenchmarkNoPreAlloc BenchmarkNoPreAlloc-8 10000 175810 ns/op BenchmarkPreAlloc BenchmarkPreAlloc-8 15987 76016 ns/op PASS
原理
不断向 map 中添加元素的操作会触发 map 的扩容
根据实际需求提前预估好需要的空间
提前分配好空间可以减少内存拷贝和 Rehash 的消耗
3.使用 strings.Builder
常见的字符串拼接方式
+
strings.Builder
bytes.Buffer
strings.Builder 最快,bytes.Buffer 较快,+ 最慢
测试代码:
package benchstring import ( "bytes" "strings" ) 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 { buf := new(bytes.Buffer) for i := 0; i < n; i++ { buf.WriteString(str) } return buf.String() } func PreStrBuilder(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 PreByteBuffer(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() }
单元测试:
package benchstring import "testing" func BenchmarkPlus(b *testing.B) { for n := 0; n < b.N; n++ { Plus(1000, "string") } } func BenchmarkStrBuilder(b *testing.B) { for n := 0; n < b.N; n++ { StrBuilder(1000, "string") } } func BenchmarkByteBuffer(b *testing.B) { for n := 0; n < b.N; n++ { ByteBuffer(1000, "string") } } func BenchmarkPreStrBuilder(b *testing.B) { for n := 0; n < b.N; n++ { PreStrBuilder(1000, "string") } } func BenchmarkPreByteBuffer(b *testing.B) { for n := 0; n < b.N; n++ { PreByteBuffer(1000, "string") } }
单元测试结果:
goos: windows goarch: amd64 cpu: AMD Ryzen 5 3500U with Radeon Vega Mobile Gfx BenchmarkPlus BenchmarkPlus-8 1296 1018434 ns/op BenchmarkStrBuilder BenchmarkStrBuilder-8 56254 19591 ns/op BenchmarkByteBuffer BenchmarkByteBuffer-8 44292 25731 ns/op BenchmarkPreStrBuilder BenchmarkPreStrBuilder-8 84880 14419 ns/op BenchmarkPreByteBuffer BenchmarkPreByteBuffer-8 62517 18126 ns/op PASS
速度差距一目了然
字符串在 Go 语言中是不可变类型,占用内存大小是固定的,当使用 + 拼接 2个字符串时,生成一个新的字符串,那么就需要开辟一段新的空间,新空间的大小是原来两个字符串的大小之和
strings.Builder,bytes.Buffer 的内存是以倍数申请的。strings.Builder 和 bytes.Buffer底层都是 []byte 数组,bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,而strings.Builder 直接将底层的 []byte 转换成了字符串类型返回
4.使用空结构体节省内存
空结构体不占据内存空间,可作为占位符使用
比如实现简单的 Set
Go 语言标准库没有提供 Set 的实现,通常使用 map 来代替。对于集合场景,只需要用到 map 的键而不需要值
测试程序:
package benchstruct func EmptyStructMap(n int) { m := make(map[int]struct{}) for i := 0; i < n; i++ { m[i] = struct{}{} } } func BoolMap(n int) { m := make(map[int]bool) for i := 0; i < n; i++ { m[i] = false } }
单元测试:
package benchstruct import "testing" func BenchmarkEmptyStructMap(b *testing.B) { for n := 0; n < b.N; n++ { EmptyStructMap(10000) } } func BenchmarkBoolMap(b *testing.B) { for n := 0; n < b.N; n++ { BoolMap(10000) } }
测试结果:
goos: windows goarch: amd64 cpu: AMD Ryzen 5 3500U with Radeon Vega Mobile Gfx BenchmarkEmptyStructMap BenchmarkEmptyStructMap-8 988 1531114 ns/op BenchmarkBoolMap BenchmarkBoolMap-8 693 1673018 ns/op PASS
5.使用 atomic 包
原理
锁的实现是通过操作系统来实现,属于系统调用,atomic 操作是通过硬件实现的,效率比锁高很多
sync.Mutex 应该用来保护一段逻辑,不仅仅用于保护一个变量
对于非数值系列,可以使用 atomic.Value,atomic.Value 能承载一个 interface{}
测试代码:
package benchatomic import ( "sync" "sync/atomic" ) type atomicCounter struct { i int32 } func AtomicAddOne(c *atomicCounter) { atomic.AddInt32(&c.i, 1) } type mutexCounter struct { i int32 m sync.Mutex } func MutexAddOne(c *mutexCounter) { c.m.Lock() c.i++ c.m.Unlock() }
单元测试:
package benchatomic import ( "testing" ) func BenchmarkAtomicAddOne(b *testing.B) { for n := 0; n < b.N; n++ { var counter = atomicCounter{} AtomicAddOne(&counter) } } func BenchmarkMutexAddOne(b *testing.B) { for n := 0; n < b.N; n++ { var counter = mutexCounter{} MutexAddOne(&counter) } }
单元测试结果:
goos: windows goarch: amd64 cpu: AMD Ryzen 5 3500U with Radeon Vega Mobile Gfx BenchmarkAtomicAddOne BenchmarkAtomicAddOne-8 46656297 27.71 ns/op BenchmarkMutexAddOne BenchmarkMutexAddOne-8 23804658 65.13 ns/op PASS
6.总结
- 避免常见的性能陷阱可以保证大部分程序的性能
- 针对普通应用代码,不要一味地追求程序的性能,应当在满足正确可靠、简洁清晰等质量要求的前提下提高程序性能