12 Go代码性能分析
# 12 Go代码性能分析
性能分析(Profiling)是一种用于测量计算机系统中资源使用情况的方法。进行性能分析通常是为了了解程序中的CPU或内存使用情况,以便针对执行时间、程序大小或可靠性进行优化。除了性能分析,在本章中,我们还将学习以下内容:
- 如何使用
pprof
对Go语言中的请求进行性能分析 - 如何比较多个性能分析结果
- 如何解读生成的性能分析报告和火焰图(flame graphs)
进行性能分析有助于推断在代码中哪些地方可以进行改进,以及函数中各个部分在整个系统中所花费的时间占比情况。
# 理解性能分析
对Go代码进行性能分析是找出代码库中瓶颈的最佳方法之一。我们的计算机系统存在物理限制(例如CPU时钟速度、内存大小/速度、I/O读写速度以及网络吞吐量等),但是我们通常可以通过优化程序,更高效地利用硬件资源。使用性能分析工具对计算机程序进行分析后,会生成一份报告。这份报告通常被称为性能分析报告(profile),它能提供所运行程序的相关信息。了解程序的CPU和内存使用情况有诸多原因,以下列举几个例子:
# CPU性能分析的原因
- 检查软件新版本中的性能改进情况
- 确认每个任务的CPU使用量
- 限制CPU使用以节省成本
- 找出延迟产生的原因
# 内存性能分析的原因
- 全局变量使用不当
- 未完成的协程(Goroutines)
- 反射使用不当
- 大量字符串分配
接下来,我们将探讨检测方法。
# 探索检测方法
pprof
工具提供了多种将性能分析集成到代码中的方法。Go语言的创造者希望确保在实现编写高性能程序所需的性能分析时,既简单又有效。我们可以在Go软件开发的多个阶段进行性能分析,即设计阶段、新函数创建阶段、测试阶段和生产阶段。
需要注意的是,性能分析确实会带来少量的性能损耗,因为在运行的二进制文件中需要持续收集更多的指标数据。许多公司(包括谷歌)认为这种权衡是可以接受的。为了持续编写高性能的代码,为CPU和内存性能分析增加5%的额外开销是值得的。
# 使用go test
进行性能分析
你可以使用go test
命令创建CPU和内存性能分析报告。如果你想比较多次测试运行的输出结果,这种方法会很有用。这些输出结果通常会存储在长期存储设备中,以便在更长的时间范围内进行比较。要对测试执行CPU和内存性能分析,可执行go test -cpuprofile /tmp/cpu.prof -memprofile /tmp/mem.prof -bench
命令。
这将创建两个输出文件cpu.prof
和mem.prof
,它们都会存储在/tmp/
文件夹中。后续可以使用本章“分析性能分析报告”部分介绍的技术来分析这些生成的性能分析报告。
# 在代码中手动插入性能分析代码
如果你想对代码中的特定部分进行性能分析,可以直接在该代码周围实现性能分析功能。如果你只想分析代码的一小部分,或者希望pprof
的输出更小更简洁,又或者不想在已知的高开销代码部分周围添加性能分析代码而增加额外开销,这种方法会特别有用。针对代码库的不同部分进行CPU和内存性能分析,有不同的方法。
对特定代码块进行CPU使用情况的性能分析代码如下:
func foo() {
pprof.StartCPUProfile()
defer pprof.StopCPUProfile()
...
// 代码
...
}
2
3
4
5
6
7
对特定代码块进行内存使用情况的性能分析代码如下:
func bar() {
runtime.GC()
defer pprof.WriteHeapProfile()
...
// 代码
...
}
2
3
4
5
6
7
如果我们设计高效、迭代有成效,并使用下一节中的习惯用法来实现性能分析,有望无需手动在代码中插入这些片段。不过,知道这始终是一种进行代码性能分析和获取有意义输出的可行选择,也是很有帮助的。
# 对运行中的服务代码进行性能分析
在Go代码中,最常用的性能分析方法是在HTTP处理函数中启用分析器。这对于调试生产环境中的实时系统很有用。能够实时对生产系统进行性能分析,使你可以根据真实的生产数据做出决策,而不仅仅依赖于本地开发环境。
有时,只有当特定系统的数据量达到一定规模时,错误才会出现。一个能够有效处理1000个数据点的方法或函数,在其运行的底层硬件上,可能无法有效处理100万个数据点。在硬件环境变化的情况下,这一点尤为重要。无论你是在有其他干扰任务的Kubernetes环境中运行,使用规格未知的新物理硬件,还是使用新版本的代码或第三方库,了解这些变化对性能的影响,对于确保系统的可靠性和弹性至关重要。
能够从生产系统获取数据(在生产系统中,最终用户的数据量可能比你在本地使用的数据量大得多),有助于发现一些在本地迭代时可能从未注意到的性能问题,进而做出对最终用户有影响的性能改进。
如果我们想在HTTP处理函数中使用pprof
库,可以导入_ "net/http/pprof"
到主包中。
这样,你的HTTP处理函数就会注册用于性能分析的HTTP处理程序。请确保不要在公开暴露的HTTP服务器上执行此操作,因为公开程序的性能分析报告可能会暴露严重的安全漏洞。pprof
包的索引展示了你使用该包时可用的路径。以下是pprof
工具索引的截图:
我们可以查看公开的HTTP pprof
路径及其描述。路径和相关描述如下表所示:
名称 | HTTP路径 | 描述 |
---|---|---|
allocs | /debug/pprof/allocs | 内存分配信息。 |
block | /debug/pprof/block | 有关协程阻塞等待位置的信息。这通常发生在同步原语上。 |
cmdline | /debug/pprof/cmdline | 二进制文件命令行调用的值。 |
goroutine | /debug/pprof/goroutine | 当前正在运行的协程的堆栈跟踪。 |
heap | /debug/pprof/heap | 内存分配采样(用于监控内存使用和泄漏情况)。 |
mutex | /debug/pprof/mutex | 竞争互斥锁的堆栈跟踪。 |
profile | /debug/pprof/profile | CPU性能分析报告。 |
symbol | /debug/pprof/symbol | 请求程序计数器。 |
threadcreate | /debug/pprof/threadcreate | 操作系统线程创建的堆栈跟踪。 |
trace | /debug/pprof/trace | 当前程序的跟踪信息。这将在第13章“Go代码追踪”中深入讨论。 |
在下一节中,我们将讨论CPU性能分析。
# CPU性能分析简介
让我们对一个简单的Go程序进行一些示例性能分析,以便了解分析器的工作原理。我们将创建一个包含几个睡眠参数的示例程序,以此查看不同函数调用的时间情况:
- 首先,实例化包并添加所有导入:
import (
"fmt"
"io"
"net/http"
_ "net/http/pprof"
"time"
)
2
3
4
5
6
7
- 接下来,在主函数中,我们有一个HTTP处理函数,其中包含两个作为处理函数一部分被调用的睡眠函数:
func main() {
Handler := func(w http.ResponseWriter, req *http.Request) {
sleep(5)
sleep(10)
io.WriteString(w, "Memory Management Test")
}
http.HandleFunc("/", Handler)
http.ListenAndServe(":1234", nil)
}
2
3
4
5
6
7
8
9
我们的sleep
函数只是睡眠特定的毫秒数,并打印结果输出:
func sleep(sleepTime int) {
time.Sleep(time.Duration(sleepTime) * time.Millisecond)
fmt.Println("Slept for ", sleepTime, " Milliseconds")
}
2
3
4
当我们运行程序时,执行
go run httpProfiling.go
。要从这段特定代码生成性能分析报告,我们需要调用curl -s "localhost:1234/debug/pprof/profile?seconds=10" > out.dump
。这将运行10秒钟的性能分析,并将结果输出到名为out.dump
的文件中。默认情况下,pprof
工具将运行30秒,并将二进制数据输出到标准输出(STDOUT)。我们要确保将测试时间限制在合理的范围内,并且需要重定向输出,以便能够在性能分析工具中获取有意义的内容进行查看。接下来,为我们的函数生成测试负载。我们可以使用Apache Bench来完成此任务,并发10个请求,共生成5000个请求,使用
ab -n 5000 -c 10 http://localhost:1234/
进行设置。一旦我们得到这个测试的输出,就可以查看
out.dump
文件,执行go tool pprof out.dump
。这将进入性能分析器。这是C++性能分析器pprof
的一个变体,该工具具有相当多的功能。我们可以使用
topN
命令查看生成的性能分析报告中排名前N的样本,如以下截图所示:
在执行性能分析器(profiler)时,Go大约每秒会暂停程序100次。在此期间,它会记录Go协程(goroutine)栈上的程序计数器。我们还可以使用累积标志(-cum
),按照当前性能分析采样中的累积值进行排序:
- 我们还能够以图形形式展示跟踪信息的可视化表示。确保安装了
graphviz
包后(它应该包含在你的包管理器中,或者也可以从http://www.graphviz.org/ ,通过简单输入网页命令进行下载),这将为我们生成的程序性能分析提供可视化展示:
性能分析图中的红色框表示对请求流影响最大的代码路径。查看这些框,正如我们所预期的,会发现示例程序的很大一部分时间花在睡眠以及向客户端回写响应上。我们可以通过在这种网页格式中输入想要查看的函数名,来查看特定函数的情况。例如,如果想要查看sleep
函数的详细视图,只需输入(pprof) web sleep
命令。
8. 然后我们会得到一个聚焦于sleep
调用的SVG图像:
- 得到这些细分信息后,我们可能想要深入了解
sleep
函数实际执行的操作。我们可以在pprof
中使用list
命令,获取对sleep
命令调用及其后续调用进行性能分析的输出。下面的截图展示了这一情况;为简洁起见,代码进行了缩短:
通过性能分析将我们所做的工作分解为可细分的部分,能让我们从利用率的角度,深入了解开发工作的改进方向。
在下一节中,我们将了解内存性能分析。
# 内存性能分析简介
我们可以对内存执行与上一节CPU测试类似的操作。让我们看看另一种使用测试功能进行性能分析的方法。以第2章“数据结构和算法”中创建的o-logn
函数为例。我们可以利用已经为这个特定函数创建的基准测试,并在该测试中添加一些内存性能分析。我们可以执行go test -memprofile=heap.dump -bench
命令。
我们会看到与第2章“数据结构和算法”类似的输出:
唯一的区别是,现在我们从这个测试中获得了堆(heap)的性能分析数据。如果使用性能分析器查看,我们将看到关于堆使用情况的数据,而不是CPU使用情况的数据。我们还能够看到程序中每个函数的内存分配情况。下面的图表展示了这一点:
这很有帮助,因为它使我们能够看到代码各部分生成的堆大小。我们还可以查看累积内存分配最多的部分:
随着程序变得越来越复杂,了解内存使用状态变得越来越重要。在下一节中,我们将讨论如何使用上游的pprof
扩展我们的性能分析能力。
# 使用上游pprof扩展功能
如果我们希望默认就能使用更多功能,可以使用上游的pprof
二进制文件来扩展性能分析的视图:
- 我们可以通过执行
go get github.com/google/pprof
获取它。pprof
工具的调用方式有好几种。我们可以使用报告生成方法,以请求的格式(目前支持.dot
、.svg
、.web
、.png
、.jpg
、.gif
和.pdf
格式)生成文件。我们也可以像上一节进行CPU和内存性能分析那样,使用交互式终端格式。最后一种也是最常用的方法是使用HTTP服务器。这种方法需要启动一个HTTP服务器,以易于理解的格式展示许多相关输出。 - 通过
go get
获取二进制文件后,我们可以通过Web界面调用它,查看之前生成的输出:pprof -http=:1234 profile.dump
。 - 然后,我们可以访问新出现的用户界面(UI),查看默认
pprof
工具中没有的功能。这个工具的一些主要亮点如下:- 一个支持正则表达式(regex)搜索的表单字段,有助于搜索所需的性能分析元素。
- 一个下拉视图菜单,方便查看可用的不同性能分析工具。
- 一个示例下拉菜单,用于显示性能分析中的样本。
- 一个优化过滤器,用于隐藏/显示请求流的不同部分。
拥有这些用于性能分析的工具,有助于使性能分析过程更加高效。如果我们想要查看名称中包含fmt
的任何调用的运行时间,可以使用带有正则表达式过滤器的样本视图,它将突出显示fmt
调用,如下截图所示:
能够根据这些值进行过滤,有助于缩小性能不佳函数的排查范围。
# 比较多个分析结果
性能分析一个非常实用的功能是,你可以对不同的分析结果进行比较。如果我们对同一个程序进行了两次不同的测量,就能够判断所做的更改是否对系统产生了积极影响。下面我们对HTTP睡眠计时函数做一些扩展:
- 先添加一些额外的导入:
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"strconv"
"time"
)
2
3
4
5
6
7
8
9
- 接下来,修改处理程序,使其能够接受一个表示时间的查询字符串参数:
func main() {
Handler := func(w http.ResponseWriter, r *http.Request) {
sleepDuration := r.URL.Query().Get("time")
sleepDurationInt, err := strconv.Atoi(sleepDuration)
if err != nil {
fmt.Println("Incorrect value passed as a query string for time")
return
}
sleep(sleepDurationInt)
fmt.Fprintf(w, "Slept for %v Milliseconds", sleepDuration)
}
http.HandleFunc("/", Handler)
http.ListenAndServe(":1234", nil)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
- 保持
sleep
函数不变:
func sleep(sleepTime int) {
time.Sleep(time.Duration(sleepTime) * time.Millisecond)
fmt.Println("Slept for ", sleepTime, " Milliseconds")
}
2
3
4
现在有了这个额外功能,我们可以通过向HTTP处理程序传递查询参数,针对不同的计时情况获取多个分析结果:
- 运行新的计时性能分析工具:
go run timedHttpProfiling.go
1- 在另一个终端中,启动性能分析工具:
curl -s "localhost:1234/debug/pprof/profile?seconds=20" > 5-millisecond-profile.dump
1- 然后对新资源发起多次请求:
ab -n 10000 -c 10 http://localhost:1234/?time=5
1- 接着收集第二个分析结果:
curl -s "localhost:1234/debug/pprof/profile?seconds=20" > 10-millisecond-profile.dump
1- 再对新资源发起第二次请求,生成第二个分析结果:
ab -n 10000 -c 10 http://localhost:1234/?time=10
1现在我们有了两个不同的分析结果,分别存储在
5-millisecond-profile.dump
和10-millisecond-profile.dump
中。我们可以使用之前的工具来比较它们,设置一个基础分析结果和一个次要分析结果。如下截图展示了这一过程:
比较分析结果能让我们了解所做的更改对系统的影响。在下一节中,我们将介绍火焰图(flame graph)。
# 在pprof中解读火焰图
上游pprof
包中最有用的工具之一就是火焰图。火焰图是一种固定速率采样的可视化图表,有助于确定分析结果中的热点代码路径。随着程序越来越复杂,分析结果也会越来越大。通常很难确切知道是哪个代码路径占用了最多的CPU资源,或者用我常说的话来讲,哪个是“短板”。
火焰图最初是由Netflix的Brendan Gregg开发的,用于解决MySQL的CPU利用率问题。这种可视化工具的出现帮助了许多程序员和系统管理员确定程序中延迟的来源。pprof
二进制文件生成的是一种冰柱式(火焰向下)的火焰图。在火焰图中,数据按特定框架进行可视化展示:
- x轴表示请求中的所有样本集合。
- y轴表示栈中的帧数,通常也称为栈深度。
- 方框的宽度表示特定函数调用所占用的总CPU时间。
将这三个元素结合起来可视化展示,有助于确定程序的哪个部分引入了最多的延迟。你可以访问pprof
分析结果的火焰图部分,地址是http://localhost:8080/ui/flamegraph 。以下图片展示了一个火焰图示例:
如果查看第2章“数据结构与算法”中的冒泡排序(bubbleSort)示例,我们可以看到在测试中占用CPU时间的不同部分的详细情况。在交互式网页模式下,我们可以将鼠标悬停在每个样本上,查看它们的持续时间和执行时间百分比。
在下一节中,我们将了解如何在Go语言中检测内存泄漏。
# 在Go语言中检测内存泄漏
正如在第8章“Go语言中的内存管理”的“内存对象分配”部分所讨论的,我们有很多工具可以用来查看当前正在执行的程序的内存统计信息。在本章中,我们还将学习使用pprof
工具进行性能分析。Go语言中一种较为常见的内存泄漏情况是无限制地创建协程(goroutines)。当你使一个无缓冲通道过载,或者有一个高度并发的抽象不断生成新的协程但这些协程又无法结束时,就经常会出现这种情况。协程占用的资源非常少,系统通常可以生成大量的协程,但在生产环境中排查程序问题时,最终会发现它们存在一个上限,找到这个上限往往很麻烦。
在下面的示例中,我们将研究一个存在泄漏问题的无缓冲通道抽象:
- 首先初始化包并导入必要的依赖项:
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"runtime"
"time"
)
2
3
4
5
6
7
8
9
- 在
main
函数中,处理HTTP监听并为leakyAbstraction
函数提供服务。通过HTTP提供服务,这样可以更方便地观察协程数量的增长:
func main() {
http.HandleFunc("/leak", leakyAbstraction)
http.ListenAndServe("localhost:6060", nil)
}
2
3
4
- 在
leakyAbstraction
函数中,首先初始化一个无缓冲的字符串通道。然后在一个无限循环中,将协程的数量写入HTTP响应,并将wait()
函数的结果写入通道:
func leakyAbstraction(w http.ResponseWriter, r *http.Request) {
ch := make(chan string)
for {
fmt.Fprintln(w, "Number of Goroutines: ", runtime.NumGoroutine())
go func() { ch <- wait() }()
}
}
2
3
4
5
6
7
8
wait()
函数会睡眠5微秒并返回一个字符串:
func wait() string {
time.Sleep(5 * time.Microsecond)
return "Hello Gophers!"
}
2
3
4
这些函数共同作用,会不断生成协程,直到运行时无法再创建新协程并崩溃。我们可以通过执行以下命令运行服务器来测试:
go run memoryLeak.go
服务器运行后,在另一个终端窗口中,我们可以使用以下命令向服务器发起请求:
curl localhost:6060/leak
curl
命令会打印生成的协程数量,直到服务器被终止:
请注意,根据系统配置的不同,这个请求可能需要一些时间。这是正常的,它展示了程序中可用的协程数量。
使用本章所学的技术,我们可以借助pprof
进一步调试类似这样的内存问题,但理解潜在的问题将有助于我们避免内存问题。
这个示例是为了明确展示内存泄漏问题而编写的,但如果想让这个可执行程序避免协程泄漏,我们需要修改两处:
- 无限循环很可能需要设置一个边界。
- 可以添加一个带缓冲的通道,以确保有能力处理通过该通道进入的所有生成的协程。
# 总结
在本章中,我们学习了性能分析相关内容,包括什么是分析结果,以及如何使用pprof
生成分析结果。你还学习了如何使用不同的方法分析分析结果、如何比较不同的分析结果,以及如何读取火焰图来评估性能。在生产环境中具备这些能力,将有助于你维持系统稳定性、提升性能,并为终端用户带来更好的体验。在下一章中,我们将讨论另一种分析代码的方法——跟踪(tracing)。