性能分析
性能分析
当一个程序编写完毕后,我们对它的要求不仅仅只是能运行,还希望它是一个稳定高效的应用。通过各种各样的测试,我们可以保证程序大部分的稳定性,而程序是否高效,就需要我们对其进行性能分析,在此前的内容中,性能分析的唯一手段就只能通过Benchmark来测试某一个功能单元的平均执行耗时,内存分配情况等,然而现实中对程序性能分析的需求远远不止于此,有时候我们需要分析程序整体的CPU占用,内存占用,堆分配情况,协程状态,热点代码路径等等,这是Benchmark所不能满足的。好在go工具链集成了许多性能分析工具以供开发者使用,下面就来逐一讲解。
逃逸分析
在go中,变量的内存分配是由编译器决定的,一般就分配到栈上和堆上这两个地方。如果一个本该分配到栈上的变量被分配到了堆上,那么这种情况就称之为逃逸,逃逸分析便是要分析程序中的内存分配情况,由于它是在编译期进行,所以是静态分析的一种。
提示
前往内存分配文章了解go具体是如分配内存的。
引用局部指针
package main
func main() {
GetPerson()
}
type Person struct {
Name string
Mom *Person
}
func GetPerson() Person {
mom := Person{Name: "lili"}
son := Person{Name: "jack", Mom: &mom}
return son
}
GetPerson
函数中创建了mom
变量,由于它是函数内创建的,本来应该是将其分配到栈上,但是它被son
的Mom
字段所引用了,并且son
被作为了函数返回值返回出去,所以编译器就将其分配到了堆上。这是一个很简单的示例,所以理解起来不需要花费太多力气,但如果是一个大点的项目,代码行数有好几万,人工分析就不是那么的轻松了,为此就需要使用工具来进行逃逸分析。前面提到过内存的分配是由编译器主导的,所以逃逸分析也是由编译器来完成,使用起来十分简单,只需要执行如下命令:
$ go build -gcflags="-m -m -l"
gcflags
即编译器gc
的参数,
-m
,打印出代码优化建议,同时出现两个会更加进行细节的输出-l
,禁用内联优化
输出如下
$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:13:2: mom escapes to heap:
./main.go:13:2: flow: son = &mom:
./main.go:13:2: from &mom (address-of) at ./main.go:14:35
./main.go:13:2: from Person{...} (struct literal element) at ./main.go:14:15
./main.go:13:2: from son := Person{...} (assign) at ./main.go:14:6
./main.go:13:2: flow: ~r0 = son:
./main.go:13:2: from return son (return) at ./main.go:15:2
./main.go:13:2: moved to heap: mom
编译器很明确的告诉了我们变量mom
发生了逃逸,导致原因是因为返回值包含了函数内的局部指针,除了这种情况外还有其它情况可能会发生逃逸现象
::: tips
如果你对逃逸分析的细节感兴趣,可以在标准库cmd/compile/internal/escape/escape.go
里面了解到更多内容。
:::
闭包引用
闭包引用了函数外的变量,那么该变量也会逃逸到堆上,这个很好理解。
package main
func main() {
a := make([]string, 0)
do(func() []string {
return a
})
}
func do(f func() []string) []string {
return f()
}
输出
$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:10:9: f does not escape
./main.go:4:2: main capturing by value: a (addr=false assign=false width=24)
./main.go:4:11: make([]string, 0) escapes to heap:
./main.go:4:11: flow: a = &{storage for make([]string, 0)}:
./main.go:4:11: from make([]string, 0) (spill) at ./main.go:4:11
./main.go:4:11: from a := make([]string, 0) (assign) at ./main.go:4:4
./main.go:4:11: flow: ~r0 = a:
./main.go:4:11: from return a (return) at ./main.go:6:3
./main.go:4:11: make([]string, 0) escapes to heap
./main.go:5:5: func literal does not escape
空间不足
栈空间不足时,也会发生逃逸现象,下面创建的切片申请了1<<15
的容量
package main
func main() {
_ = make([]int, 0, 1<<15)
}
输出
$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:4:10: make([]int, 0, 32768) escapes to heap:
./main.go:4:10: flow: {heap} = &{storage for make([]int, 0, 32768)}:
./main.go:4:10: from make([]int, 0, 32768) (too large for stack) at ./main.go:4:10
./main.go:4:10: make([]int, 0, 32768) escapes to heap
长度未知
当切片的长度是一个变量的时候,由于其长度未知,便会发生逃逸现象(map并不会)
package main
func main() {
n := 100
_ = make([]int, n)
}
输出
$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:5:10: make([]int, n) escapes to heap:
./main.go:5:10: flow: {heap} = &{storage for make([]int, n)}:
./main.go:5:10: from make([]int, n) (non-constant size) at ./main.go:5:10
./main.go:5:10: make([]int, n) escapes to heap
还有一种特殊情况便是函数参数为...any
类型时也可能会发生逃逸
package main
import "fmt"
func main() {
n := 100
fmt.Println(n)
}
输出
$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:7:14: n escapes to heap:
./main.go:7:14: flow: {storage for ... argument} = &{storage for n}:
./main.go:7:14: from n (spill) at ./main.go:7:14
./main.go:7:14: from ... argument (slice-literal-element) at ./main.go:7:13
./main.go:7:14: flow: {heap} = {storage for ... argument}:
./main.go:7:14: from ... argument (spill) at ./main.go:7:13
./main.go:7:14: from fmt.Println(... argument...) (call parameter) at ./main.go:7:13
./main.go:7:13: ... argument does not escape
./main.go:7:14: n escapes to heap
我们之所以要进行逃逸分析,把内存分配控制的这么细,主要是为了减轻GC压力,不过go并不是c语言,内存分配的最终决定权依旧掌握在编译器手里,除了极端的性能要求情况下,大多数时候我们也无需太过于专注内存分配的细节,毕竟GC诞生的目的就是为了解放开发者。
小细节
对于一些引用类型,当确认以后不会再用到它时,我们可以将其置为nil
,来告诉GC可以将其回收。
type Writer struct {
buf []byte
}
func (w Writer) Close() error {
w.buff = nil
return nil
}
pprof
pprof(program profiling),是一个程序性能分析的利器,它会对程序运行时的数据进行部分采样,涵盖了cpu,内存,协程,锁,堆栈信息等许多方面,然后再使用工具对采样的数据进行分析并展示结果。
所以pprof的使用步骤就只有两步:
- 采集数据
- 分析结果
采集
数据采集的方式有两种,自动和手动,各有优劣。在此之前,编写一个简单的函数来模拟内存和cpu的消耗
func Do() {
for i := 0; i < 10; i++ {
slice := makeSlice()
sortSlice(slice)
}
}
func makeSlice() []int {
var s []int
for range 1 << 24 {
s = append(s, rand.Int())
}
return s
}
func sortSlice(s []int) {
slices.Sort(s)
}
手动
手动采集就是通过代码来控制,其优点是可控,灵活,可以自定义,直接在代码中使用pprof需要引入runtime/pprof
包
package main
import (
"log"
"os"
"runtime/pprof"
)
func main() {
Do()
w, _ := os.Create("heap.pb")
heapProfile := pprof.Lookup("heap")
err := heapProfile.WriteTo(w, 0)
if err != nil {
log.Fatal(err)
}
}
pprof.Lookup
支持的参数如下面代码所示
profiles.m = map[string]*Profile{
"goroutine": goroutineProfile,
"threadcreate": threadcreateProfile,
"heap": heapProfile,
"allocs": allocsProfile,
"block": blockProfile,
"mutex": mutexProfile,
}
该函数会将采集到的数据写入到指定文件中,在写入时传入的数字有以下几个含义
0
,写入压缩后的Protobuf数据,没有可读性1
,写入文本格式的数据,能够阅读,http接口返回的就是这一种数据2
,仅goroutine
可用,表示打印panic
风格的堆栈信息
采集cpu数据需要单独使用 pprof.StartCPUProfile
函数,它需要一定的时间进行采样,且其原始数据不可读,如下所示
package main
import (
"log"
"os"
"runtime/pprof"
"time"
)
func main() {
Do()
w, _ := os.Create("cpu.out")
err := pprof.StartCPUProfile(w)
if err != nil {
log.Fatal(err)
}
time.Sleep(time.Second * 10)
pprof.StopCPUProfile()
}
采集trace的数据也是同样如此
package main
import (
"log"
"os"
"runtime/trace"
"time"
)
func main() {
Do()
w, _ := os.Create("trace.out")
err := trace.Start(w)
if err != nil {
log.Fatal(err)
}
time.Sleep(time.Second * 10)
trace.Stop()
}
自动
net/http/pprof
包将上面的分析函数包装成了http接口,并注册到了默认路由中,如下所示
package pprof
import ...
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)
}
这使得我们可以直接一键运行pprof数据采集
package main
import (
"net/http"
// 记得要导入这个包
_ "net/http/pprof"
)
func main() {
go func(){
http.ListenAndServe(":8080", nil)
}
for {
Do()
}
}
此时打开浏览器访问http://127.0.0.1:8080/debug/pprof
,就会出现这样的页面
页面中有几个可供选择的选项,它们分别代表了
allocs
:内存分配抽样block
:同步原语的阻塞跟踪cmdline
:当前程序的命令行调用goroutine
:跟踪所有的协程heap
:对于存活对象的内存分配抽样mutex
:互斥锁相关信息的跟踪profile
:cpu分析,会分析一段时间并下载一个文件threadcreate
:分析导致创建新OS线程原因trace
:当前程序执行情况的跟踪,同样会下载一个文件
这里的数据大多数可读性并不高,主要是拿来给工具分析用的,如下所图
具体的分析工作要留到后面再进行,除了profile
和trace
两个选项之外,如果你想要在网页中下载数据文件,可以将query
参数debug=1
去掉。也可以将这些接口集成到自己的路由中而不是使用默认路由,如下所示
package main
import (
"net/http"
"net/http/pprof"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/trace", pprof.Trace)
servre := &http.Server{
Addr: ":8080",
Handler: mux,
}
servre.ListenAndServe()
}
如此一来,也能其集成到其它的web框架中,比如gin
,iris
等等。
分析
在得到了采集的数据文件后,有两种方式进行分析,命令行或网页,两者都需要借助pprof
命令行工具,go默认集成该工具,所以不需要额外下载。
pprof开源地址:google/pprof: pprof is a tool for visualization and analysis of profiling data (github.com)
命令行
将此前收集到的数据文件作为参数
$ go tool pprof heap.pb
如果数据是由web采集的话,用web url替换掉文件名即可。
$ go tool pprof -http :8080 http://127.0.0.1/debug/pprof/heap
然后就会出现一个交互式的命令行
15:27:38.3266862 +0800 CST
Type: inuse_space
Time: Apr 15, 2024 at 3:27pm (CST)
No samples were found with the default sample value type.
Try "sample_index" command to analyze different sample values.
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)
输入help,可以查看其它命令
Commands:
callgrind Outputs a graph in callgrind format
comments Output all profile comments
disasm Output assembly listings annotated with samples
dot Outputs a graph in DOT format
eog Visualize graph through eog
evince Visualize graph through evince
...
在命令行中查看数据一般使用top
命令,也可以用traces
命令不过它的输出很冗长,top
命令只是简单的看个大概。
(pprof) top 5
Showing nodes accounting for 117.49MB, 100% of 117.49MB total
flat flat% sum% cum cum%
117.49MB 100% 100% 117.49MB 100% main.makeSlice (inline)
0 0% 100% 117.49MB 100% main.Do
0 0% 100% 117.49MB 100% main.main
0 0% 100% 117.49MB 100% runtime.main
简单介绍一些其中的一些指标(cpu同理)
flat
,代表着当前函数所消耗的资源cum
,当前函数及其后续调用链所消耗的资源总和flat%
,flat/totalcum%
,cum/total
我们可以很明显的看到整个调用栈的内存占用是117.49MB,由于Do
函数本身什么都没做,只是调用了其它函数,所以其flat
的指标是0,创建切片的事情是由makeSlice
函数在负责,所以其flat
指标是100%
。
我们可以将转换可视化的格式,pprof
支持相当多的格式,比如pdf,svg,png,gif等等(需要安装Graphviz)。
(pprof) png
Generating report in profile001.png
通过图片我们可以更加清晰的看到整个调用栈的内存情况。
通过list
命令以源代码的形式查看
(pprof) list Do
Total: 117.49MB
ROUTINE ======================== main.Do in D:\WorkSpace\Code\GoLeran\golearn\example\main.go
0 117.49MB (flat, cum) 100% of Total
. . 21:func Do() {
. . 22: for i := 0; i < 10; i++ {
. 117.49MB 23: slice := makeSlice()
. . 24: sortSlice(slice)
. . 25: }
. . 26:}
. . 27:
. . 28:func makeSlice() []int {
对于图片和源代码而言,还可以用web
和weblist
命令在浏览器中查看图片和源代码。
网页
在此之前为了数据更加多样化,修改一下模拟的函数
func Do1() {
for i := 0; i < 10; i++ {
slice := makeSlice()
sortSlice(slice)
}
}
func Do2() {
for i := 0; i < 10; i++ {
slice := makeSlice()
sortSlice(slice)
}
}
func makeSlice() []int {
var s []int
for range 1 << 12 {
s = append(s, rand.Int())
}
return s
}
func sortSlice(s []int) {
slices.Sort(s)
}
网页分析可以可视化结果,免去了我们手动操作命令行,在使用网页分析时,只需执行如下命令
$ go tool pprof -http :8080 heap.pb
如果数据是由web采集的话,将web url替换掉文件名即可
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/heap
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/profile
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/goroutine
提示
关于如何分析数据,前往pprof: How to read the graph了解更多
网页中总共有6个可查看的项
- Top,同命令top
- Graph,直线图
- Flame Graph,火焰图
- Peek,
- Source,查看源代码
- Disassemble,反汇编查看
对于内存而言四个维度可以分析
alloc_objects
:目前已分配的所有对象数量,包括已释放alloc_spcae
:目前为止已分配的所有内存空间,包括已释放inuse_objects
:正在使用的对象数量inuse_space
:正在使用的内存空间
上图最下方的白色叶子节点代表着不同大小的对象占用。
关于折线图,有几个点要注意
- 块的颜色越深,占用越高,线越粗,占用越高
- 实线代表着直接调用,虚线代表着略过了一些调用链。
对于火焰图而言,从上往下看是调用链,从左往右看是cum的占用百分比。
trace
pprof主要负责分析程序的资源占用,而trace更适合跟踪程序的运行细节,它与前者的数据文件互不兼容,由go tool trace
命令来完成相关的分析工作。
如果是手动采集的数据,可以将文件名作为参数
$ go tool trace trace.out
如果是自动采集,也是同样的道理
$ curl http://127.0.0.1:8080/debug/pprof/trace > trace.out && go tool trace trace.out
执行后会开启一个web server
2024/04/15 17:15:40 Preparing trace for viewer...
2024/04/15 17:15:40 Splitting trace for viewer...
2024/04/15 17:15:40 Opening browser. Trace viewer is listening on http://127.0.0.1:51805
打开后页面大概如下所示
这里面主要包含了以下几个部分,这些数据要看懂还挺不容易的。
Event timelines for running goroutines
trace by proc:显示每一时刻在该处理器上运行的协程时间线
trace by thread:显示每一时刻在OS线程上运行的协程时间线
Goroutine analysis:展示每组主函数的协程相关统计信息
Profiles
- Network blocking profile: 因网络IO而阻塞的协程信息
- Synchronization blocking profile:因同步原语而阻塞的协程信息
- Syscall profile:因系统调用而阻塞的协程信息
User-defined tasks and regions
- User-defined tasks:用户定义任务的相关协程信息
- User-defined regions:用户定义代码区域的相关协程信息
Garbage collection metrics
Minimum mutator utilization:展示最近GC的最大耗时