2.1 简介
性能调优原则
- 要依靠数据不是猜测
- 要定位最大瓶颈而不是细枝末节
- 不要过早优化
- 不要过度优化
2.2 性能分析工具 pprof
说明
- 希望知道应用在什么地方耗费了多少CPU、Memory
- pprof是用于可视化和分析性能分析数据的工具
搭建pprof实践项目
-
该项目提前埋入一些炸弹代码,产生可观测的性能问题
- 程序会占用2G内存;
- 程序占用CPU最高约100%(总量按“核数” * 100%来算);
- 程序不涉及网络或文件读写;
- 程序除了吃资源以外没有任何其他危险操作。
-
也可在终端中运行
go get便可获取,注意加上-d参数,避免下载后自动安装go get -d github.com/wolfogre/go-pprof-practice cd $GOPATH/src/github.com/wolfogre/go-pprof-practice接着编译一下这个程序并运行,你不必担心依赖问题,这个程序没有任何外部依赖。
go build ./go-pprof-practice该项目的结构如下:
运行其中的main.go程序,运行后注意查看一下资源是否吃紧,机器能否抗的住,坚持一分钟,如果没问题,咱们再进行下一步。
main.go文件的内容如下:
package main
import (
// 略
_ "net/http/pprof" // 会自动注册 handler 到 http server,方便通过 http 接口获取程序运行采样报告
// 略
)
func main() {
// 略
runtime.GOMAXPROCS(1) // 限制 CPU 使用数,避免过载
runtime.SetMutexProfileFraction(1) // 开启对锁调用的跟踪
runtime.SetBlockProfileRate(1) // 开启对阻塞操作的跟踪
go func() {
// 启动一个 http server,注意 pprof 相关的 handler 已经自动注册过了
if err := http.ListenAndServe(":6060", nil); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
// 略
}
控制台里在不停的打印日志,都是一些“猫狗虎鼠在不停地吃喝拉撒”之类的,不用细看。
2.2.1.1 排查CPU占用过高
在任务管理器中查看go-pprof-practice的资源使用情况
- 当CPU占用的比较高时,可以使用
go tool pprof命令来排查一下:
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"
等待一会儿后进入一个交互式终端
- 输入
top命令,查看CPU占用较高的调用:
上图中的数字越大占用情况越严重,可以看出是github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat造成的CPU占用过高。
并且从上图中可以看到flat与cum的值有时相同有时不同,那什么时候相同什么时候不同呢?
当flat == cum时,函数中没有调用其他函数;当flat == 0时,函数中只有其他函数的调用。
- 输入
list Eat,查看问题具体出现在代码的哪一个位置:- list 根据指定的正则表达式查找代码行
可以看出是第24行的一百亿次空循环花费了大量时间,
接下来有一个扩展操作,图形化显示调用栈信息,但是需要事先在机器上安装graphviz:
brew install graphviz # for macos
apt install graphviz # for ubuntu
yum install graphviz # for centos
Windows操作系统则可以去官网下载.exe安装包,自行安装。
安装完成后,在上文的终端中输入web。这个命令虽然叫“web”,但它实际上是产生一个.svg文件,并用你机器中默认打开.svg的程序打开它,浏览器之类的也能打开,放心使用。
(为什么我的图跟示例中的图不一样?)
至此,这一小节使用pprof定位CPU占用的实验就结束了,在终端中输入exit就可退出pprof。
为了方便后面的实验,我们修复一下这个问题,不用太麻烦,注释掉go-pprof-practice/animal/felidae/tiger/tiger.go中的一百亿次空循环的代码即可。
func (t *Tiger) Eat() {
log.Println(t.Name(), "eat")
//loop := 10000000000
//for i := 0; i < loop; i++ {
// // do nothing
//}
}
2.2.1.2 排查内存占用过高
再次运行该代码,可以看到CPU的占用率下来了,但是内存的占用率依然很高。
我们再次运行使用 pprof 命令,注意这次使用的 URL 的结尾是 heap:
go tool pprof http://localhost:6060/debug/pprof/heap
再次运行top命令查看谁的CPU占用率高
是github.com/wolfogre/go-pprof-practice/animal/muridae/mouse.(*Mouse).Steal占用内存最高
使用list Steal查看问题具体出现在代码的哪一个位置:
可以看出,问题出现在
go-pprof-practice-master\animal\muridae\mouse\mouse.go的第60行中,使用1.2CB内存。
第60行所属的方法如下:
func (m *Mouse) Steal() {
log.Println(m.Name(), "steal")
max := constant.Gi
for len(m.buffer)*constant.Mi < max {
m.buffer = append(m.buffer, [constant.Mi]byte{})
}
}
可以看到,这里有个循环会一直向 m.buffer 里追加长度为 1 MiB 的数组,直到总容量到达 1 GiB 为止,且一直不释放这些内存,这就难怪会有这么高的内存占用了。
使用 web 来查看图形化展示,可以再次确认问题确实出在这里:
现在同样注释掉相关代码来解决这个问题。
func (m *Mouse) Steal() {
log.Println(m.Name(), "steal")
//max := constant.Gi
//for len(m.buffer)*constant.Mi < max {
// m.buffer = append(m.buffer, [constant.Mi]byte{})
//}
}
再次运行,查看内存占用:
可以看出mouse.go占用的内存确实少了不少,但是整体的内存怎么升上去了???
继续使用top、list、web命令。
mouse.go中还有一个slowBuffer在不断增加,
继续注释掉重新运行
现在看到整体使用的内存下降不少,但还是mouse.go使用的内存多
2.2.1.3 排查频繁内存回收
由于内存的申请与释放频度是需要一段时间来统计的,所有我们保证炸弹程序已经运行了几分钟之后,再运行命令:
go tool pprof http://localhost:6060/debug/pprof/allocs
再次使用go tool pprof http://localhost:6060/debug/pprof/allocs命令,再来一套top、list、web组合拳
可以看到github.com/wolfogre/go-pprof-practice/animal/canidae/dog.(*Dog).Run (inline)会进行毫无意义的内存申请,而且这个函数又会被频繁调用,这才导致程序不停地进行GC:
func (d *Dog) Run() {
log.Println(d.Name(), "run")
_ = make([]byte, 16*constant.Mi)
}
这里有个小插曲,你可尝试一下将 16 * constant.Mi 修改成一个较小的值,重新编译运行,会发现并不会引起频繁 GC,原因是在 golang 里,对象是使用堆内存还是栈内存,由编译器进行逃逸分析并决定,如果对象不会逃逸,便可在使用栈内存,但总有意外,就是对象的尺寸过大时,便不得不使用堆内存。所以这里设置申请 16 MiB 的内存就是为了避免编译器直接在栈上分配,如果那样得话就不会涉及到 GC 了。
我们同样注释掉问题代码,重新编译执行,可以看到这一次,程序的 GC 频度要低很多,以至于短时间内都看不到 GC 日志了:
2.2.1.4 排查协程泄露
由于 golang 自带内存回收,所以一般不会发生内存泄露。但凡事都有例外,在 golang 中,协程本身是可能泄露的,或者叫协程失控,进而导致内存泄露。
在浏览器中可以看到,有115条协程,虽然 115条并不算多,但对于这样一个小程序来说,似乎还是不正常的。为求安心,我们再次是用 pprof 来排查一下:
再次使用top、list、web流程
是wolf.go中的第34行有个休眠。
第34行内容所属方法如下所示,其在不停的创建没用的协程。
func (w *Wolf) Drink() {
log.Println(w.Name(), "drink")
for i := 0; i < 10; i++ {
go func() {
time.Sleep(30 * time.Second)
}()
}
}
可以看到,Drink 函数每次会释放 10 个协程出去,每个协程会睡眠 30 秒再退出,而 Drink 函数又会被反复调用,这才导致大量协程泄露,试想一下,如果释放出的协程会永久阻塞,那么泄露的协程数便会持续增加,内存的占用也会持续增加,那迟早是会被操作系统杀死的。
我们注释掉问题代码,重新编译运行可以看到,协程数已经降到 15条了:
2.2.1.5 排查锁的争用
到目前为止,我们已经解决这个炸弹程序的所有资源占用问题,但是事情还没有完,我们需要进一步排查那些会导致程序运行慢的性能问题,这些问题可能并不会导致资源占用,但会让程序效率低下,这同样是高性能程序所忌讳的。
我们首先想到的就是程序中是否有不合理的锁的争用,我们倒一倒,回头看看上一张图,虽然协程数已经降到 15 条,但还显示有一个 mutex 存在争用问题。
在终端中运行go tool pprof http://localhost:6060/debug/pprof/mutex
top、list、web
可以看出是go-pprof-practice-master\animal\canidae\wolf\wolf.go第58行使用到了锁,但要知道,在代码中使用锁是无可非议的,并不是所有的锁都会被标记有问题,我们看看这个有问题的锁那儿触雷了。
func (w *Wolf) Howl() {
log.Println(w.Name(), "howl")
m := &sync.Mutex{}
m.Lock()
go func() {
time.Sleep(time.Second)
m.Unlock()
}()
m.Lock()
}
可以看到,这个锁由主协程 Lock,并启动子协程去 Unlock,主协程会阻塞在第二次 Lock 这儿等待子协程完成任务,但由于子协程足足睡眠了一秒,导致主协程等待这个锁释放足足等了一秒钟。虽然这可能是实际的业务需要,逻辑上说得通,并不一定真的是性能瓶颈,但既然它出现在原作者写的“炸弹”里,就肯定不是什么“业务需要”啦。
所以,我们注释掉这段问题代码,重新编译执行,继续。
可以看到那一个锁没有了,协程也减少了。
2.2.1.6 排查阻塞操作(block)
我们开始排查最后一个问题。在程序中,除了锁的争用会导致阻塞之外,很多逻辑都会导致阻塞。
可以看到上图中有一个阻塞操作(block),虽然不一定是有问题的,但我们保证程序性能,我们还是要老老实实排查确认一下才对。
go tool pprof http://localhost:6060/debug/pprof/block
有两个,咱们挨个看
func (c *Cat) Live() {
c.Eat()
c.Drink()
c.Shit()
c.Pee()
c.Climb()
c.Sneak()
}
Cat.go中的Live方法好像没问题,再看下一个Pee方法。
func (c *Cat) Pee() {
log.Println(c.Name(), "pee")
<-time.After(time.Second)
}
不同于睡眠一秒,这里是从一个 channel 里读数据时,发生了阻塞,直到这个 channel 在一秒后才有数据读出,这就导致程序阻塞了一秒而非睡眠了一秒。
看看web生成的图像
注释掉相关代码后再次运行
可以看到阻塞也为0。