一文读懂 Go profiling 和性能优化

http://yangxikun.com/golang/2017/12/24/golang-profiling-optimizing.html

本文总结自 Profiling and Optimizing Go

[1],对应的 PPT[2],有梯子的可以直接看视频,没梯子的也可以看下这篇文章:) 。

Golang 的 runtime 内建了强大的分析工具 pprof,能帮助我们对程序运行时的 CPU、内存进行分析。

首先,跟随视频,将作者提供的代码 clone 下来。在我们的工作区执行 go get -v github.com/prashantv/go_profiling_talk即可。

进入src/github.com/prashantv/go_profiling_talk,master 分支是优化过的最快的版本,slow 分支是优化前的版本,执行go run main.go即可启动一个 http 服务。

代码说明:

  • 以一个 http 服务运行,有两个请求路径/hello和/simple
  • /hello的请求会增加一些统计信息的记录,然后输出Hello world
  • /simple的请求则是简单的输出Hello world!
  • 实践目的:通过 pprof 分析优化/hello接口的性能,使其与/simple接口的性能差距尽量小。

先来看看未优化前/hello和/simple的性能对比:

一文读懂 Go profiling 和性能优化

很明显/hello要比/simple慢很多,接下来就开始我们的性能分析之旅。首先需要保证/hello接口处于压测状态,因为这样才能进行性能分析的数据采样。

执行~/Software/go-app/bin/go-wrk -d 500 http://localhost:9090/hello进行 500s 的持续压测。

访问http://localhost:9090/debug/pprof/,能够看到:

  • {当前协程数量} goroutine
  • {当前堆上对象的数量} heap

goroutine 页面

展示了当前正在运行的所有协程的调用栈,每一块调用栈的开头表示 pprof 采样时有多少个协程处于这个调用栈,如下表示有 4 个协程:

4 @ 0x42ec9c 0x429daa 0x4293a7 0x491b9e 0x491c1d 0x4929ba 0x563772 0x575aad 0x65bef5 0x516a5a 0x51790c 0x517b74 0x5e9fd0 0x5e9ddb 0x656169 0x65d1fc 0x660ffe 0x45d5e1
#\t0x4293a6\tinternal/poll.runtime_pollWait+0x56\t\t/home/rokety/Software/go/src/runtime/netpoll.go:173
#\t0x491b9d\tinternal/poll.(*pollDesc).wait+0xad\t\t/home/rokety/Software/go/src/internal/poll/fd_poll_runtime.go:85
#\t0x491c1c\tinternal/poll.(*pollDesc).waitRead+0x3c\t\t/home/rokety/Software/go/src/internal/poll/fd_poll_runtime.go:90
#\t0x4929b9\tinternal/poll.(*FD).Read+0x189\t\t\t/home/rokety/Software/go/src/internal/poll/fd_unix.go:126
#\t0x563771\tnet.(*netFD).Read+0x51\t\t\t\t/home/rokety/Software/go/src/net/fd_unix.go:202
#\t0x575aac\tnet.(*conn).Read+0x6c\t\t\t\t/home/rokety/Software/go/src/net/net.go:176
#\t0x65bef4\tnet/http.(*connReader).Read+0x104\t\t/home/rokety/Software/go/src/net/http/server.go:753
#\t0x516a59\tbufio.(*Reader).fill+0x119\t\t\t/home/rokety/Software/go/src/bufio/bufio.go:97
#\t0x51790b\tbufio.(*Reader).ReadSlice+0x2b\t\t\t/home/rokety/Software/go/src/bufio/bufio.go:338
#\t0x517b73\tbufio.(*Reader).ReadLine+0x33\t\t\t/home/rokety/Software/go/src/bufio/bufio.go:367
#\t0x5e9fcf\tnet/textproto.(*Reader).readLineSlice+0x6f\t/home/rokety/Software/go/src/net/textproto/reader.go:55
#\t0x5e9dda\tnet/textproto.(*Reader).ReadLine+0x2a\t\t/home/rokety/Software/go/src/net/textproto/reader.go:36
#\t0x656168\tnet/http.readRequest+0x98\t\t\t/home/rokety/Software/go/src/net/http/request.go:925
#\t0x65d1fb\tnet/http.(*conn).readRequest+0x17b\t\t/home/rokety/Software/go/src/net/http/server.go:933
#\t0x660ffd\tnet/http.(*conn).serve+0x50d\t\t\t/home/rokety/Software/go/src/net/http/server.go:1739

heap 页面

展示了堆内存的详细信息,页面底部部分信息字段说明:

  • NumGC:程序运行到现在的 gc 执行次数
  • PauseNs:是拥有 256 个元素的环形缓冲,用于记录最近的 256 次 gc 执行的停顿时间(一次 gc 执行可能会有多次停顿,这里记录的是一次 gc 执行中总的停顿时间)
  • PauseEnd:也是拥有 256 个元素的环形缓冲,用于记录最近的 256 次 gc 执行时,停顿结束的时间戳(纳秒级)(一次 gc 执行可能会有多次停顿,这里记录的是最后的一次停顿的结束时间)
  • 更多字段含义可以查看:runtime.MemStats[3]

现在开始进行采样并分析,执行 go tool pprof --seconds 5 http://localhost:9090/debug/pprof/profile,会将采样结果下载下来,并且打开一个交互界面:

Fetching profile over HTTP from http://localhost:9090/debug/pprof/profile?seconds=5
Please wait... (5s)
Saved profile in /home/rokety/pprof/pprof.main.samples.cpu.002.pb.gz
File: main
Type: cpu
Time: Dec 25, 2017 at 10:26pm (CST)
Duration: 5.12s, Total samples = 8.86s (173.15%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top

Showing nodes accounting for 3790ms, 42.78% of 8860ms total
Dropped 198 nodes (cum <= 44.30ms)
Showing top 10 nodes out of 182
flat flat% sum% cum cum%
2070ms 23.36% 23.36% 2170ms 24.49% syscall.Syscall /home/rokety/Software/go/src/syscall/asm_linux_amd64.s
550ms 6.21% 29.57% 560ms 6.32% syscall.Syscall6 /home/rokety/Software/go/src/syscall/asm_linux_amd64.s
200ms 2.26% 31.83% 380ms 4.29% regexp.(*machine).tryBacktrack /home/rokety/Software/go/src/regexp/backtrack.go
200ms 2.26% 34.09% 200ms 2.26% runtime.epollctl /home/rokety/Software/go/src/runtime/sys_linux_amd64.s
150ms 1.69% 35.78% 150ms 1.69% runtime._ExternalCode /home/rokety/Software/go/src/runtime/proc.go
140ms 1.58% 37.36% 140ms 1.58% runtime.memmove /home/rokety/Software/go/src/runtime/memmove_amd64.s
130ms 1.47% 38.83% 620ms 7.00% runtime.mallocgc /home/rokety/Software/go/src/runtime/malloc.go
120ms 1.35% 40.18% 200ms 2.26% runtime.mapassign_faststr /home/rokety/Software/go/src/runtime/hashmap_fast.go
120ms 1.35% 41.53% 120ms 1.35% runtime.usleep /home/rokety/Software/go/src/runtime/sys_linux_amd64.s
110ms 1.24% 42.78% 110ms 1.24% runtime.heapBitsSetType /home/rokety/Software/go/src/runtime/mbitmap.go
(pprof) top -cum
Showing nodes accounting for 2.16s, 24.38% of 8.86s total
Dropped 198 nodes (cum <= 0.04s)
Showing top 10 nodes out of 182
flat flat% sum% cum cum%
0 0% 0% 8.02s 90.52% net/http.(*conn).serve /home/rokety/Software/go/src/net/http/server.go
0.03s 0.34% 0.34% 3.53s 39.84% net/http.(*ServeMux).ServeHTTP /home/rokety/Software/go/src/net/http/server.go
0 0% 0.34% 3.53s 39.84% net/http.serverHandler.ServeHTTP /home/rokety/Software/go/src/net/http/server.go
0 0% 0.34% 3.35s 37.81% github.com/prashantv/go_profiling_talk/handlers.WithStats.func1 /home/rokety/Learn/golang/src/github.com/prashantv/go_profiling_talk/handlers/stats_handler.go
0 0% 0.34% 3.35s 37.81% net/http.HandlerFunc.ServeHTTP /home/rokety/Software/go/src/net/http/server.go
0.01s 0.11% 0.45% 2.38s 26.86% net/http.(*response).finishRequest /home/rokety/Software/go/src/net/http/server.go
2.07s 23.36% 23.81% 2.17s 24.49% syscall.Syscall /home/rokety/Software/go/src/syscall/asm_linux_amd64.s
0.02s 0.23% 24.04% 2.07s 23.36% bufio.(*Writer).Flush /home/rokety/Software/go/src/bufio/bufio.go
0.01s 0.11% 24.15% 1.86s 20.99% github.com/prashantv/go_profiling_talk/handlers.getStatsTags /home/rokety/Learn/golang/src/github.com/prashantv/go_profiling_talk/handlers/stats_handler.go
0.02s 0.23% 24.38% 1.80s 20.32% net/http.(*conn).readRequest /home/rokety/Software/go/src/net/http/server.go

top 默认显示 10 个占用 CPU 时间最多的函数,top -cum 则显示 10 个占用 CPU 时间(包括其调用的子函数)最多的函数。

使用 web 命令可以在浏览器打开一张 SVG 图,能够更好地看到函数调用 CPU 占用情况(web依赖 Graphviz,没有的话安装下 sudo apt-get install graphviz):

一文读懂 Go profiling 和性能优化

图有点大(点击上图可打开 svg 图片),以如下方块为例:

一文读懂 Go profiling 和性能优化

  • net/http(*ServeMux)ServeHTTP server.go:调用的函数
  • 0.03s(0.34%):表示函数本身执行了 0.03s,占总采样时间 8.86s 的 0.34%
  • 3.53(39.84%):表示函数本身执行时间+调用的子函数的执行时间用了 3.53s,占总采样时间的 39.84%

还可以使用另外一个工具,来生成火焰图,~/Software/go-app/bin/go-torch --seconds 5 http://localhost:9090/debug/pprof/profile:

一文读懂 Go profiling 和性能优化

(点击上图可打开 svg 图片),火焰图怎么看?首先找与业务逻辑代码相关的且占用 CPU 时间最多的函数,可以看到github.com/prashantv/go_profiling_talk/handlers.WithStats.func1用于获取统计信息的函数占用了不少 CPU 时间,点击该函数可以看到其调用栈的更详细信息:

一文读懂 Go profiling 和性能优化

现在可以看到github.com/prashantv/go_profiling_talk/handlers.getStatsTags->os.Hostname的调用是占最多 CPU 时间的,看下对应的代码:

func getStatsTags(r *http.Request) map[string]string {
\tuserBrowser, userOS := parseUserAgent(r.UserAgent())
\tstats := map[string]string{
\t\t"browser": userBrowser,
\t\t"os": userOS,
\t\t"endpoint": filepath.Base(r.URL.Path),
\t}
\thost, err := os.Hostname()
\tif err == nil {
\t\tif idx := strings.IndexByte(host, '.'); idx > 0 {
\t\t\thost = host[:idx]
\t\t}
\t\tstats["host"] = host
\t}
\treturn stats
}

os.Hostname在每次请求都会被调用,然而 hostname 不会变化得很频繁,所以我们可以改下代码,对应的commit[4]

看下修改后的性能如何,增加了 3000+qps:

一文读懂 Go profiling 和性能优化

现在,重新生成火焰图看下,继续找与业务逻辑代码相关的且占用 CPU 时间最多的函数:

一文读懂 Go profiling 和性能优化

这次我们可以看到github.com/prashantv/go_profiling_talk/stats.addTagsToName->github.com/prashantv/go_profiling_talk/stats.clean占用许多 CPU 时间,看下对应的代码:

func addTagsToName(name string, tags map[string]string) string {
\t// The format we want is: host.endpoint.os.browser
\t// if there's no host tag, then we don't use it.
\tvar keyOrder []string
\tif _, ok := tags["host"]; ok {
\t\tkeyOrder = append(keyOrder, "host")
\t}
\tkeyOrder = append(keyOrder, "endpoint", "os", "browser")
\tparts := []string{name}

\tfor _, k := range keyOrder {
\t\tv, ok := tags[k]
\t\tif !ok || v == "" {
\t\t\tparts = append(parts, "no-"+k)
\t\t\tcontinue
\t\t}
\t\tparts = append(parts, clean(v))
\t}
\treturn strings.Join(parts, ".")
}
var specialChars = regexp.MustCompile(`[{}/\\\\:\\s.]`)
// clean takes a string that may contain special characters, and replaces these
// characters with a '-'.
func clean(value string) string {
\treturn specialChars.ReplaceAllString(value, "-")
}

在clean函数中使用了正则替换,将 {}/\\\\:\\s. 字符替换为 -。

我们还可以通过go pprof查看上面两个函数的执行情况,首先写好 Benchmark 测试用例,对应的commit[5],然后执行go test -bench . -benchmem -cpuprofile prof.cpu,该命令会产生 CPU 的分析文件prof.cpu。

一文读懂 Go profiling 和性能优化

执行go tool pprof prof.cpu,然后看下addTagsToName函数的执行情况:

一文读懂 Go profiling 和性能优化

上图红框中的 10ms 是指某一次采样时,addTagsToName正好处于调用栈顶,每采样一次的时间间隔是 10ms。(When CPU profiling is enabled, the Go program stops about 100 times per second and records a sample consisting of the program counters on the currently executing goroutine’s stack.——Profiling Go Programs[6])你可以发现列出来的时间全都是 10ms 的整数倍。现在我们可以看出占用 CPU 时间最多的就是clean函数里的正则替换。

将正则替换改为字符替换,对应的commit

[7],再次执行 benchmark:

一文读懂 Go profiling 和性能优化

如下图,从top可以看出红框中的runtime.*的调用占用最多 CPU,从list clean也可以看出make()和string()占用了许多 CPU:

一文读懂 Go profiling 和性能优化

我们还可以看下clean的汇编结果:

(pprof) disasm clean
Total: 2.94s
ROUTINE ======================== _/home/rokety/Learn/golang/src/github.com/prashantv/go_profiling_talk/stats.clean
160ms 780ms (flat, cum) 26.53% of Total
\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t……
\t\t\t\t. 340ms 4ee07b: CALL runtime.makeslice(SB) ;_/home/rokety/Learn/golang/src/github.com/prashantv/go_profiling_talk/stats.clean reporter.go:61
\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t……
\t\t\t\t. 280ms 4ee119: CALL runtime.slicebytetostring(SB) ;_/home/rokety/Learn/golang/src/github.com/prashantv/go_profiling_talk/stats.clean reporter.go:70
\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t……

(……表示省略了部分输出结果)从汇编结果中可以看出runtime.makeslice和runtime.slicebytetostring占用的 CPU 时间多,然而这里并没有我们能优化的,我们再看下addTagsToName:

一文读懂 Go profiling 和性能优化

append占用了不少 CPU 时间的,主要由于 slice 的扩容,我们可以看下addTagsToName的汇编确认下:

(pprof) disasm addTagsToName
Total: 2.94s
ROUTINE ======================== _/home/rokety/Learn/golang/src/github.com/prashantv/go_profiling_talk/stats.addTagsToName
120ms 2.78s (flat, cum) 94.56% of Total
……
. 870ms 4ede3b: CALL runtime.growslice(SB) ;_/home/rokety/Learn/golang/src/github.com/prashantv/go_profiling_talk/stats.addTagsToName reporter.go:52
……
. 200ms 4edf65: CALL runtime.growslice(SB) ;_/home/rokety/Learn/golang/src/github.com/prashantv/go_profiling_talk/stats.addTagsToName reporter.go:43
……
. 130ms 4edfb1: CALL runtime.growslice(SB) ;_/home/rokety/Learn/golang/src/github.com/prashantv/go_profiling_talk/stats.addTagsToName reporter.go:41

从addTagsToName的代码逻辑中可以分析出,我们可以通过 make 创建好需要的 slice 容量,此优化对应的commit[8],优化后 append 操作占用的 CPU 时间下降了不少:

一文读懂 Go profiling 和性能优化

一文读懂 Go profiling 和性能优化

addTagsToName返回值是将一个 slice 通过 join 后得到的 string,那么这里可以考虑使用bytes.Buffer来代替 slice,看看是否能带来一些性能提升,此优化对应的commit[9]

一文读懂 Go profiling 和性能优化

从上图看出,list addTagsToName性能提升并不明显,但 Benchmark 的结果可以发现ns/op和allocs/op,以及执行的次数都有所提升,在 Benchmark 这种大压力执行下,整体性能提升不少。

现在来看还有 2 个 allocs,执行go test -bench . -benchmem -memprofile prof.mem看下内存分析结果:

一文读懂 Go profiling 和性能优化

从上图可以看到 2 个 allocs,一个在 buffer.go 中,这个我们没法优化掉,一个在 addTagsToName 中,是给bytes.Buffer分配内存,这个是否可以优化掉呢?Golang 提供了 sync.Pool 可以用来缓存对象,尝试下是否能够得到性能提升,对应的代码修改如下:

var bufPool = sync.Pool{
\tNew: func() interface{} {
\t\treturn &bytes.Buffer{}
\t},
}
func addTagsToName(name string, tags map[string]string) string {
\t// The format we want is: host.endpoint.os.browser
\t// if there's no host tag, then we don't use it.
\tkeyOrder := make([]string, 0, 4)
\tif _, ok := tags["host"]; ok {
\t\tkeyOrder = append(keyOrder, "host")
\t}
\tkeyOrder = append(keyOrder, "endpoint", "os", "browser")
\t buf := bufPool.Get().(*bytes.Buffer)
\t defer bufPool.Put(buf)
\t buf.Reset()
\t//buf := &bytes.Buffer{}
\tbuf.WriteString(name)
\tfor _, k := range keyOrder {
\t\tbuf.WriteByte('.')
\t\tv, ok := tags[k]
\t\tif !ok || v == "" {
\t\t\tbuf.WriteString("no-")
\t\t\tbuf.WriteString(k)
\t\t\tcontinue
\t\t}
\t\twriteClean(buf, v)
\t}
\treturn buf.String()
}

执行下 Benchmark,可以发现ns/op增加了一点,这个其实主要是因为 defer 的执行也会占用不少 CPU,改为不使用 defer,对比 Benchmark 结果,ns/op下降了一些:

一文读懂 Go profiling 和性能优化

然而,上面的优化(使用 sync.Pool 和不使用 defer)所带来的性能提升并不明显,反而增加了代码的维护成本,所以最终实现的代码是这样子的commit[10]

目前优化就到此结束了,来看下优化后的性能表现如何:

一文读懂 Go profiling 和性能优化

命令总结

1、进行 5 秒钟的 CPU 性能采样并打开一个交互界面:go tool pprof -seconds 5 http://localhost:9090/debug/pprof/profile

  • topN:显示 N 个占用 CPU 时间最多的函数
  • topN -cum:显示 N 个占用 CPU 时间(包括其调用的子函数)最多的函数
  • list regex:显示与正则 regex 匹配的函数的代码
  • disasm regex:显示与正则 regex 匹配的函数的汇编代码

2、进行压力测试并产生 CPU 和内存分析文件:go test -bench . -benchmem -cpuprofile prof.cpu -memprofile prof.mem

3、打开 CPU 分析文件:go tool pprof prof.cpu

4、打开内存分析文件:go tool pprof -alloc_objects stats.test prof.mem

5、进行 5 秒钟的 CPU 性能采样并生成火焰图:go-torch --seconds 5 http://localhost:9090/debug/pprof/profile

6、从 CPU 分析文件中生成火焰图:go-torch --binaryname stats.test -b prof.cpu

7、显示内联函数,逃逸分析情况:go build -gcflags=-m .

性能分析总结

  • 不要过早的优化:Go 提供了非常便捷的方式帮助我们分析代码,无论是通过本地的压测还是在生产环境。go-torch 能帮助你找到热点的函数。
  • 减少在热点路径上的内存分配:为热点路径编写压测用例。使用-benchmem 和内存分析找到在哪里发生了内存分配,并尽量优化掉。一个内存分配意味着一个可能的 GC,GC 会增加请求的延迟。
  • 不要害怕查看汇编代码:有时内存分配和时间消耗不能清晰地从我们的代码里看出来,那就尝试看下汇编结果,通常你能从中找到导致内存分配和时间消耗的函数(例如 runtime.convI2E)。

文中链接

[1]Profiling and Optimizing Go: https://www.youtube.com/watch?v=N3PWzBeLX2M

[2]PPT: https://docs.google.com/presentation/d/1n6bse0JifemG7yve0Bb0ZAC-IWhTQjCNAclblnn2ANY/present?slide=id.g39a91bbe9_0264

[3]runtime.MemStats: https://golang.org/pkg/runtime/#MemStats

[4]commit: https://github.com/prashantv/go_profiling_talk/commit/a8c6b0e1cd043df85ccbc57883c6422863176c17

[5]commit: https://github.com/prashantv/go_profiling_talk/commit/b26d6c4672d1fd8f063ab3a4a5fd0589b742cec8

[6]Profiling Go Programs: https://blog.golang.org/profiling-go-programs

[7]commit: https://github.com/prashantv/go_profiling_talk/commit/87fa6d9ae553234169aeac719a67c4d39eca2777

[8]commit: https://github.com/prashantv/go_profiling_talk/commit/9fb6e8db2884b20cb7f5a576f2dc4e36f477af2f

[9]commit: https://github.com/prashantv/go_profiling_talk/commit/a2f0a614abd36b734cb413dc759c4fb21cd6de80

[10]commit: https://github.com/prashantv/go_profiling_talk/commit/02630f1c5bcf2fbdc034eca393ab34071221d1ef


分享到:


相關文章: