这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天
1 性能调优建议
如何评估代码性能:Go语言提供了支持基准性能测试的benchmark工具。
1.1 Slice
切片本质是一个数组片段的描述,包括数组指针、片段长度以及片段容量:
type slice struct {
array unsafe.Pointer // 数组指针
len int // 片段长度
cap int // 片段容量
}
切片操作并不复制切片指向的元素,创建一个新的切片会复用原来切片的底层数组。
-
slice预分配内存,切片容量足够时直接讲元素放入对应的内存,容量不足时会先进行一次扩容操作,因此尽可能在使用make初始化切片时提供容量信息; -
大内存释放问题,由于在已有切片的基础上创建切片时,不会创建新的底层数组,新的切片依然会引用原底层数组,当原切片较大时,就会造成内存的浪费,我们可以使用
copy替代re-slice:// re-slice func GetLastBySlice(origin []int) []int { return origin[len(origin)-2:] } // copy func GetLastByCopy(origin []int) []int { result := make([]int, 2) copy(result, origin[len(origin)-2:]) return result }
1.2 Map
Map的性能调优方向和Slice类似,同样从预分配内存方向考虑:在不断向Map中添加元素时会触发扩容,提前分配好空间可以减少内存的拷贝以及Rehash的消耗,因此根据实际需求提前预估需要的空间是必要的。
1.3 String
使用strings.Builder或者bytes.Buffer进行字符串拼接的性能远远由于直接使用+进行拼接,性能表现上string.Buffer更快,其原因在于:
-
字符串在Go语言中是不可变类型,因此占用的内存大小是固定的;
-
使用
+每次都会重新分配内存; -
strings.Builder和bytes.Buffer的底层都是[]byte数组,内存分配策略不需要每次拼接都重新分配内存; -
bytes.Buffer在将[]byte数组转化为字符串时重新申请了一块空间,而strings.Builder是直接将[]byte数组转化为了字符串类型返回:// bytes.Buffer func (b *Buffer) String() string { if b == nil { return "<nil>" } return string(b.buf[b.off:]) } // strings.Builder func (b *Builder) String() string { return *(*string)(unsafe.Pointer(&b.buf)) }
在已知拼接的字符串长度的情况下,可以使用Grow(size)方法进一步提高字符串拼接的性能。
1.4 Struct
使用空结构体节省内存:空结构体struct{}实例不占据如何的内存空间,可以作为各种场景下的占位符:
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
}
}
使用场景:
- 实现
Set可以考虑用Map代替,例如:golang-set; - 只需要用到
Map的KEY,而不需要VALUE的场景;
1.5 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()
}
其原因在于:
- 锁的实现是通过操作系统实现的,属于系统调用,而原子操作是通过硬件实现的,因此效率更高;
sync.Mutex用于保护一段逻辑,不仅仅保护一个变量;- 对于非数值操作,可以使用
atomic.Value,能承载一个interface{};
2 性能调优实战
性能调优原则:
- 要依赖数据而不是猜测;
- 要定位最大瓶颈而不是细枝末节;
- 不要过早优化也不要过度优化;
2.1 性能分析工具pprof
- 可以知道应用在什么地方耗费了多少CPU资源和内存资源;
- 可以进行可视化的性能数据分析;
2.2 项目分析
func main() {
log.SetFlags(log.Lshortfile | log.LstdFlags)
log.SetOutput(os.Stdout)
runtime.GOMAXPROCS(1) // 限制CPU使用数
runtime.SetMutexProfileFraction(1) // 开启锁调用跟踪
runtime.SetBlockProfileRate(1) // 开启阻塞调用跟踪
go func() {
// 启动 HTTP server
if err := http.ListenAndServe(":6060", nil); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
// ...
}
访问:http://localhost:6060/debug/pprof/
2.2.1 CPU性能排查
采集10秒的数据
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"
top命令
- flat:当前函数本身的执行耗时
- flat%:flat在CPU总时间的占比
- sum%:上面每一行的flat%总和
- cum:当前函数本身加上其调用函数的总耗时
- cum%:cum在CPU总时间的占比
当函数没有调用其它函数时,flat和cum相等;
当flat等于0时,函数只有其它函数的调用;
list命令
由top命令的结果可知,Eat方法占用了很大的CPU资源,我们可以使用list Eat命令对Eat方法进行排查:
2.2.2 Heap堆内存排查
go tool pprof "http://localhost:6060/debug/pprof/heap"
和CPU排查的方法类似:使用top和list进行问题定位
2.2.3 协程排查
可以添加-http参数进行可视化分析:
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap"
在View中切换为Flame Graph,可以更直观地进行问题排查
排查到wolf下的Drink方法存在问题:
func (w *Wolf) Drink() {
log.Println(w.Name(), "drink")
// 问题
for i := 0; i < 10; i++ {
go func() {
time.Sleep(30 * time.Second)
}()
}
}
2.2.4 Mutex排查
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/mutex"
排查问题:
func (w *Wolf) Howl() {
log.Println(w.Name(), "howl")
// 问题
m := &sync.Mutex{}
m.Lock()
go func() {
time.Sleep(time.Second)
m.Unlock()
}()
m.Lock()
}
2.2.5 阻塞排查
go tool pprof "http://localhost:6060/debug/pprof/block"
pprof通过过滤策略会忽略一些点。