1. 前言
如果你需要对自己开发的 Go 程序进行性能优化的话,一个必不可少的工具就是 pprof 了,这是 Go 自带的一个性能调试工具。这篇文章中我们就会通过一个模拟了各类资源占用问题的炸弹项目来学习如何运用 pprof。
2. 启用 pprof
在开始使用 pprof 之前,首先来了解一下 pprof 支持的场景:
net/http/pprof:采集 HTTP Server 的运行时数据进行分析runtime/pprof:采集程序(非 Server)的运行时数据进行分析
这里我们主要将重心放在基于 HTTP 服务器的性能调试,不过两者方式其实本质都差不多,只不过后者需要手动去调用代码来控制采集的数据。在 HTTP 服务器项目中启用 pprof 非常简单:只需要 import _ "net/http/pprof" 即可,pprof 会自动注册 HTTP 端点 /debug/pprof 并采集数据。(如果你对于 pprof 是如何实现通过 import 达成目标感兴趣的话:Go 会在 import 包时自动执行包内的 init 函数(如果有的话),而注册就发生在 pprof 的 init 函数中。)
我们可以从 github.com/wolfogre/go… 下载所需的炸弹项目。这个项目已经默认启用了 pprof,因此我们不需要做额外的操作。
3. 使用 pprof
使用 go build 先生成可执行文件,然后打开程序,并让它运行一段时间。在运行过程中,你可以看到程序不断地输出一些动物的信息,同时在任务管理器里可以看到 go-pprof-practice 占用了大量的 CPU 与内存资源:
接下来,我们就要开始运用 pprof 来逐一击破程序存在的性能问题了。在浏览器中打开 http://localhost:6060/debug/pprof/,可以看到一个页面:
可以看到,pprof 为我们提供了各类资源的大致占用情况,以及 goroutine、内存、CPU 等各方面资源指标的探针,便于我们深入进行分析。不过这些探针并非设计为方便人类阅读的,如果你点进去的话会发现有一堆看不懂的魔法数字;它们的预期用法是通过命令行工具 go tool pprof 进行分析。下面我们就来试着分析 CPU 占用问题:先在终端中输入
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
其中 seconds=30 表示采集 30 秒内的 CPU 数据。耐心等待半分钟,当看到如下界面时,便表示数据采集完毕,可以与 pprof 进行交互了:
首先输入 top 来查看占用最大的几项数据:
可以看到,pprof 列出了一个表格,表格中各列的意义如下:
flat:当前项(函数)的执行耗时(仅自身,相当于函数调用树中的自身结点);flat%:flat占总执行耗时(即图中的9.29s total)的比例;sum%:从第一行到当前行的flat%的总和;cum:当前项的执行耗时(包括自身调用的函数的总耗时,相当于函数调用树中以自身结点为根的子树);cum%:cum占总执行耗时的比例。
最后一列显而易见,是当前项所对应的函数名称。如果你觉得光看文字不过瘾的话,还可以选择查看数据的图形化展示,更直观地看到结果。为了生成图形,pprof 需要 graphviz 来运作,通过输入一个 DOT 脚本,graphviz 可以将其转换为相应的图形。这里以 Windows 举例,我们可以通过 Windows 自带的包管理器来安装依赖:winget install graphviz,此外也推荐先将 .svg 的格式关联到浏览器,查看起来会更方便。安装完成后,在 pprof 的交互式终端中输入 web,pprof 会自动在浏览器中打开图形化的 CPU 数据图,通过鼠标滚轮和拖拽即可交互:
可以看到,pprof 已经自动标记并放大了导致 CPU 高占用的罪魁祸首,即 (*Tiger) Eat() 函数。为了确定到底是函数中的哪一部分导致了高占用,我们可以在 pprof 终端中执行 list Eat,pprof 会将 Eat 作为正则表达式去匹配,并最终返回我们想要的函数信息:
这里可以看到,代码的 24 行占用了 9.20s,明显就是它搞的鬼。定位到了问题,解决方法自然浮出水面:将这个空循环删去即可。重新运行修改后的代码,可以发现 CPU 占用已经基本为 0,CPU 占用问题算是成功解决了。
接下来解决内存占用问题。其实除了命令行的交互以外,pprof 还支持在浏览器上进行交互,我们只需要执行
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
pprof 就会自动打开浏览器,可以在顶栏中切换 top 视图、火焰图等。火焰图的效果如下:
可以看到,火焰图提供了紧凑且直观、可交互的查看方式,实际开发中也常常会用到。切换到 Source 视图,可以看到 pprof 将所有存在问题的地方都标注了出来:
接下来如法炮制,将有问题的代码删去,内存问题得到解决。但我们的优化之旅还未结束,在之前的 /debug/pprof 网页中还可以看到有一些数字不太正常,而它们代表着其他潜在的性能问题,不会直接体现在内存和 CPU 指标上。可以看到 goroutine 数上百,这似乎不太正常,那么来分析一下:
可以看到 (*Wolf).Drink 函数有很大的嫌疑。查看源码,发现其在不停地创建进行长时间等待后立刻退出的无用 goroutine,很显然是无效的逻辑。删去后重新运行,数字回落到了正常水平。除了 goroutine 之外,还有一个东西会隐式地拖慢程序的运行,那就是 GC。先来排查一下程序是否存在 GC 频繁调用的问题:
可以看到,程序平均每 3 秒就执行了一次 GC,并且每次都从 16 MB 清理到 0 MB,这暗示着程序对于内存的分配可能不够合理。那么,再次祭出 pprof,查看 allocs 指标,它与 heap 的不同之处是它会记录所有的分配内存量,而不包括释放的内存。这次可以看到:
很显然,(*Dog).Run 是元凶,点击它并切换到 Source 视图,pprof 会自动筛选出目标函数:
可见是 make 一句在不停地分配并释放内存,注释掉后 GC 的压力得到了大幅缓解,内存消耗也进一步下降。
最后来看 block 指标。我们都知道,Go 的一大特点就是其高并发能力,但这也意味着多线程间的数据共享,为了避免由此带来的数据竞争,我们往往会使用 sync.Mutex 等并发原语来解决问题。但对于并发原语的不恰当使用会导致 goroutine 间的竞争非常激烈,有大量的 goroutine 被堵塞,从而失去了多核带来的高并发优势。查看 block 的可视化展示:
发现 (*Wolf).Howl 与 (*Cat).Pee 都存在长时间的堵塞问题,具体分别是 Mutex 和 chan。当然堵塞并非总是无意义的,有时候生产者生产数据的速度就是很慢,此时长时间堵塞就是十分合理的。具体到代码来看:
func (w *Wolf) Howl() {
log.Println(w.Name(), "howl")
m := &sync.Mutex{}
m.Lock()
go func() {
time.Sleep(time.Second)
m.Unlock()
}()
m.Lock()
}
这里的 time.Sleep(time.Second) 显然是大忌,因为它无意义地延长了锁的占用时间,而在实际环境中,长时间地占用锁显然会加剧竞争。
func (c *Cat) Pee() {
log.Println(c.Name(), "pee")
<-time.After(time.Second)
}
这段代码中的 <-time.After(time.Second) 其实就是通过读取 channel 的方式堵塞一秒钟。这句话本身有十分合理的用途,如搭配 select 为等待操作加上超时退出功能,等等。但它在这里没什么意义,直接删去即可。
到了这里,我们的炸弹基本上已经非常温顺了,可以说我们的炸弹优化之旅就到此告一段落了。
4. 总结
这次 pprof 的实操使得我更加深刻地理解了 Go 中各种可能发生的性能问题,并掌握了基本的排查方法、接触了火焰图等数据的可视化方式。