0.前言

有时,我们开发的 Golang 程序会出现 CPU 使用率达到 100%,内存使用量过大,死锁等问题,我们该如何定位上诉问题的具体位置,来解决程序的到性能问题呢?

1.什么是 pprof

Go 是一个非常注重性能的语言,语言内置了里性能分析库 runtime/pprof、net/http/pprof 和配套的分析工具 go tool pprof。

所以我们平时说的 golang pprof 实际上包含两部分:
(1)编译到程序中的 runtime/pprof 和 net/http/pprof 包;
(2)性能分析工具 go tool pprof。

其中 runtime/pprof 和 net/http/pprof 区别如下:
(1)runtime/pprof 对于只跑一次的程序,例如每天只跑一次的离线预处理程序,调用 pprof 包提供的函数,手动开启性能数据采集。
(2)net/http/pprof 是对 runtime/pprof 的封装,封装成接口对外提供网络访问。对于一个 HTTP 服务,访问 pprof 提供的 HTTP 接口,获得性能数据。

如果你的程序或者服务遇到了性能问题,诉诸 pprof 就对了。

2.pprof 的作用是什么

根据其名字我们就知道 pprof 是用于性能分析,找到程序或服务的性能瓶颈,来优化提升程序或服务的性能。

利用 pprof,我们一般用来分析程序如下几种数据:

类型描述
allocsA sampling of all past memory allocations
blockStack traces that led to blocking on synchronization primitives
cmdlineThe command line invocation of the current program
goroutineStack traces of all current goroutines
heapA sampling of memory allocations of live objects. You can specify the gc GET parameter to run GC before taking the heap sample.
mutexStack traces of holders of contended mutexes
profileCPU profile. You can specify the duration in the seconds GET parameter. After you get the profile file, use the go tool pprof command to investigate the profile.
threadcreateStack traces that led to the creation of new OS threads
traceA trace of execution of the current program. You can specify the duration in the seconds GET parameter. After you get the trace file, use the go tool trace command to investigate the trace.

最常分析的是下面几个性能数据。

  • CPU Profiling:CPU 分析,按照一定的频率采集所监听的应用程序 CPU(含寄存器)的使用情况,可确定应用程序在主动消耗 CPU 周期时花费时间的位置。
  • Memory Profiling:内存分析,在应用程序进行堆分配时记录堆栈跟踪,用于监视当前和历史内存使用情况,以及检查内存泄漏。
  • Block Profiling:阻塞分析,记录 goroutine 阻塞等待同步(包括定时器通道)的位置。
  • Mutex Profiling:互斥锁分析,报告互斥锁的竞争情况。
3.pprof 的使用模式

使用 pprof 有几种不同的方法。

(1)报告生成(Report generation)

pprof 可以生成 DOT 格式的图形报告,并使用 Graphviz 工具将其转换为多种格式。如果提示 Graphviz 没有安装,则需要安装。

命令格式:

pprof <format> [options] source

支持的 format 有:

-dot: 生成 DOT 格式的报告。所有其他格式均由该格式转换得到
-svg: 生成 SVG 格式的报告
-web: 生成一个 SVG 格式的临时报告,并启动 web 浏览器查看该报告
-png, -jpg, -gif, -pdf: 生成对应格式的报告

(2)交互式终端使用(Interactive terminal use)

输入命令直接进入命令行交互模式:

pprof [options] source
top -cum

(3)Web 界面(Web interface)

pprof 开启在指定端口上的 HTTP 服务,使用浏览器访问对应端口的 url 便可以查看性能报告。

Web 界面交互方式比较直观,是最常用的交互方式。

pprof -http=[host]:[port] [options] source

端口号随便填,但不要与现有程序的端口冲突。

4.安装 Graphviz

Graphviz(Graph Visualization Software)是一个开源的图形可视化软件,它可以将 pprof 生成的性能文件转换为我们人类可读的图形,比如函数调用关系图和火焰图。

访问 Graphviz 官网 下载所需版本(Windows,Linux,Mac等)进行安装。

我这里选择的是 Windows 版本,下文性能分析也在 Windows 10 环境下完成。

5.应用程序性能分析

CPU 性能分析

CPU 性能分析主要是查看函数占用 CPU 的时长,来判断程序主要耗时部分是哪里。这也是我们最常分析程序性能的手段。

只需要调用 runtime/pprof 库即可得到我们想要的数据。

假设我们实现了这么一个程序,随机生成了 5 组数据,并且使用冒泡排序法排序。

package main

import (
	"math/rand"
	"time"
)

func generate(n int) []int {
	rand.Seed(time.Now().UnixNano())
	nums := make([]int, 0)
	for i := 0; i < n; i++ {
		nums = append(nums, rand.Int())
	}
	return nums
}

// bubbleSort 冒泡排序。
func bubbleSort(nums []int) {
	for i := 0; i < len(nums); i++ {
		for j := 1; j < len(nums)-i; j++ {
			if nums[j] < nums[j-1] {
				nums[j], nums[j-1] = nums[j-1], nums[j]
			}
		}
	}
}

func main() {
	n := 10
	for i := 0; i < 5; i++ {
		nums := generate(n)
		bubbleSort(nums)
		n *= 10
	}
}

如果我们想度量这个应用程序的 CPU 性能数据,只需要在 main 函数中添加两行代码即可:

import (
	"math/rand"
	"os"
	"runtime/pprof"
	"time"
)

func main() {
	pprof.StartCPUProfile(os.Stdout)
	defer pprof.StopCPUProfile()
	n := 10
	for i := 0; i < 5; i++ {
		nums := generate(n)
		bubbleSort(nums)
		n *= 10
	}
}

为了简单,直接将数据输出到标准输出 os.Stdout。运行该程序,将输出定向到文件 cpu.pprof 中。

go run main.go > cpu.pprof

一般来说,不建议将结果直接输出到标准输出,因为如果程序本身有输出,则会相互干扰,直接记录到一个文件中是最好的方式。

func main() {
	f, _ := os.OpenFile("cpu.pprof", os.O_CREATE|os.O_RDWR, 0644)
	defer f.Close()
	pprof.StartCPUProfile(f)
	defer pprof.StopCPUProfile()
	n := 10
	for i := 0; i < 5; i++ {
		nums := generate(n)
		bubbleSort(nums)
		n *= 10
	}
}

这样只需运行 go run main.go 即可。

得到 CPU 性能数据后,我们开始使用 go tool pprof 工具进行分析。

我们采用 Web 界面交互方式来查看性能报告,命令行输入:

go tool -http=:9999 cpu.pprof

会自动打开浏览器,我们将看到下面这样的页面:


通过上面的函数调用关系图,可以看到 main.bubbleSort 是消耗 CPU 最多的函数。

我们也可以选择使用火焰图(Flame Graph)来查看。

火焰图中,每一块代表一个函数,越大代表占用 CPU 的时间越长。从上到下,表示主调函数与被调函数。同时它也支持点击块深入进行分析!

找到了性能瓶颈,我们就可以对症下药。比如将排序算法改为复杂度为 O(nlogn) 的快排来提高程序性能。

内存性能分析

生成 profile

假设我们实现了这么一个程序,生成长度为 N 的随机字符串,拼接在一起。

package main

import (
	"github.com/pkg/profile"
	"math/rand"
)

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func randomString(n int) string {
	b := make([]byte, n)
	for i := range b {
		b[i] = letterBytes[rand.Intn(len(letterBytes))]
	}
	return string(b)
}

func concat(n int) string {
	var s string
	for i := 0; i < n; i++ {
		s += randomString(n)
	}
	return s
}

func main() {
	defer profile.Start(profile.MemProfile, profile.MemProfileRate(1)).Stop()
	concat(1000)
}

接下来,我们使用一个易用性更强的库 pkg/profile 来采集性能数据,pkg/profile 封装了 runtime/pprof 的接口,使用起来更简单。

比如我们想度量 concat() 的 CPU 性能数据,只需要一行代码即可生成 profile 文件。

import (
	"github.com/pkg/profile"
)

func main() {
	defer profile.Start().Stop()
	concat(100)
}

运行 go run main.go:

go run main.go
2020/11/22 18:38:29 profile: cpu profiling enabled, C:\Users\dablelv\AppData\Local\Temp\profile068616584\cpu.pprof
2020/11/22 18:39:12 profile: cpu profiling disabled,C:\Users\dablelv\AppData\Local\Temp\profile068616584\cpu.pprof

CPU profile 文件已经在 tmp 目录生成,得到 profile 文件后,就可以像之前一样,用 go tool pprof 命令,在浏览器或命令行进行分析了。

接下来将使用类似的方式采集内存数据,同样地,只需简单地修改 main() 函数即可。

func main() {
	defer profile.Start(profile.MemProfile, profile.MemProfileRate(1)).Stop()
	concat(1000)
}

运行程序:

go run main.go
2021/05/31 19:05:48 profile: memory profiling enabled (rate 1), C:\Users\dablelv\AppData\Local\Temp\profile768395255\mem.pprof
2021/05/31 19:05:48 profile: memory profiling disabled, C:\Users\dablelv\AppData\Local\Temp\profile768395255\mem.pprof

分析 profile

接下来,我们在浏览器中分析内存性能数据:

go tool pprof -http=:9999 C:\Users\dablelv\AppData\Local\Temp\profile768395255\mem.pprof

会自动打开浏览器,我们将看到下面这样的页面:

从这张图中,我们可以看到 concat 消耗了 524KB 内存,randomString 仅消耗了 22KB 内存。理论上,concat 函数仅仅是将 randomString 生成的字符串拼接起来,消耗的内存应该和 randomString 一致,但怎么会产生 20+ 倍的差异呢?

这和 Go 字符串内存分配的方式有关系。字符串是不可变的,因为将两个字符串拼接时,相当于是产生新的字符串,如果当前的空间不足以容纳新的字符串,则会申请更大的空间,将新字符串完全拷贝过去,这消耗了 2 倍的内存空间。在这 100 次拼接的过程中,会产生多次字符串拷贝,从而消耗大量的内存。

那有什么好的方式呢?使用 strings.Builder 替换 + 进行字符串拼接,将有效地降低内存消耗。

func concat(n int) string {
	var s strings.Builder
	for i := 0; i < n; i++ {
		s.WriteString(randomString(n))
	}
	return s.String()
}

接下来,重新运行程序:

go run main.go
2021/05/31 20:22:12 profile: memory profiling enabled (rate 1), C:\Users\dablelv\AppData\Local\Temp\profile592131615\mem.pprof
2021/05/31 20:22:12 profile: memory profiling disabled, C:\Users\dablelv\AppData\Local\Temp\profile592131615\mem.pprof

这次换个方式来使用 pprof,直接通过命令行进入交互:


可以看到,使用 strings.Builder 后,concat 内存消耗降为了原来的 1/8 。

6.HTTP 服务性能分析

如果我们的程序不是跑一次就结束的程序,而是一个常驻的 HTTP 服务。那么开启 pprof 的方式和上面介绍的会有所区别。

import _ net/http/pprof

CPU 性能分析

下面我们稍微改造一下上面随机数排序,生成指定范围内的随机数并排序。以 HTTP 接口的形式来调用,并获取其 CPU profile 文件。

package main

import (
	"fmt"
	"math/rand"
	"net/http"
	"time"

	_ "net/http/pprof"
)

func generate(n int) []int {
	rand.Seed(time.Now().UnixNano())
	nums := make([]int, 0, n)
	for i := 0; i < n; i++ {
		nums = append(nums, rand.Intn(n))
	}
	return nums
}

// bubbleSort 升序排序
func bubbleSort(nums []int) {
	for i := 0; i < len(nums); i++ {
		for j := 1; j < len(nums)-i; j++ {
			if nums[j] < nums[j-1] {
				nums[j], nums[j-1] = nums[j-1], nums[j]
			}
		}
	}
}

func randHandler(w http.ResponseWriter, r *http.Request) {
	nums := generate(1000)
	bubbleSort(nums)
	fmt.Fprintf(w, "ordered random number is %v", nums)
}

func main() {
	http.HandleFunc("/Rand", randHandler)
	http.ListenAndServe(":8888", nil)
}
http://127.0.0.1:8888/Rand


同样的做法,当我们在代码中 import 包 “net/http/pprof” 时,pprof 包会自动注册 handler,处理相关的请求:

// src/net/http/pprof/pprof.go

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/” 会得到一个汇总页面:


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

关于 goroutine 的信息有两个链接,goroutine 和 full goroutine stack dump,前者是一个汇总的消息,可以查看 goroutines 的总体情况,后者则可以看到每一个 goroutine 的状态。

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

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

go tool pprof -http=:9999 profile

或者生成 svg 格式的函数调用关系图报告,通过浏览器来查看:

go tool pprof profile
web


从上图可以看到,CPU 耗时主要花在了冒泡排序函数,我们可以采用性能更高的排序算法,比如快排来优化。

注意,采集 CPU profile 期间,我们要不断触发对接口 randHandler 的调用,不然采集不到数据。

内存性能分析

同样地,我们将上面的内存有问题的代码改造成 HTTP 接口进行采样分析。

package main

import (
	"fmt"
	"math/rand"
	"net/http"
	"strings"

	_ "net/http/pprof"
)

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func randomString(n int) string {
	b := make([]byte, n)
	for i := range b {
		b[i] = letterBytes[rand.Intn(len(letterBytes))]
	}
	return string(b)
}

func concatSmall(n int) string {
	var s strings.Builder
	for i := 0; i < n; i++ {
		s.WriteString(randomString(n))
	}
	return s.String()
}

func concatLarge(n int) string {
	var s string
	for i := 0; i < n; i++ {
		s += randomString(n)
	}
	return s
}

func concatHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "random string is %v", concatLarge(1000))
}

func main() {
	debug.SetGCPercent(-1)
	http.HandleFunc("/concat", concatHandler)
	http.ListenAndServe(":8888", nil)
}
debug.SetGCPercent(-1)
http://127.0.0.1:8888/concat
http://127.0.0.1:8888/debug/pprof/heap
go tool pprof -http=:9999 http://127.0.0.1:8888/debug/pprof/heap

在堆信息中你可以查看分配的堆大小和对象数量,或者当前没有释放的堆大小和对象数量。

或者通过命令行查看:

go tool pprof http://127.0.0.1:8888/debug/pprof/heap


下面我们换成 concatSmall 函数进行拼接字符串,再测试一次。

func concatHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "random string is %v", concatSmall(100))
}

还是通过命令行交互方式进行分析:


可见 concatSmall 占用更小的内存。

7.小结

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

本文介绍了 Go 程序性能分析工具 pprof 的使用,以应用程序和 HTTP 服务两种不同类型的程序为例,介绍 CPU 和内存性能数据的生成与分析。

其他类型的性能分析不再赘述,比如 Block Profiling,Mutex Profiling。后面如果在实践中遇到,会补充。


参考文献

Profiling Go programs with pprof
Go 语言高性能编程.pprof 性能分析
Golang 大杀器之性能剖析 PProf | 煎鱼
golang pprof 实战
google pprof doc
深度解密Go语言之 pprof