深度解密Go语言之 pprof

目录

  • 什么是 pprof
  • pprof 的作用
  • pprof 如何使用
    • runtime/pprof
    • net/http/pprof
  • pprof 进阶
    • Russ Cox 实战
    • 查找内存泄露
  • 总结
  • 参考资料

相信很多人都听过“雷神 3”关于性能优化的故事。在一个 3D 游戏引擎的源码里,John Carmack 将 1/sqrt(x) 这个函数的执行效率优化到了极致。

一般我们使用二分法,或者牛顿迭代法计算一个浮点数的平方根。但在这个函数里,作者使用了一个“魔数”,根本没有迭代,两步就直接算出了平方根。令人叹为观止!

因为它是最底层的函数,而游戏里涉及到大量的这种运算,使得在运算资源极其紧张的 DOS 时代,游戏也可以流畅地运行。这就是性能优化的魅力!

工作中,当业务量比较小的时候,用的机器也少,体会不到性能优化带来的收益。而当一个业务使用了几千台机器的时候,性能优化 20%,那就能省下几百台机器,一年能省几百万。省下来的这些钱,给员工发年终奖,那得多 Happy!

一般而言,性能分析可以从三个层次来考虑:应用层、系统层、代码层。

应用层主要是梳理业务方的使用方式,让他们更合理地使用,在满足使用方需求的前提下,减少无意义的调用;系统层关注服务的架构,例如增加一层缓存;代码层则关心函数的执行效率,例如使用效率更高的开方算法等。

做任何事,都要讲究方法。在很多情况下,迅速把事情最关键的部分完成,就能拿到绝大部分的收益了。其他的一些边边角角,可以慢慢地缝合。一上来就想完成 100%,往往会陷入付出了巨大的努力,却收获寥寥的境地。

性能优化这件事也一样,识别出性能瓶颈,会让我们付出最小的努力,而得到最大的回报。

Go 语言里,pprof 就是这样一个工具,帮助我们快速找到性能瓶颈,进而进行有针对性地优化。

什么是 pprof

代码上线前,我们通过压测可以获知系统的性能,例如每秒能处理的请求数,平均响应时间,错误率等指标。这样,我们对自己服务的性能算是有个底。

但是压测是线下的模拟流量,如果到了线上呢?会遇到高并发、大流量,不靠谱的上下游,突发的尖峰流量等等场景,这些都是不可预知的。

线上突然大量报警,接口超时,错误数增加,除了看日志、监控,就是用性能分析工具分析程序的性能,找到瓶颈。当然,一般这种情形不会让你有机会去分析,降级、限流、回滚才是首先要做的,要先止损嘛。回归正常之后,通过线上流量回放,或者压测等手段,制造性能问题,再通过工具来分析系统的瓶颈。

一般而言,性能分析主要关注 CPU、内存、磁盘 IO、网络这些指标。

Profiling 是指在程序执行过程中,收集能够反映程序执行状态的数据。在软件工程中,性能分析(performance analysis,也称为 profiling),是以收集程序运行时信息为手段研究程序行为的分析方法,是一种动态程序分析的方法。

Go 语言自带的 pprof 库就可以分析程序的运行情况,并且提供可视化的功能。它包含两个相关的库:

  • runtime/pprof
    对于只跑一次的程序,例如每天只跑一次的离线预处理程序,调用 pprof 包提供的函数,手动开启性能数据采集。
  • net/http/pprof
    对于在线服务,对于一个 HTTP Server,访问 pprof 提供的 HTTP 接口,获得性能数据。当然,实际上这里底层也是调用的 runtime/pprof 提供的函数,封装成接口对外提供网络访问。

pprof 的作用

pprof 是 Go 语言中分析程序运行性能的工具,它能提供各种性能数据:

allocsheap 采样的信息一致,不过前者是所有对象的内存分配,而 heap 则是活跃对象的内存分配。

The difference between the two is the way the pprof tool reads there at start time. Allocs profile will start pprof in a mode which displays the total number of bytes allocated since the program began (including garbage-collected bytes).

上图来自参考资料【wolfogre】的一篇 pprof 实战的文章,提供了一个样例程序,通过 pprof 来排查、分析、解决性能问题,非常精彩。

  1. 当 CPU 性能分析启用后,Go runtime 会每 10ms 就暂停一下,记录当前运行的 goroutine 的调用堆栈及相关数据。当性能分析数据保存到硬盘后,我们就可以分析代码中的热点了。
  1. 内存性能分析则是在堆(Heap)分配的时候,记录一下调用堆栈。默认情况下,是每 1000 次分配,取样一次,这个数值可以改变。栈(Stack)分配 由于会随时释放,因此不会被内存分析所记录。由于内存分析是取样方式,并且也因为其记录的是分配内存,而不是使用内存。因此使用内存性能分析工具来准确判断程序具体的内存使用是比较困难的。
  1. 阻塞分析是一个很独特的分析,它有点儿类似于 CPU 性能分析,但是它所记录的是 goroutine 等待资源所花的时间。阻塞分析对分析程序并发瓶颈非常有帮助,阻塞性能分析可以显示出什么时候出现了大批的 goroutine 被阻塞了。阻塞性能分析是特殊的分析工具,在排除 CPU 和内存瓶颈前,不应该用它来分析。

pprof 如何使用

我们可以通过 报告生成Web 可视化界面交互式终端 三种方式来使用 pprof

—— 煎鱼《Golang 大杀器之性能剖析 PProf》

runtime/pprof

拿 CPU profiling 举例,增加两行代码,调用 pprof.StartCPUProfile 启动 cpu profiling,调用 pprof.StopCPUProfile() 将数据刷到文件里:

import "runtime/pprof"

var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")

func main() {
    // …………

    pprof.StartCPUProfile(f)
    defer pprof.StopCPUProfile()

    // …………
}

net/http/pprof

启动一个端口(和正常提供业务服务的端口不同)监听 pprof 请求:

import _ "net/http/pprof"

func initPprofMonitor() error {
    pPort := global.Conf.MustInt("http_server", "pprofport", 8080)

    var err error
    addr := ":" + strconv.Itoa(pPort)

    go func() {
        err = http.ListenAndServe(addr, nil)
        if err != nil {
            logger.Error("funcRetErr=http.ListenAndServe||err=%s", err.Error())
        }
    }()

    return err
}

pprof 包会自动注册 handler, 处理相关的请求:

// src/net/http/pprof/pprof.go:71

func init() {
    http.Handle("/debug/pprof/", http.HandlerFunc(Index))
    http.Handle("/debug/pprof/cmdline", http.HandlerFunc(Cmdline))
    http.Handle("/debug/pprof/profile", http.HandlerFunc(Profile))
    http.Handle("/debug/pprof/symbol", http.HandlerFunc(Symbol))
    http.Handle("/debug/pprof/trace", http.HandlerFunc(Trace))
}

第一个路径 /debug/pprof/ 下面其实还有 5 个子路径:

goroutine
threadcreate
heap
block
mutex

启动服务后,直接在浏览器访问:

http://47.93.238.9:8080/debug/pprof/

就可以得到一个汇总页面:

可以直接点击上面的链接,进入子页面,查看相关的汇总信息。

关于 goroutine 的信息有两个链接,goroutinefull goroutine stack dump,前者是一个汇总的消息,可以查看 goroutines 的总体情况,后者则可以看到每一个 goroutine 的状态。页面具体内容的解读可以参考【大彬】的文章。

点击 profiletrace 则会在后台进行一段时间的数据采样,采样完成后,返回给浏览器一个 profile 文件,之后在本地通过 go tool pprof 工具进行分析。

当我们下载得到了 profile 文件后,执行命令:

go tool pprof ~/Downloads/profile

就可以进入命令行交互式使用模式。执行 go tool pprof -help 可以查看帮助信息。

直接使用如下命令,则不需要通过点击浏览器上的链接就能进入命令行交互模式:

go tool pprof http://47.93.238.9:8080/debug/pprof/profile

当然也是需要先后台采集一段时间的数据,再将数据文件下载到本地,最后进行分析。上述的 Url 后面还可以带上时间参数:?seconds=60,自定义 CPU Profiling 的时长。

类似的命令还有:

# 下载 cpu profile,默认从当前开始收集 30s 的 cpu 使用情况,需要等待 30s
go tool pprof http://47.93.238.9:8080/debug/pprof/profile
# wait 120s
go tool pprof http://47.93.238.9:8080/debug/pprof/profile?seconds=120     

# 下载 heap profile
go tool pprof http://47.93.238.9:8080/debug/pprof/heap

# 下载 goroutine profile
go tool pprof http://47.93.238.9:8080/debug/pprof/goroutine

# 下载 block profile
go tool pprof http://47.93.238.9:8080/debug/pprof/block

# 下载 mutex profile
go tool pprof http://47.93.238.9:8080/debug/pprof/mutex

进入交互式模式之后,比较常用的有 toplistweb 等命令。

执行 top

得到四列数据:

列名 含义
flat 本函数的执行耗时
flat% flat 占 CPU 总时间的比例。程序总耗时 16.22s, Eat 的 16.19s 占了 99.82%
sum% 前面每一行的 flat 占比总和
cum 累计量。指该函数加上该函数调用的函数总耗时
cum% cum 占 CPU 总时间的比例

其他类型,如 heap 的 flat, sum, cum 的意义和上面的类似,只不过计算的东西不同,一个是 CPU 耗时,一个是内存大小。

执行 list,使用正则匹配,找到相关的代码:

list Eat

直接定位到了相关长耗时的代码处:

执行 web(需要安装 graphviz,pprof 能够借助 grapgviz 生成程序的调用图),会生成一个 svg 格式的文件,直接在浏览器里打开(可能需要设置一下 .svg 文件格式的默认打开方式):

图中的连线代表对方法的调用,连线上的标签代表指定的方法调用的采样值(例如时间、内存分配大小等),方框的大小与方法运行的采样值的大小有关。

每个方框由两个标签组成:在 cpu profile 中,一个是方法运行的时间占比,一个是它在采样的堆栈中出现的时间占比(前者是 flat 时间,后者则是 cumulate 时间占比);框越大,代表耗时越多或是内存分配越多。

另外,traces 命令还可以列出函数的调用栈:

除了上面讲到的两种方式(报告生成、命令行交互),还可以在浏览器里进行交互。先生成 profile 文件,再执行命令:

go tool pprof --http=:8080 ~/Downloads/profile

进入一个可视化操作界面:

点击菜单栏可以在:Top/Graph/Peek/Source 之间进行切换,甚至可以看到火焰图(Flame Graph):

它和一般的火焰图相比刚好倒过来了,调用关系的展现是从上到下。形状越长,表示执行时间越长。注:我这里使用的 go 版本是 1.13,更老一些的版本 pprof 工具不支持 -http 的参数。当然也可以下载其他的库查看火焰图,例如:

go get -u github.com/google/pprof

或者

go get github.com/uber/go-torch

pprof 进阶

我在参考资料部分给出了一些使用 pprof 工具进行性能分析的实战文章,可以跟着动手实践一下,之后再用到自己的平时工作中。

Russ Cox 实战

这部分主要内容来自参考资料【Ross Cox】,学习一下大牛的优化思路。

事情的起因是这样的,有人发表了一篇文章,用各种语言实现了一个算法,结果用 go 写的程序非常慢,而 C++ 则最快。然后 Russ Cox 就鸣不平了,哪受得了这个气?马上启用 pprof 大杀器进行优化。最后,程序不仅更快,而且使用的内存更少了!

首先,增加 cpu profiling 的代码:

var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")

func main() {
    flag.Parse()
    if *cpuprofile != "" {
        f, err := os.Create(*cpuprofile)
        if err != nil {
            log.Fatal(err)
        }

        pprof.StartCPUProfile(f)
        defer pprof.StopCPUProfile()
    }
    ...
}

使用 pprof 观察耗时 top5 的函数,发现一个读 map 的函数耗时最长:mapaccess1_fast64,而它出现在一个递归函数中。

一眼就能看到框最大的 mapacess1_fast64 函数。执行 web mapaccess1 命令,更聚焦一些:

调用 mapaccess1_fast64 函数最多的就是 main.FindLoops 和 main.DFS,是时候定位到具体的代码了,执行命令:list DFS,定位到相关的代码。

优化的方法是将 map 改成 slice,能这样做的原因当然和 key 的类型是 int 而且不是太稀疏有关。

The take away will be that for smaller data sets, you shouldn’t use maps where slices would suffice, as maps have a large overhead.

修改完之后,再次通过 cpu profiling,发现递归函数的耗时已经不在 top5 中了。但是新增了长耗时函数:runtime.mallocgc,占比 54.2%,而这和分存分配以及垃圾回收相关。

下一步,增加采集内存数据的代码:

var memprofile = flag.String("memprofile", "", "write memory profile to this file")

func main() {
    // …………

    FindHavlakLoops(cfgraph, lsgraph)
    if *memprofile != "" {
        f, err := os.Create(*memprofile)
        if err != nil {
            log.Fatal(err)
        }
        pprof.WriteHeapProfile(f)
        f.Close()
        return
    }

    // …………
}

继续通过 top5list 命令找到内存分配最多的代码位置,发现这回是向 map 里插入元素使用的内存比较多。改进方式同样是用 slice 代替 map,但 map 还有一个特点是可以重复插入元素,因此新写了一个向 slice 插入元素的函数:

func appendUnique(a []int, x int) []int {
    for _, y := range a {
        if x == y {
            return a
        }
    }
    return append(a, x)
}

好了,现在程序比最初的时候快了 2.1 倍。再次查看 cpu profile 数据,发现 runtime.mallocgc 降了一些,但仍然占比 50.9%。

Another way to look at why the system is garbage collecting is to look at the allocations that are causing the collections, the ones that spend most of the time in mallocgc.

因此需要查看垃圾回收到底在回收哪些内容,这些内容就是导致频繁垃圾回收的“罪魁祸首”。

使用 web mallocgc 命令,将和 mallocgc 相关的函数用矢量图的方式展现出来,但是有太多样本量很少的节点影响观察,增加过滤命令:

go tool pprof --nodefraction=0.1 profile

将少于 10% 的采样点过滤掉,新的矢量图可以直观地看出,FindLoops 触发了最多的垃圾回收操作。继续使用命令 list FindLoops 直接找到代码的位置。

原来,每次执行 FindLoops 函数时,都要 make 一些临时变量,这会加重垃圾回收器的负担。改进方式是增加一个全局变量 cache,可以重复利用。坏处是,现在不是线程安全的了。

使用 pprof 工具进行的优化到这就结束了。最后的结果很不错,基本上能达到和 C++ 同等的速度和同等的内存分配大小。

我们能得到的启发就是先使用 cpu profile 找出耗时最多的函数,进行优化。如果发现 gc 执行比较多的时候,找出内存分配最多的代码以及引发内存分配的函数,进行优化。

原文很精彩,虽然写作时间比较久远(最初写于 2011 年)了,但仍然值得一看。另外,参考资料【wolfogre】的实战文章也非常精彩,而且用的招式和这篇文章差不多,但是你可以运行文章提供的样例程序,一步步地解决性能问题,很有意思!

查找内存泄露

内存分配既可以发生在堆上也可以在栈上。堆上分配的内存需要垃圾回收或者手动回收(对于没有垃圾回收的语言,例如 C++),栈上的内存则通常在函数退出后自动释放。

Go 语言通过逃逸分析会将尽可能多的对象分配到栈上,以使程序可以运行地更快。

这里说明一下,有两种内存分析策略:一种是当前的(这一次采集)内存或对象的分配,称为 inuse;另一种是从程序运行到现在所有的内存分配,不管是否已经被 gc 过了,称为 alloc

As mentioned above, there are two main memory analysis strategies with pprof. One is around looking at the current allocations (bytes or object count), called inuse. The other is looking at all the allocated bytes or object count throughout the run-time of the program, called alloc. This means regardless if it was gc-ed, a summation of everything sampled.

加上 -sample_index 参数后,可以切换内存分析的类型:

go tool pprof -sample_index=alloc_space  http://47.93.238.9:8080/debug/pprof/heap

共有 4 种:

类型 含义
inuse_space amount of memory allocated and not released yet
inuse_objects amount of objects allocated and not released yet
alloc_space total amount of memory allocated (regardless of released)
alloc_objects total amount of objects allocated (regardless of released)

参考资料【大彬 实战内存泄露】讲述了如何通过类似于 diff 的方式找到前后两个时刻多出的 goroutine,进而找到 goroutine 泄露的原因,并没有直接使用 heap 或者 goroutine 的 profile 文件。同样推荐阅读!

总结

pprof 是进行 Go 程序性能分析的有力工具,它通过采样、收集运行中的 Go 程序性能相关的数据,生成 profile 文件。之后,提供三种不同的展现形式,让我们能更直观地看到相关的性能数据。

得到性能数据后,可以使用 topweblist等命令迅速定位到相应的代码处,并进行优化。

“过早的优化是万恶之源”。实际工作中,很少有人会关注性能,但当你写出的程序存在性能瓶颈,qa 压测时,qps 上不去,为了展示一下技术实力,还是要通过 pprof 观察性能瓶颈,进行相应的性能优化。

参考资料

【Russ Cox 优化过程,并附上代码】https://blog.golang.org/profiling-go-programs

【google pprof】https://github.com/google/pprof

【使用 pprof 和火焰图调试 golang 应用】https://cizixs.com/2017/09/11/profiling-golang-program/

【资源合集】https://jvns.ca/blog/2017/09/24/profiling-go-with-pprof/

【Profiling your Golang app in 3 steps】https://coder.today/tech/2018-11-10_profiling-your-golang-app-in-3-steps/

【案例,压测 Golang remote profiling and flamegraphs】https://matoski.com/article/golang-profiling-flamegraphs/

【煎鱼 pprof】https://segmentfault.com/a/1190000016412013

【鸟窝 pprof】https://colobu.com/2017/03/02/a-short-survey-of-golang-pprof/

【关于 Go 的 7 种性能分析方法】https://blog.lab99.org/post/golang-2017-10-20-video-seven-ways-to-profile-go-apps.html

【pprof 比较全】https://juejin.im/entry/5ac9cf3a518825556534c76e

【通过实例来讲解分析、优化过程】https://artem.krylysov.com/blog/2017/03/13/profiling-and-optimizing-go-web-applications/

【Go 作者 Dmitry Vyukov】https://github.com/golang/go/wiki/Performance

【wolfogre 非常精彩的实战文章】https://blog.wolfogre.com/posts/go-ppof-practice/

【dave.cheney】https://dave.cheney.net/high-performance-go-workshop/dotgo-paris.html

【实战案例】https://www.cnblogs.com/sunsky303/p/11058808.html

【大彬 实战内存泄露】https://segmentfault.com/a/1190000019222661

【查找内存泄露】https://www.freecodecamp.org/news/how-i-investigated-memory-leaks-in-go-using-pprof-on-a-large-codebase-4bec4325e192/

【雷神 3 性能优化】https://diducoder.com/sotry-about-sqrt.html

原文地址:https://www.cnblogs.com/qcrao-2018/p/11832021.html

时间: 2024-11-03 02:11:36

深度解密Go语言之 pprof的相关文章

深度解密Go语言之Slice

目录 当我们在说 slice 时,到底在说什么 slice 的创建 直接声明 字面量 make 截取 slice 和数组的区别在哪 append 到底做了什么 为什么 nil slice 可以直接 append 传 slice 和 slice 指针有什么区别 总结 参考资料 Go 语言的 slice 很好用,不过也有一些坑.slice 是 Go 语言一个很重要的数据结构.网上已经有很多文章写过了,似乎没必要再写.但是每个人看问题的视角不同,写出来的东西自然也不一样.我这篇会从更底层的汇编语言去解

深度解密Go语言之 map

目录 什么是 map 为什么要用 map map 的底层如何实现 map 内存模型 创建 map 哈希函数 key 定位过程 map 的两种 get 操作 如何进行扩容 map 的遍历 map 的赋值 map 的删除 map 进阶 可以边遍历边删除吗 key 可以是 float 型吗? 总结 参考资料 这篇文章主要讲 map 的赋值.删除.查询.扩容的具体执行过程,仍然是从底层的角度展开.结合源码,看完本文一定会彻底明白 map 底层原理. 我要说明的是,这里对 map 的基本用法涉及比较少,我

深度解密Go语言之unsafe

目录 指针类型 什么是 unsafe 为什么有 unsafe unsafe 实现原理 unsafe 如何使用 获取 slice 长度 获取 map 长度 map 源码中的应用 Offsetof 获取成员偏移量 string 和 slice 的相互转换 总结 参考资料 上一篇文章我们详细分析了 map 的底层实现,如果你也跟着阅读了源码,那一定对 unsafe.Pointer 不陌生,map 对 key 进行定位的时候,大量使用. unsafe.Pointer 位于 unsafe 包,这篇文章,我

顶级资深工程师深度讲解Go语言

[下载地址:https://pan.baidu.com/s/1nx82k7mOn8ErlPSsCdLfTw ] 第1章 课程介绍欢迎大家来到深度讲解Go语言的课堂.本课程将从基本语法讲起,逐渐深入,帮助同学深度理解Go语言面向接口,函数式编程,错误处理,测试,并行计算等元素,并带领大家实现一个分布式爬虫的实战项目. 第2章 基础语法量,常量,类型,选择,循环,函数,指针,本章节带领大家学习一门新语言所需的必备语法知识.让大家对Go语言有一个初步的认识! 第3章 内建容器本章节我们来学习数组,切片

Google资深工程师深度讲解Go语言☆

准备工作 ######课程导读: Go语言的历史起源??? 为什么会有Go出现?? Go的发展趋势?? 怎么样学习Go?? ######安装与环境: 视频地址:安装与环境 ######Go的参考资料汇总:1.GO语言零基础入门资料整理2.Restful接口开发(PHP)3.Go入门指南4.Learngobyexample 巩固语法基础的Demo,初学者“抄”(学习)代码的地方5.Go 初学者成长之路6.Go 标准库中文文档7.<编程之法:面试和算法心得>8.目录: 第1章 课程介绍    欢迎

Google资深工程师深度讲解Go语言

第1章 课程介绍   1-1 课程导读   1-2 安装与环境第2章 基础语法   2-1 变量定义   2-2 内建变量类型   2-3 常量与枚举   2-4 条件语句   2-5 循环   2-6 函数   2-7 指针第3章 内建容器   3-1 数组   3-2 切片的概念   3-3 切片的操作   3-4 Map   3-5 Map例题   3-6 字符和字符串处理   第4章 面向"对象"   4-1 结构体和方法   4-2 包和封装   4-3 扩展已有类型   4

深度解密HTTP通信细节

目录 HTTP报文截获 背景介绍 抓包 mac本地 远程docker 请求 && 分析 关闭服务进程 关闭docker 正常请求 HTTP协议分析 整体介绍 编码 MIME类型 URI/URL/URN HTTP方法 状态码 报文格式 HTTP协议进阶 代理 缓存 cookie 实体和编码 国际化支持 重点向与负载均衡 HTTP重定向 DNS重定向 HTTP连接 并行连接 持久连接 管道化连接 小结 上一篇文章--<"三次握手,四次挥手",你真的懂吗>中,我们

深度探索C语言函数可变长参数

通常我们使用的C函数的参数个数都是固定的,但也有不固定的.比如printf()与scanf().如何自己动手实现一个可变参数函数,这个还是有点技巧的. 我们最常用的就是定义一个宏,使用printf或者printk,如下 #define wwlogk(fmt, args...) printk(fmt, ## args) 现在我们自己动手实现一个可变参数的函数,后面分析原理.首先看一个例子: #include <stdio.h> #include <stdarg.h> int Sum(

Google资深工程师深度讲解Go语言全套

咱们每先多积累一分,时间越长你与其他人的差距就会越发明显,源于复利效应 课程目录第1章 课程介绍第2章 基础语法第3章 内建容器第4章 面向“对象”第5章 面向接口第6章 函数式编程第7章 错误处理和资源管理第8章 测试与性能调优第9章 Goroutine第10章 Channel第11章 http及其他标准库第12章 迷宫的广度优先搜索第13章 开始实战项目第14章 单任务版爬虫第15章 并发版爬虫第16章 数据存储和展示第17章 分布式爬虫第18章 课程总结 ◆因问题,百度网盘的链接分享被取消