听说火山引擎出开发者社区了,来捧个场,搬运一篇我之前的文章
大家好啊,今天外边真的是热爆了,根本不想出去走动,这个天气在空调房里拿个小勺子🥄挖着冰镇西瓜吃,真的是绝了😄,正当我一边看着奥运一边恰西瓜时,我突然想到,这大夏天的不能光我自己凉快,也得给我们的程序“降降温“,而降温的关键是要找到“升温点”,而golang就提供了非常好用的工具来帮助我们来定位程序中的很多问题,它就是pprof 。
pprof提供运行时程序的profiling,profiling一般翻译为画像。在互联网中,各个app一般都会有自己的用户画像,用户画像会包含年龄、性别、视频偏好等多项特征,从而更方便的为用户去推荐用户可能会感兴趣的内容。而计算机领域的profile指的就是进程的运行时特征,一般会包括CPU、内存、锁等多项运行时特征,从而让我们更方便的去优化我们程序的性能。
golang是一个非常注重性能的语言(虽然有gc😂),所以golang内置了pprof工具来帮助我们了解我们程序的各项profiling数据,同时结合插件也可以可视化的看到程序的各项pprofing,golang提供了两种pprof的使用方式。
- runtime/pprof
对应的场景是脚本/工具类的程序,一般运行一段时间就会停止,不会持续运行,这种情况下直接使用runtime包的pprof工具来采集进程的性能数据是最方便,直接在进程运行中持续写入pprof文件或者在结束后将各项性能数据写入文件即可。
- net/http/pprof
对应的场景是在线的程序,一般需要持续运行(提供服务),只有在服务需要升级时才会停止,这种情况下使用net包的pprof更合适一点,net/http/pprof会对外暴露一个端口,我们通过它提供的各项api就可以持续/动态/实时的采集,当然net/http/pprof只是为了方便我们通过外部接口来更方便的使用,其底层调用的也是runtime/pprof提供的各个接口。
下文中使用的go代码和pprof工具都是基于golang 1.16
runtime/pprof
package main
import (
"fmt"
"os"
"runtime/pprof"
"time"
)
func main() {
fi, err := os.Create("cpu.pprof")
if err != nil {
panic(err)
}
pprof.StartCPUProfile(fi)
go infLoop()
time.Sleep(time.Second)
pprof.StopCPUProfile()
}
func infLoop() {
for {
fmt.Println("abcd")
}
}
执行上述程序,会生成cpu的profiling写入到cpu.pprof
文件。
然后我们再执行 go tool pprof cpu.pprof
,就可以进入到pprof的交互式终端。
首先有几行信息,分别是
- Type:当前pprof文件的类型,目前的取值是cpu,后续我们可以看到更多类型的性能分析数据
- Time:pprof文件采集开始的时间,精确到min
- Duration:pprof持续的时间,后边的Total samples是样本数采集的时间
执行top
命令可以可以看到占用量逆序排列的函数,如下。
可以看到总共有6列信息,这六列分别是
列名 | 解释 |
---|---|
flat | 当前函数占用的cpu时间(因为这里分析的是cpu,所以单位是时间单位,在分析内存时单位就会换成空间单位) |
flat% | 当前函数占用的cpu时间百分比 |
sum% | flat%从上到下依次累加的结果,所以第一行的sum%会等于第一行的flat%,而第二行的sum%就会是第一行的flat%加第二行的flat%,后边依次类推 |
cum | cumulative,当前函数及当前函数的子函数占用的cpu时间 |
cum% | 当前函数及当前函数的子函数占用的cpu时间百分比 |
(最后一列) | 函数的路径,格式为{Function} |
除了top
命令外,使用help可以看到pprof支持的全部交互式命令,当前版本(golang 1.16)共支持以下几种,其中比较常用的已经用灰色标记出来。
Command | 解释 |
---|---|
callgrind | 以callgrind格式输出图,callgrind是linux valgrind工具集提供的一个性能分析工具。 |
comments | 输出所有profile的注释 |
disasm | 选择或过滤程序中的汇编调用并输出展示 |
dot | 以dot格式输出图,dot是linux提供的一个绘图 |
eog | 通过eog来展示图(需要安装graphviz) |
evince | 通过evince来展示图(需要安装graphviz) |
gif | 以gif格式输出图(需要安装graphviz) |
kcachegrind | 用KCachegrind来可视化的展示报告(KCacheGrind是一个性能可视化的工具) |
list | 需要给到一个正则,输出符合对应正则的函数的源代码 |
以pdf格式输出图 | |
peek | 需要给到一个正则,输出符合对应正则的函数的caller/callee |
png | 以png格式输出图 |
proto | 以proto压缩格式输出profile |
ps | 以ps格式生成图(需要安装Graphviz) |
raw | 以文本格式输出profile源文件 |
svg | 以svg格式生成图(需要安装Graphviz) |
tags | 输出profile文件中的全部tag |
text | 与top相同 |
top | 以文本格式输出占用量前n的函数 |
topproto | 以protobuf格式输出top的每个节点 |
traces | 以文本格式输出所有的profile样本 |
tree | 输出函数调用栈 |
web | 以浏览器来浏览pprof生成的图(需要安装Graphviz) |
weblist | 以浏览器来浏览函数及对应的代码 |
o/options | 列表输出全部的options及对应的值 |
q/quit/exit/^d | 退出pprof命令行交互模式 |
net/http/pprof
package main
import (
"net/http"
_ "net/http/pprof"
)
func main() {
http.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
resp.Write([]byte("hellp net pprof"))
})
err := http.ListenAndServe("127.0.0.1:8000", http.DefaultServeMux)
if err != nil {
panic(err)
}
}
如果使用的是http package的默认serveMux,在pprof包init时会自动注册几个路由到我们的mux中,如果用的不是http.DefaultServeMux,我们自己注册一下以下几个serveMux就好啦。
//src/net/http/pprof/pprof.go
package pprof
//...
func init() {
http.HandleFunc("/debug/pprof/", Index)
http.HandleFunc("/debug/pprof/cmdline", Cmdline)
http.HandleFunc("/debug/pprof/profile", Profile)
http.HandleFunc("/debug/pprof/symbol", Symbol)
http.HandleFunc("/debug/pprof/trace", Trace)
}
//...
注意,一般来说,在真正的线上服务里,为了与我们的对外服务端口隔离开,要用一个新的端口(debug port)来作为pprof的端口。
编译程序后直接运行,访问http://localhost:8000
即可看到我们根目录。
接下来我们再看下pprof注册的这几个路由。
/debug/pprof/cmdline
很简单,就是输出当前进程的命令行参数,如下。
/debug/pprof/symbol
根据传入的函数地址(PC),获取对应的函数名,如果是http POST方法,则从body中读取数据,如果是http GET方法,则读取url query。如果需要传多个函数地址,则用加号做连接,如下。
/debug/pprof/trace
获取程序运行中的各种事件追踪信息,例如系统调用、GC、Goroutine等等,可以接一个second参数,代表要采样的时长(单位:秒),执行完成后会自动下载一个文件,如下。
在这里,我们进行5s的采样,采样结束后会自动下载采样的trace文件。
使用go tool trace tracefile
即可看到采样信息,如下:
在这也可以看到比较多的信息,不过trace不在我们这篇文章的范畴内,可以放到后边再做深一步的学习。
/debug/pprof/profile
采集cpu的profiling,与trace一致,也可以跟一个seconds参数来指定采集的时长(单位:秒),执行完成后,会自动下载一个文件,如下。
然后再使用go tool pprof profile
就可以进入交互模式,剩下的操作和runtime/pprof
一节介绍的就一样了。
/debug/pprof/profile
的底层实现和我们runtime/pprof的demo是一致的,在内部调用了StartCPUProfile和StopCPUProfile。
- /debug/pprof
列出pprof支持的profile,当前版本(go 1.16)支持的profile如下。
包括我们刚才介绍的profile操作,各profile及解释如下:
profile | 说明 |
---|---|
allocs | 程序运行到现在的内存申请情况 |
block | 处于阻塞的协程的栈帧,默认不开启,需要调用runtime.SetBlockProfileRate 来开启 |
goroutine | 全部协程的栈帧。使用?debug=2会展示更详细的信息(例如goroutine状态) |
heap | 堆上对象的内存申请情况 |
mutex | mutex的竞争状态,默认不开启, 需要调用runtime.SetMutexProfileFraction 来开启 |
profile | cpu 状态,采样完成后,使用go tool pprof来分析 |
threadcreate | 系统线程的创建情况 |
trace | 程序的追踪状态 |
图形化
在runtime/pprof,我们可以看到有非常多的命令(大部分都是各种生成图形化的命令)都依赖一个组件才可以使用,这个组件就是graphviz,下面我们来介绍下graphviz及用graphviz后的生成的各种图形。
Graphviz
Graphviz 是一个开源的图可视化工具,非常适合绘制结构化的图标和网络。Graphviz 使用一种叫 DOT 的语言来表示图形。
而golang pprof就可以生成dot数据,再使用graphviz打开即可看到各种精美的图形,Graphviz大家直接去http://www.graphviz.org/安装即可。
Flame Graph🔥
Flame graphs are a visualization of hierarchical data, created to visualize stack traces of profiled software so that the most frequent code-paths to be identified quickly and accurately.
图片摘自 https://www.brendangregg.com/FlameGraphs/cpu-mysql-updated.svg 可以访问源网站去体验一下火焰图的详细交互,下面简单说一下如何看火焰图(以cpu火焰图为例)。
火焰图是一个二维展示的svg图形。
y 轴表示函数调用栈,每一层都是一个函数。调用栈越深,火焰就越高,顶部就是正在执行的函数,下方都是它的父函数。
x 轴表示样本数,如果一个函数在 x 轴占据的宽度越宽,就表示它被抽到的次数多,即执行的时间长。注意,x 轴不代表函数调用时间,并不意味着出现在越靠前的函数代表是先调用的,而是将所有的函数调用栈合并后,按函数名字母顺序排列的。
火焰图就是看哪个函数占据的宽度最大。只要有"平顶",就表示该函数可能存在性能问题。
"平顶" 的意思是没有再次进行子函数调用,"平顶" 段越长,证明该函数在采样期间调用时间更长.
颜色没有特殊含义,因为火焰图表示的是 CPU 的繁忙程度,所以一般选择暖色(🔥)。
Graph
Graph,我一般叫他调用图(为了和火焰图区分开,不清楚是否准确😀),图中每一个方形都代表一个函数实体,而有向边则是函数调用,源节点是caller,目标节点是callee。有向边越粗代表调用开销越大,节点越大代表当前函数开销越大。
纸上得来终觉浅,觉知此事要躬行。
看过了基本使用,下面让我们用一个demo来巩固下pprof的学习。
实战源码:https://github.com/wolfogre/go-pprof-practice,感谢wolfgre提供的实战代码🙏。
clone下来直接go build,然后运行即可(源仓库没有基于go mod构建,我们这里也就先off掉mod)。
export GO111MODULE=off && go build
我们先来简单看一下main.go
文件。
程序设置可GOMAXPROCS,可以限制P的数量为1,变相的限制了cpu核数,开启了mutex和block的追踪信息,同时程序的pprof地址为:localhost:6060
。
注意实战代码直接构造了各种性能case,是比较简单的,很多异常情况都可以通过肉眼看出来,但这里不要直接通过看代码的方式来解决问题,要借助pprof工具来发现程序中的问题。
进程启动起来后,我们可以看到终端里不停的有输出。
下面让我们来从各个方面来分析这个炸弹程序。
profile
我这里是mac os,所以直接用活动监视器来看进程情况了,可以看到我们的实战程序已经占了非常高的cpu😱。
接下来让我们使用pprof进行30s的cpu信息采样。
go tool pprof ``http://127.0.0.1:6060/debug/pprof/profile``?seconds=30
采样结束后就可以直接用前边介绍的命令行工具来查看top调用了,这里我们使用新的方式,我们使用图形化的形式来观测。
go tool pprof --http=:8080 xxxx/pprof.samples.cpu.001.pb.gz
这里会启动一个8080端口来支持我们可视化的访问cpu采样信息。
- flame graph
- top
- graph
通过top、graph和flame graph都可以看到“平顶”出现在*Tiger.Eat
这里(就这三幅图来说,graph给人的视觉冲击力应该更明显一些)。
现在我们已经定位到问题出现在Tiger.Eat
这里,我们再切换到source
这个tab,来看一下详细的代码。
可以看到,在Eat每次被调用时都会执行1e10次的调用,这里我们注释掉这里的loop代码,重新运行。
可以看到cpu的耗用已经完全降下来了,🎉已经解决了cpu问题,下面让我们再来看第二个问题。
heap
还是使用活动监视器,可以看到,我们的进程目前吃掉了接近2G的内存。
我们与cpu章一致,先采集再分析。
go tool pprof --http=:8080 127.0.0.1:6060/debug/pprof/heap
通过heap调用图,我们可以看到是🐀偷走了我们的内存😁,主要发生在Mouse.Steal这里,我们接着再去看source。
发现这里在循环的append slice,每次append 1MB,直到达到1G,且后续也没有释放操作。
这里留个小习题,为什么程序中只append了1G的内存,最终我们在应用监视器里看到的是2G。提示一下,与slice append的实现机制相关。
我们注释掉问题代码,再次编译运行,可以看到内存已经降下来了,我们又解决了一个问题🎉!但是内存相关的问题已经完全解决了吗🤔?
allocs
heap查看的只是当前堆上存活对象的情况,大部分的内存问题都可以通过heap命令解决,但是他覆盖不到一种特殊情况,这种情况就是申请、再释放内存。golang是带gc的语言,频繁的申请内存/GC也会导致我们的程序性能有很大的下降。
使用go tool pprof --http=:8080 127.0.0.1:6060/debug/pprof/allocs
可以看到历史的内存申请情况。
可以看到“平顶”出现在Dog.Run
这里,我们再用source看下源代码。
这里dog.Run
被inline了,所以在source这里看不到Run的代码,我们使用pprof换一种方式来看(这里是为了演示下list的调用,工作中下直接去看源代码可能更方便)。
使用go tool pprof ${filename}来进入
pprof的交互式命令终端,然后使用list Run
就可以看到有问题的地方,dog.Run每次调用都会申请16MB的内存,然后不使用,自然就会被GC掉,我们注释掉这里。
这里也可以留第二个小习题,这里申请了内存,又没有其他地方引用,为什么依然会在堆上进行内存申请?而不是在栈上申请?
让程序稍微运行一会,然后再查看alloc_spaces,发现已经没有大量的申请了🎉。
goroutine
golang从语言层面就支持了并发(goroutine),在go里用goroutine写并发程序是一件非常简单的事情,但是goroutine一旦使用不当也会发生问题。使用过不带GC语言(例如C++)的同学可能都经历过内存泄露的问题,而golang是带gc的语言,一般情况下不会出现内存泄露,但是如果goroutine使用不当,也会发生goroutine泄露,进而导致cpu/内存的飙升。
打开http://127.0.0.1:6060/debug/pprof/
。
可以看到程序中有100多个goroutine,100多个goroutine对于我们这样的小程序来说肯定是不正常的。
使用go tool pprof --http=:8080 127.0.0.1:6060/debug/pprof/goroutine
来查看我们程序中的goroutine分配/使用情况。
可以看到wolf.Drink
这里申请了101个goroutine,我们再查看一下source。
发现wolf.Drink
这里每次调用都会申请10个goroutine,每个goroutine sleep 30秒,我们注释掉这里,重新运行程序。
可以看到已经只有5个goroutine了🎉。
mutex
上述我们解决的问题都是资源相关的,但是除了资源相关的,大量的同步操作(例如互斥锁mutex)也会导致虽然资源利用率很低,但是程序的性能非常差。
依然是老一套,先采样观测。
go tool pprof --http=:8080 127.0.0.1:6060/debug/pprof/mutex
可以看到锁的竞争出现在了Wolf.Howl
这里,然后接下来相比大家已经熟悉了,还是source操作去查看源代码。
这里要说明一下,程序中出现mutex是非常正常的事情,并不是每一个mutex都有问题,这里mutex只是为了演示锁竞争情况,并不是mutex的推荐用法(mutex详解可以看这里:📚mutex ),这里我们简单把sleep注释掉就可以了。
可以看到锁竞争等待的时间已经非常少了🎉。
block
golang支持了并发操作,但是本质上还是一个同步的语言(相对于node.js事件回调),必然会有一些阻塞的操作,而阻塞操作也是影响我们程序性能的很大一个点。
使用go tool pprof --http=:8080 127.0.0.1:6060/debug/pprof/block
。
可以看到阻塞操作发生在chanrecv上,向上追溯到我们的业务代码就是Cat.Pee
。
在源码这,我们可以看到Cat.Pee在每次调用时都会去读chan,而这个chan直到1秒后才可以读到。把这里注掉就可以啦🎉。
首先,我们介绍了profile的基本定义。其次,我们介绍了golang pprof的基本使用,golang pprof对外暴露的api包括runtime/pprof和net/http/pprof两部分,net/http/pprof是对runtime/pprof的http调用封装。pprof常用的命令是list、top和web。然后,图形化的pprof可以帮助我们更直观的观测go进程的各项profile数据。最后通过实战,解决了一个程序的cpu、内存、锁等方面的性能问题。总而言之,pprof是一个非常强大的工具,更多的用法和特性还有待大家去探索,希望pprof可以使你的golang程序也能"清凉一夏"。