Go微服务实战
上QQ阅读APP看书,第一时间看更新

6.2.2 性能分析

工程师在进行代码优化前,首先要做的就是对代码充分分析。代码分析是一个动态的过程,需要结合各种数值来了解程序运行的细节。为了更方便地分析代码,Go语言提供了runtime/pprof标准库。

prof是profile的缩写,其含义就是性能分析报告,所以该标准包的主要作用就是生成性能分析报告。

工程师可以通过命令go tool pprof --help来了解相关用法。代码分析都是直接或间接地使用runtime/pprof包。

注意

runtime/pprof是比较底层的包,常用于代码分析。不过在后面要讲的Web编程中,一般使用net/http/pprof标准包,其底层也是runtime/pprof,第9章会进行介绍。

要使用pprof包分析性能,首先要对产生性能问题的原因有一定的认识,这样在采集数据的时候也可以有的放矢,一般有以下几个常见原因:

▪CPU占有率高,高负荷运转,要找到是执行哪个函数时出现这种情况的。

▪goroutine在死锁状态,没有运行,但是占用了资源。

▪垃圾回收占用时间。

针对这些情况,可以收集CPU的执行数据,pprof会不停采样,然后找出高度占用CPU的函数。在生成.prof文件后,可以通过go tool pprof文件进行分析。

在runtime/pprof包内提供了如下主要的接口:


//堆栈分析,可以分析内存的使用情况
func WriteHeapProfile(w io.Writer) error
//CPU分析
func StartCPUProfile(w io.Writer) error
func StopCPUProfile()

在使用的时候将文件指针传入,然后数据会写入文件中,运行结束后再使用上面提到的go tool pprof命令进行分析即可。

下面来看一个示例,这样可以比较直观地感受pprof的使用方法:


book/ch06/6.2/profile.go
1. package main
2.
3. import (
4.     "log"
5.     "os"
6.     "runtime"
7.     "runtime/pprof"
8.     "time"
9. )
10.
11. var ch4cpu chan uint64
12. var chTimer chan struct{}
13. var memMap map[int]interface{}
14.
15. func init(){
16.     ch4cpu = make(chan uint64, 10000)
17.     chTimer = make(chan struct{}, 20)
18.     memMap = make (map[int]interface{})
19. }
20. func main() {
21.     c, err := os.Create("/Users/liujinliang/projects/go/src/ljl/book/
            ch06/6.2/cpu_profile.prof")
22.     if err != nil {
23.         log.Fatal(err)
24.     }
25.
26.     defer c.Close()
27.
28.     m1, err := os.Create("/Users/liujinliang/projects/go/src/ljl/book/
            ch06/6.2/mem_profile1.prof")
29.     if err != nil {
30.         log.Fatal(err)
31.     }
32.
33.     m2, err := os.Create("/Users/liujinliang/projects/go/src/ljl/book/
            ch06/6.2/mem_profile2.prof")
34.     if err != nil {
35.         log.Fatal(err)
36.     }
37.
38.     m3, err := os.Create("/Users/liujinliang/projects/go/src/ljl/book/
            ch06/6.2/mem_profile3.prof")
39.     if err != nil {
40.         log.Fatal(err)
41.     }
42.
43.     m4, err := os.Create("/Users/liujinliang/projects/go/src/ljl/book/
            ch06/6.2/mem_profile4.prof")
44.     if err != nil {
45.         log.Fatal(err)
46.     }
47.
48.     defer m1.Close()
49.     defer m2.Close()
50.     defer m3.Close()
51.     defer m4.Close()
52.
53.     pprof.StartCPUProfile(c)
54.     defer pprof.StopCPUProfile()
55.
56.     memMap[1]= runMEMTest()
57.
58.     runtime.GC()
59.     pprof.Lookup("heap").WriteTo(m1, 0)
60.     //从此处开始ch4cpu通道被不断地写入数据
61.     go runcputest()
62.     //goroutine运行15秒后chTimer写入值
63.     go func(){
64.         time.Sleep(15 * time.Second)
65.         log.Println("write timer")
66.         chTimer <- struct{}{}
67.
68.     }()
69.     memMap[2]= runMEMTest()
70.     runtime.GC()
71.     pprof.Lookup("heap").WriteTo(m2, 0)
72.
73.     memMap[2] = nil
74.     runtime.GC()
75.     pprof.Lookup("heap").WriteTo(m3, 0)
76.
77.     memMap[1] = nil
78.     runtime.GC()
79.     pprof.Lookup("heap").WriteTo(m4, 0)
80.
81.     procmsg()
82. }
83.
84. func runMEMTest()([]int) {
85.     mem :=  make([]int, 100000, 120000)
86.     return mem
87. }
88.
89. func runcputest(){
90.     var i uint64
91.     for {
92.         ch4cpu <- i
93.         i++
94.     }
95. }
96.
97. func procmsg(){
98.     for {
99.         select {
100.            case _ = <-ch4cpu:
101.            case _ = <-chTimer://直到满足此条件for循环才结束
102.                log.Println("timeout")
103.                return
104.         }
105.     }
106. }

下面先讲解相应的代码。

第11行至第19行,定义了两个channel,分别是ch4cpu和chTimer。这两个通道,一个是在测试CPU性能的时候读写数据,一个是用作定时器。chTimer是无缓存通道,此处的用法就是将这个通道作为信号。在第63行至第68行的goroutine内,先休眠15秒,再往chTimer写入数据。而在主goroutine的第81行运行procmsg函数,该函数会不停地循环,直到第101行条件满足的时候,在一定概率下才会执行第二个case结束程序。一定要注意,两个case都满足条件时尽可能随机选择一个case执行,也就是在chTimer满足条件以后会再执行一定次数才结束程序。另外,此处还定义了一个map,用来记录内存分配。

第21行至第51行生成了几个文件,这些文件用来存放分析记录的数据,可通过defer关键字来关闭这些文件,也就是在main函数结束时关闭文件。

第53行和第54行是调用runtime/pprof包的CPU性能测试的两个函数,把结果写入c文件。

第56行至第59行,调用runMEMTest函数分配一个len为100000、cap为120000的int型切片,并且把这个切片返回给memMap[1]。每执行一次,mem这个变量就会通过垃圾回收机制回收一次,然后把堆的分配情况写入文件m1。

第61行,开始运行runcputest函数,不停地往ch4cpu通道写入数据。

第63行至第68行,goroutine运行一个匿名函数,作用是15秒后往chTimer通道写入数据。

第69行至第71行,类似第56行至第59行,这次会进一步增大内存压力,再分配一块内存,再次以值传递的方式传递给memMap[2],然后把数据写入m2文件。

第73行至第79行是把分配的内存释放,并且分别记录数据到m3和m4文件。

第81行是main函数的最后一个函数调用,对应的procmsg函数的具体代码在第97行至第106行。函数的主体是一个for循环,不停地执行select,可以看到第一个case是一直满足的,而第二个case要等待chTimer可读,一旦执行到第二个case,则整个程序结束。

梳理一下,整段代码其实就是为了分析CPU和内存的运行状况而设计的,对CPU的测试主要是用runcputest函数不停地向通道ch4cpu写入uint64类型的数据,并由procmsg()函数不停地读数据,而procmsg函数结束的条件是等到chTimer可读,chTimer是启动了一个goroutine并在开始运行15秒后往chTimer写入数据的过程,也就是说,大概在chTimer执行以后,procmsg函数就有机会结束了。因为有大量的通道读写,所以这是测试CPU的主要代码。

对于内存的测试,则是通过大量分配内存堆实现的,并且每次都有一个变量是可回收的,如此反复,每次都执行垃圾回收,并记录堆的信息,这是在模拟一种内存使用情况,可以通过分析记录查看内存泄露等情况。

在执行了上述代码以后,应该已经生成了五个文件:cpu_profile.prof、mem_profile1.prof、mem_profile2.prof、mem_profile3.prof、mem_profile4.prof。这些文件都是二进制的,不能直接打开阅读。要使用这些文件就需要借助Go工具。示例如下:


1. $ go tool pprof  /Users/liujinliang/projects/go/src/ljl/book/ch06/6.2/cpu_profile.prof
2. Type: cpu
3. Time: Aug 27, 2019 at 9:01am (CST)
4. Duration: 15.12s, Total samples = 25.55s (169.03%)
5. Entering interactive mode (type "help" for commands, "o" for options)

执行该命令后可以看到一些概要信息,注意第4行,这里因为是多线程并行,所以把每个线程执行时间加起来的和是超过整个程序运行时间的。

我们还可以进一步查看更详细的信息,比如查看占用CPU最高的十个函数:


1. (pprof) top
2. Showing nodes accounting for 25.26s, 98.86% of 25.55s total
3. Dropped 10 nodes (cum <= 0.13s)
4. Showing top 10 nodes out of 34
5.       flat  flat%   sum%        cum   cum%
6.      12.19s 47.71% 47.71%     12.19s 47.71%  runtime.pthread_cond_signal
7.       7.41s 29.00% 76.71%      7.41s 29.00%  runtime.pthread_cond_wait
8.       4.72s 18.47% 95.19%      4.73s 18.51%  runtime.usleep
9.       0.34s  1.33% 96.52%      0.34s  1.33%  runtime.pthread_mutex_lock
10.      0.22s  0.86% 97.38%      0.22s  0.86%  runtime.(*waitq).dequeue
11.      0.13s  0.51% 97.89%      2.76s 10.80%  runtime.lock
12.      0.09s  0.35% 98.24%      3.09s 12.09%  runtime.selectgo
13.      0.09s  0.35% 98.59%      0.35s  1.37%  runtime.unlock
14.      0.05s   0.2% 98.79%      3.14s 12.29%  main.procmsg
15.      0.02s 0.078% 98.86%      0.44s  1.72%  runtime.chansend

执行top命令需要在pprof命令包内进行,可以看第一行top命令前括号内的信息。

可以看到,第6行和第7行是CPU使用率最高的方法,而且这两个方法是标准函数。pthread_cond_signal用于唤醒线程,pthread_cond_wait用于使线程进入阻塞状态。CPU使用率高的原因可能是程序中几次主动调用垃圾回收,而在运行的时候会让线程阻塞和唤醒。另外就是不停地向通道ch4cpu写入数据,写数据是通过goroutine实现的,而读数据是在主goroutine中,也会造成缓存区域满的情况。

本节就不再继续优化代码了,仅仅找出可能原因即可,读者可以自行优化测试。如果对于某个具体的函数有疑问,想获取更为详细的信息,可以使用list方法,比如可以看一下自己写的main.procmsg函数:


(pprof) list main.procmsg

对内存使用的分析与CPU类似,即先使用quit命令离开当前的pprof,然后再进入。内存使用分析也是通过list看具体函数执行时的内存使用情况的。


1. $ go tool pprof  /Users/liujinliang/projects/go/src/ljl/book/ch06/6.2/
        mem_profile2.prof
2. Type: inuse_space
3. Time: Aug 27, 2019 at 9:01am (CST)
4. Entering interactive mode (type "help" for commands, "o" for options)
5. (pprof) list main.main
6. Total: 1.10MB
7. ROUTINE ======================== main.main in /Users/liujinliang/projects/
        go/src/ljl/book/ch06/6.2/profile.go
8.          0     1.10MB (flat, cum)   100% of Total
9.           .          .     67:
10.          .          .     68:   }()
11.          .          .     69:   memMap[2]= runMEMTest()
12.          .          .     70:   runtime.GC()
13.          .          .     71:   pprof.Lookup("heap").WriteTo(m2, 0)
14.          .     1.10MB     72:
15.          .          .     73:   memMap[2] = nil
16.          .          .     74:   runtime.GC()
17.          .          .     75:   pprof.Lookup("heap").WriteTo(m3, 0)
18.          .          .     76:

如果要更加直观地查看内存使用情况,可以生成图片,命令如下:


$ go tool pprof -svg  ./mem_profile2.prof > mem.svg

生成的图如图6-1所示。

图6-1 性能分析结果图

注意

上面生成图片的方式依赖graphviz,使用该方式前请先安装该工具。

前面已经介绍了代码优化,而且解释了代码优化的关键是性能分析。除了使用runtime/pprof进行性能分析以外,其实在GitHub上可以找到更好用的第三方工具,不过都是基于该包的封装,希望读者能够掌握该包的使用,在实际项目中不断总结,改进方式。

注意

Donald E. Knuth说过一句非常著名的话“过早地优化是万恶之源”,所以一定要在项目稳定运行没有明显bug后再优化。