目录


前言

如果要说在 golang 开发过程进行性能调优,pprof 一定是一个大杀器般的工具。但在网上找到的教程都偏向简略,难寻真的能应用于实战的教程。这也无可厚非,毕竟 pprof 是当程序占用资源异常时才需要启用的工具,而我相信大家的编码水平和排场问题的能力是足够高的,一般不会写出性能极度堪忧的程序,且即使发现有一些资源异常占用,也会通过排查代码快速定位,这也导致 pprof 需要上战场的机会少之又少。即使大家有心想学习使用 pprof,却也常常相忘于江湖。

既然如此,那我就送大家一个性能极度堪忧的“炸弹”程序吧!

这程序没啥正经用途缺极度占用资源,基本覆盖了常见的性能问题。本文就是希望读者能一步一步按照提示,使用 pprof 定位这个程序的的性能瓶颈所在,借此学习 pprof 工具的使用方法。

因此,本文是一场“实验课”而非“理论课”,请读者腾出时间,脚踏实地,一步一步随实验步骤进行操作,这会是一个很有趣的冒险,不会很无聊,希望你能喜欢。

实验准备

这里假设你有基本的 golang 开发功底,拥有 golang 开发环境并配置了 $GOPATH,能熟练阅读简单的代码或进行简单的修改,且知道如何编译运行 golang 程序。此外,需要你大致知道 pprof 是干什么的,有一个基本印象即可,你可以花几分钟时间读一下《Golang 大杀器之性能剖析 PProf》的开头部分,这不会耽误太久。

此外由于你需要运行一个“炸弹”程序,请务必确保你用于做实验的机器有充足的资源,你的机器至少需要:

  • 2 核 CPU;
  • 2G 内存。

注意,以上只是最低需求,你的机器配置能高于上述要求自然最好。实际运行“炸弹”时,你可以关闭电脑上其他不必要的程序,甚至 IDE 都不用开,我们的实验操作基本上是在命令行里进行的。

此外,务必确保你是在个人机器上运行“炸弹”的,能接受机器死机重启的后果(虽然这发生的概率很低)。请你务必不要在危险的边缘试探,比如在线上服务器运行这个程序。

可能说得你都有点害怕了,为打消你顾虑,我可以剧透一下“炸弹”的情况,让你安心:

  • 程序会占用约 2G 内存;
  • 程序占用 CPU 最高约 100%(总量按“核数 * 100%”来算);
  • 程序不涉及网络或文件读写;
  • 程序除了吃资源之外没有其他危险操作。

且程序所占用的各类资源,均不会随着运行时间的增长而增长,换句话说,只要你把“炸弹”启动并正常运行了一分钟,就基本确认安全了,之后即使运行几天也不会有更多的资源占用,除了有点费电之外。

获取“炸弹”

go get-d
main.go

除此之外的其他代码你一律不用看,那些都是我为了模拟一个“逻辑复杂”的程序而编造的,其中大多数的问题很容易通过肉眼发现,但我们需要做的是通过 pprof 来定位代码的问题,所以为了保证实验的趣味性请不要提前阅读代码,可以实验完成后再看。

接着我们需要编译一下这个程序并运行,你不用担心依赖问题,这个程序没有任何外部依赖。

运行后注意查看一下资源是否吃紧,机器是否还能扛得住,坚持一分钟,如果确认没问题,咱们再进行下一步。

控制台里应该会不停的打印日志,都是一些“猫狗虎鼠在不停地吃喝拉撒”的屁话,没有意义,不用细看。

使用 pprof

http://localhost:6060/debug/pprof/

image

页面上展示了可用的程序运行采样数据,分别有:

因为 cmdline 没有什么实验价值,trace 与本文主题关系不大,threadcreate 涉及的情况偏复杂,所以这三个类型的采样信息这里暂且不提。除此之外,其他所有类型的采样信息本文都会涉及到,且炸弹程序已经为每一种类型的采样信息埋藏了一个对应的性能问题,等待你的发现。

go tool pprof

我们先不用完整地学习如何使用这个命令,毕竟那太枯燥了,我们一边实战一边学习。

以下正式开始。

排查 CPU 占用过高

我们首先通过活动监视器(或任务管理器、top 命令,取决于你的操作系统和你的喜好),查看一下炸弹程序的 CPU 占用:

image

go tool pprof

等待一会儿后,进入一个交互式终端:

image

输入 top 命令,查看 CPU 占用较高的调用:

image

github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat
flatflat%sum%cumcum%
list Eat

image

可以看到,是第 24 行那个一百亿次空循环占用了大量 CPU 时间,至此,问题定位成功!

graphviz

或者你也可以访问 graphviz 官网寻找适合自己操作系统的安装方法。

web

你应该可以看到:

image

tiger.(*Tiger).Eat
exit

为了方便进行后面的实验,我们修复一下这个问题,不用太麻烦,注释掉相关代码即可:

之后修复问题的的方法都是注释掉相关的代码,不再赘述。你可能觉得这很粗暴,但要知道,这个实验的重点是如何使用 pprof 定位问题,我们不需要花太多时间在改代码上。

排查内存占用过高

重新编译炸弹程序,再次运行,可以看到 CPU 占用率已经下来了,但是内存的占用率仍然很高:

image

我们再次运行使用 pprof 命令,注意这次使用的 URL 的结尾是 heap:

toplist

image

github.com/wolfogre/go-pprof-practice/animal/muridae/mouse.(*Mouse).Steal

可以看到,这里有个循环会一直向 m.buffer 里追加长度为 1 MiB 的数组,直到总容量到达 1 GiB 为止,且一直不释放这些内存,这就难怪会有这么高的内存占用了。

web

image

现在我们同样是注释掉相关代码来解决这个问题。

再次编译运行,查看内存占用:

image

可以看到内存占用已经将到了 35 MB,似乎内存的使用已经恢复正常,一片祥和。

但是,内存相关的性能问题真的已经全部解决了吗?

排查频繁内存回收

你应该知道,频繁的 GC 对 golang 程序性能的影响也是非常严重的。虽然现在这个炸弹程序内存使用量并不高,但这会不会是频繁 GC 之后的假象呢?

为了获取程序运行过程中 GC 日志,我们需要先退出炸弹程序,再在重新启动前赋予一个环境变量,同时为了避免其他日志的干扰,使用 grep 筛选出 GC 日志查看:

日志输出如下:

image

可以看到,GC 差不多每 3 秒就发生一次,且每次 GC 都会从 16MB 清理到几乎 0MB,说明程序在不断的申请内存再释放,这是高性能 golang 程序所不允许的。

如果你希望进一步了解 golang 的 GC 日志可以查看《如何监控 golang 程序的垃圾回收》,为保证实验节奏,这里不做展开。

所以接下来使用 pprof 排查时,我们在乎的不是什么地方在占用大量内存,而是什么地方在不停地申请内存,这两者是有区别的。

由于内存的申请与释放频度是需要一段时间来统计的,所有我们保证炸弹程序已经运行了几分钟之后,再运行命令:

同样使用 top、list、web 大法:

image

image

github.com/wolfogre/go-pprof-practice/animal/canidae/dog.(*Dog).Run
16 * constant.Mi

我们同样注释掉问题代码,重新编译执行,可以看到这一次,程序的 GC 频度要低很多,以至于短时间内都看不到 GC 日志了:

image

排查协程泄露

由于 golang 自带内存回收,所以一般不会发生内存泄露。但凡事都有例外,在 golang 中,协程本身是可能泄露的,或者叫协程失控,进而导致内存泄露。

我们在浏览器里可以看到,此时程序的协程数已经多达 106 条:

image

虽然 106 条并不算多,但对于这样一个小程序来说,似乎还是不正常的。为求安心,我们再次是用 pprof 来排查一下:

同样是 top、list、web 大法:

image

image

github.com/wolfogre/go-pprof-practice/animal/canidae/wolf.(*Wolf).Drink

可以看到,Drink 函数每次回释放 10 个协程出去,每个协程会睡眠 30 秒再退出,而 Drink 函数又会被反复调用,这才导致大量协程泄露,试想一下,如果释放出的协程会永久阻塞,那么泄露的协程数便会持续增加,内存的占用也会持续增加,那迟早是会被操作系统杀死的。

我们注释掉问题代码,重新编译运行可以看到,协程数已经降到 4 条了:

image

排查锁的争用

到目前为止,我们已经解决这个炸弹程序的所有资源占用问题,但是事情还没有完,我们需要进一步排查那些会导致程序运行慢的性能问题,这些问题可能并不会导致资源占用,但会让程序效率低下,这同样是高性能程序所忌讳的。

我们首先想到的就是程序中是否有不合理的锁的争用,我们倒一倒,回头看看上一张图,虽然协程数已经降到 4 条,但还显示有一个 mutex 存在争用问题。

相信到这里,你已经触类旁通了,无需多言,开整。

同样是 top、list、web 大法:

image

image

github.com/wolfogre/go-pprof-practice/animal/canidae/wolf.(*Wolf).Howl

可以看到,这个锁由主协程 Lock,并启动子协程去 Unlock,主协程会阻塞在第二次 Lock 这儿等待子协程完成任务,但由于子协程足足睡眠了一秒,导致主协程等待这个锁释放足足等了一秒钟。虽然这可能是实际的业务需要,逻辑上说得通,并不一定真的是性能瓶颈,但既然它出现在我写的“炸弹”里,就肯定不是什么“业务需要”啦。

所以,我们注释掉这段问题代码,重新编译执行,继续。

排查阻塞操作

好了,我们开始排查最后一个问题。

在程序中,除了锁的争用会导致阻塞之外,很多逻辑都会导致阻塞。

image

可以看到,这里仍有 2 个阻塞操作,虽然不一定是有问题的,但我们保证程序性能,我们还是要老老实实排查确认一下才对。

top、list、web,你懂得。

image

image

github.com/wolfogre/go-pprof-practice/animal/felidae/cat.(*Cat).Pee

你应该可以看懂,不同于睡眠一秒,这里是从一个 channel 里读数据时,发生了阻塞,直到这个 channel 在一秒后才有数据读出,这就导致程序阻塞了一秒而非睡眠了一秒。

这里有个疑点,就是上文中是可以看到有两个阻塞操作的,但这里只排查出了一个,我没有找到其准确原因,但怀疑另一个阻塞操作是程序监听端口提供 porof 查询时,涉及到 IO 操作发生了阻塞,即阻塞在对 HTTP 端口的监听上,但我没有进一步考证。

不管怎样,恭喜你完整地完成了这个实验。

思考题

另有一些问题,虽然比较重要,但碍于篇幅(其实是我偷懒),就以思考题的形式留给大家了。

runtime.SetMutexProfileFractionruntime.SetBlockProfileRate

最后

碍于我的水平有限,实验中还有很多奇怪的细节我只能暂时熟视无睹(比如“排查内存占用过高”一节中,为什么实际申请的是 1.5 GiB 内存),如果这些奇怪的细节你也发现了,并痛斥我假装睁眼瞎,那么我的目的就达到了……

——还记得吗,本文的目的是让你熟悉使用 pprof,消除对它的陌生感,并能借用它进一步得了解 golang。而你通过这次试验,发现了程序的很多行为不同于你以往的认知或假设,并抱着好奇心,开始向比深处更深处迈进,那么,我何尝不觉得这是本文的功德呢?

与君共勉。


写得非常棒,向原作者致敬!