这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天.
一.简介
- 性能优化的前提是满足正确可靠,简洁清晰等质量因素
- 性能优化是综合评估,有时候时间效率和空间效率可能对立(不能同时获得)
- 针对Go语言特性,介绍Go相关的性能优化建议
二.性能优化建议-Benchmark
1.如何使用
- 性能表现需要实际数据衡量
- Go语言提供了支持基准性能测试的benchmark工具
go test -bench=. -benchmem
如果使用IDE的话,可以用IDE直接执行
如:
func Fib(n int) int {
if n < 2 {
return n
}
return Fib(n-1) + Fib(n-2)
}
func BenchmarkFib(b *testing.B) {
fmt.Println(b.N)
for n := 0; n < b.N; n++ {
Fib(10)
}
}
输出结果:
- BenchmarkFib-12:前面是测试的函数名,后面是GOMAXPROCS的值为12(与cpu核数一致).
- 5430518代表执行的次数,即b.N的值,前面的100~5430518这些数字为fmt.Println(b.N)输出的值.
- 220.5 ns/op 表示每次执行花费的时间
- 0 B/op 每次执行申请多大内存
- 0 allocs/op 每次执行申请几次内存
2.slice预分配内存
- 仅可能在使用make()初始化切片时提供容量信息
func Slice(size int) {
data := make([]int, 0)
for k := 0; k < size; k++ {
data = append(data, k)
}
}
func Slice2(size int) {
data := make([]int, 10)
for k := 0; k < size; k++ {
data = append(data, k)
}
}
func Slice3(size int) {
data := make([]int, 0, 10)
for k := 0; k < size; k++ {
data = append(data, k)
}
}
测试:
func BenchmarkSlice(b *testing.B) {
for i := 0; i < b.N; i++ {
Slice(10)
}
}
func BenchmarkSlice2(b *testing.B) {
for i := 0; i < b.N; i++ {
Slice3(10)
}
}
func BenchmarkSlice3(b *testing.B) {
for i := 0; i < b.N; i++ {
Slice2(10)
}
}
可以清晰得看到执行消耗的时间更少,分配的内存次数,分配占用的内存也更少.(按我的理解,理论上第三个应该是 1 callocs/op,不知道为什么为0,猜测make([]int, 0, 10)情况下,可能先预估内存大小,make([]int, 10)情况下,可能直接分配内存)
结构:
type slice struct {
array unsafe.Pointer
len int
cap int
}
slice的append操作和相关知识在我的第一篇笔记中已经写了,就不在此赘述.
slice内存陷阱:
- 在已有切片的基础上创建切片,不会创建新的底层数组
- 场景:
- 原数组较大,代码在原切片基础上新建小切片
- 原底层数组在内存中有引用,得不到释放
- 可使用copy替代re-slice
例子:
func GetLastSlice(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 TestGetLastSlice(t *testing.T) {
result := make([][]int, 0)
for i := 0; i < 100; i++ {
origin := make([]int, 128*1024)
f(origin)
result = append(result, GetLastSlice(origin))// 内存:+ 1 MB
}
printMem()
}
func TestGetLastByCopy(t *testing.T) {
result := make([][]int, 0)
for i := 0; i < 100; i++ {
origin := make([]int, 128*1024)
f(origin)
result = append(result, GetLastByCopy(origin))// 内存:+ 2 * sizeof(int)
}
printMem()
}
func f(origin []int) {
for i := range origin {
origin[i] = i
}
}
func printMem() {
var rtm runtime.MemStats
runtime.ReadMemStats(&rtm)
fmt.Printf("%f MB\n", float64(rtm.Alloc)/1024./1024.)
}
运行命令:go test -run=. -v
可以看出,切片占用的内存是copy的近百倍,切片占用的内存等于100 * 1 MB,与预期结果相同.
3.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 n := 0; n < b.N; n++ {
NoPreAlloc(100)
}
}
func BenchmarkPreAlloc(b *testing.B) {
for n := 0; n < b.N; n++ {
PreAlloc(100)
}
}
可以看出预分配内存比需要再分配各项值更低.
- 不断向map中添加元素的操作会触发map的扩容
- 提前分配好空间可以减少内存拷贝和rehash的消耗
- 建议根据实际需求提前预估好需要的空间
4.使用strings.Builder
常见的字符串拼接字符串:
使用"+",使用strings.Builder,使用bytes.Buffer.
三种方法测试:
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 {
var buf bytes.Buffer
for i := 0; i < n; i++ {
buf.WriteString(str)
}
return buf.String()
}
测试代码:
func BenchmarkPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
Plus(100, "hello ")
}
}
func BenchmarkStrBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
StrBuilder(100, "hello ")
}
}
func BenchmarkByteBuffer(b *testing.B) {
for i := 0; i < b.N; i++ {
ByteBuffer(100, "hello ")
}
}
可以看出,"+"最差,bytes.Buffer其次,strings.Builder最好.
原因:
string是一个线程安全类型,因为它建立以后就不会再改变,每次执行"+"相当于创建一个新的string,strings.Builder使用一个slice来存储数据,数据实际上是被append到了其内部的slice上,strings.Builder和bytes.Buffer底层都是[]byte数组.strings.Builder和bytes.Buffer都有内存扩容测量bytes.Buffer转化为字符串时重新申请了一块内存strings.Builder直接将底层的[]byte转换为了字符串类型返回
strings.Builder预分配
func PreBuilder(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 NoPreBuilder(n int, str string) string {
var builder strings.Builder
for i := 0; i < n; i++ {
builder.WriteString(str)
}
return builder.String()
}
结果:
数据量小(100)时:
数据量大(1000)时:
可以看出,随数据增大,预分配的效果越好
5.使用空结构体节省内存
- 空结构体struct{}实际不占用任何的内存空间
- 可作为各种场景下的占位符使用
- 节省资源 空结构体本身具有很强的语义,即这里不需要任何值,仅作为占位符
例如:因为Go语言没有set,可以用map[T]struct{}和map[T]bool充当set
func EmptyStructSet(n int) {
data := make(map[int]struct{})
for i := 0; i < n; i++ {
data[i] = struct{}{}
}
}
func BoolSet(n int) {
data := make(map[int]bool)
for i := 0; i < n; i++ {
data[i] = false
}
}
虽然空结构体struct{}比bool占用的空间更少,但是使用空结构体struct{}会更加麻烦.
6.atomic包
type atomicCounter struct {
i int32
}
func AtomicAddOne(c *atomicCounter) {
atomic.AddInt32(&c.i,1)
}
type mutexCounter struct {
i int32
sync.Mutex
}
func MutexAddOne(c *mutexCounter){
c.Lock()
defer c.Unlock()
c.i++
}
atomic比起mutex少了一个加锁,因为atomic是一个原子操作,是通过硬件实现,不用额外加锁,且效率比锁高.Mutex由操作系统实现,属于系统调用,而atomic包中的原子操作则由硬件实现。- sync.Mutex应该用来保护一段逻辑,而不是一个变量
- 对于非数值操作,可以使用atomic.Value,能承载一个interface{}
atomic包中除了atomic.Value外,其余都是早期由汇编写成的- 原子操作:指不会在CPU执行的过程被中断的操作.
7.建议
- 避免常见的性能陷阱可以保证大部分程序的性能
- 不要一味地追求程序的性能,越高级的性能优化手段越容易出问题