Golang性能调优之pprof

535 阅读13分钟

摘要:

当我们的应用程序出现cpu使用率过高,内存泄漏等一系列性能问题时,就要采集程序运行时的数据,基于采集到的数据借助工具进行性能分析,优化程序性能。对于Go程序而言,我们就可以借助golang 官方提供的pprof工具对程序进行性能分析。本篇文章就详细的介绍下,如何使用pprof这个Go性能分析的大杀器,文章有点长但干货满满!

pprof简介

pprof通常(还有其他的)用于分析以下几类性能数据:

类别用途
CPU Profiling按照一定的频率分析程序运行时CPU的使用情况,帮助我们优化程序耗时
Memory Profiling监视应用程序当前和历史堆内存的使用情况,以检查内存泄漏。注意仅监视堆内存,对于栈空间函数调用结束后会自动回收不用消耗性能进行GC,所以我们也不用进行分析
Mutex Profiling监视互斥锁的争用情况,供我们优化Goroutine之间互斥等待的时间,减少data race
Goroutine Profiling分析当前应用程序的Goroutine情况,可以对当前正在运行的 Goroutine 进行堆栈跟踪和分析。这对我们分析Goroutine的阻塞,回收状态,排查Goroutine是否出现泄漏非常有用。Goroutine Profiling和Memory Profiling也密切相关,因为Goroutine一旦泄漏往往也会造成内存泄漏

另外pprof包有两个,分别是 runtime/pprof 和 net/http/pprof,其中runtime/pprof用于分析非web型server,net/http/pprof用于分析web server,每个包都可以用于以上几类性能分析,并且每种分析方式大的流程步骤都是一样的:首先采集程序的运行数据,然后分析运行数据。分析运行数据时pprof工具也提供了几种分析方式,一种是基于交互式终端,通过命令行的方式进行分析,第二种就是基于web 可视化界面进行分析,这种方式更方便,更直观,线上分析中更推荐用这种。下面都会一一介绍,先来看一下runtime/pprof包的使用

runtime/pprof

1 CPU Profiling

1.1 程序demo

package main

import (
   "flag"
   "fmt"
   "log"
   "os"
   "os/signal"
   "runtime"
   "runtime/pprof"
   "sync"
   "syscall"
   "time"
)

var cpuProfiling = flag.String("cpuprofile", "", "write cpu profile to `file`")

func main()  {
   flag.Parse()
   if *cpuProfiling != "" {
      f, err := os.Create(*cpuProfiling)
      if err != nil {
         log.Fatal("could not create CPU profile: ", err)
      }
      defer f.Close()

      if err := pprof.StartCPUProfile(f); err != nil {
         log.Fatal("could not start CPU profile: ", err)
      }
      defer pprof.StopCPUProfile()
   }
   clo := make(chan struct{})
   go func() {
      sig := make(chan os.Signal, 1)
      signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
      for range sig {
         clo <- struct{}{}
      }
   }()
   for {
      select {
      case <-clo :
         return
      default:
         time.Sleep(time.Millisecond*time.Duration(10))
         currencyPattern()
      }
   }
}

//扇入模型
func currencyPattern() {
   fanin := func(chs ...<-chan interface{}) <-chan interface{} {
      ch := make(chan interface{})
      var g sync.WaitGroup
      for _, c := range chs {
         g.Add(1)
         go func(c <-chan interface{}) {
            defer g.Done()
            for value := range c {
               ch <- value
            }
         }(c)
      }
      go func() {
         g.Wait()
         close(ch)
      }()
      return ch
   }
   create := func() []<-chan interface{} {
      ret := make([]<-chan interface{}, 0)
      for i:=0; i<100; i++ {
         func(num int) {
            ch := make(chan interface{})
            go func() {
               defer close(ch)
               time.Sleep(time.Millisecond*time.Duration(num))
               ch <- fmt.Sprintf("f%d", num)
            }()
            ret = append(ret, ch)
         }(i)
      }
      return ret
   }

   total := fanin(create()...)
   for v := range total {
      fmt.Println(v)
   }
}

1.2 采集数据

编译并运行以上代码

go build -o pprof
./pprof --cpuprofile=cpu.pprof

在当前目录下,会生成cpu.pprof文件,这个文件是ProtoBuf的数据格式,下面先对cpu.pprof文件进行CPU Profiling

1.3 分析数据

1.3.1 命令行交互式终端分析

go get github.com/google/pprof(安装pprof工具)
go tool pprof democpu.pprof

1653799429(1).png

pprof涉及很多交互式命令,输入help可查看每个命令的详解,输入top默认输出耗时排前10的函数。可以看到输入top命令后总共输出有六列,其中最后一列是函数名称,flat 表示调用一次当前函数消耗的时间,flat% 则表示调用一次当前函数占总样本时间(410ms)的比例,sum% 则表示在总的410ms的样本时间中当前函数占比多少,以currencyPattern为例可以看出该函数总共被调用了31次(75.61/2.44向上取整),cum表示调用完当前函数总共累计消耗的时间,cum% 则表示累计占比。还是以currencyPattern为例,可以看出currencyPattern之后调用的函数总共消耗了60.98%的cpu时间 image.png

输入list加具体的函数名,可以看到函数内每行代码消耗的时间,以currencyPattern为例:在currencyPattern函数内fanin是func1,create是func2。其中fmt.Println(v)这一行io输出消耗了100ms,调用fanin和create总共消耗了40ms,而调用fanin则消耗了10ms,调用create消耗了30ms。其他的以此类推就不一一详述了。可以看到就能快速的分析出我们程序的耗时,进而对不合理的耗时进行优化。提高程序性能 image.png

1.3.2 图形可视化分析

这种分析方式,通过图形的方式把采集到的数据直观的展示出来,相比于命令行交互式分析,免去了我们熟悉每个命令的怎么用的麻烦。图形可视化分析的方法又分为两种,一种是通过安装 graphviz 软件,然后在交互式终端输入web命令生成svg图,然后用浏览器打开svg文件。第二种就是通过pprof启动一个指定端口的程序然后我们动态的访问这个页面直接浏览。这里我就仅展示第二种方式

运行命令:go tool pprof -http=:8887 cpu.pprof (端口任意指定,只要是空闲端口即可)

然后浏览器访问 http://localhost:8887/ui/ ,即可在浏览器上分析性能数据。VIEW菜单下,可以切换数据的展示模式,如图表Graph,火焰图Flame Graph等。下图就是以Graph的方式展示的cpu profiling数据(文件太大,只截取了一小部分图)。图中从上往下展示的就是完整的调用链路 image.png

关于图形Graph:

  1. 每个框代表一个函数,框越大表示占用的 cpu 资源越多
  2. 每个框之间的线条代表函数之间的调用关系,线条上的数字表示下层函数的耗时
  3. 每个框中第一个数字表示当前函数占用 cpu 的百分比,第二行数字表示当前函数累计占用 cpu 的百分比

关于火焰图Flame Graph: 火焰图是性能分析专家Bredan Gregg 创建的一种性能分析图,之前go程序要生成火焰图比较麻烦,要安装go-torch,FlameGraph等工具,还要搭建perl环境。现在pprof已经把火焰图整合进来了,我们在上面的UI界面中直接切换到Flame Graph即可。 image.png 火焰图展示的也是从上到下的完整调用链,每行的每个方块代表一个函数,它的下一层方块表示当前函数会调用哪些函数,每个方块的大小代表了CPU的耗时长短。鼠标hover到对应的方块上可以看到cum以及cum%的数据。点击对应的方块可以以当前方块为父节点查看其下更详细的调用链。如点击main.currencyPattern,可以看到内部主要的耗时消耗在哪,每部分的占比等 image.png

1.4 All in all

总而言之不管是采用命令行交互式的分析方式,还是图形化分析的Graph和Flame Graph方式,都可以达到分析性能指标的目的。哪种方式更直观,哪种方式更简便我们就选哪种

2 Memory Profiling

在开始正式动手内存分析之前,先来看一下Dave《High Performance Go Workshop》 中关于Memory Profiling能做什么不能做什么的描述 3547313569-5cdfd2e9da578.webp

简而言之Memory Profiling记录的是堆内存的分配情况,不记录栈内存,并且Memory Profiling是基于抽样的,默认是每1000次堆内存分配,抽样一次。并且Memory profiling跟踪的是已分配的但未使用的内存,而不是使用中的内存,所以Memory profiling并不能衡量程序整体的内存使用情况。Dave个人也认为,使用Memory Profiling不能够发现内存泄漏的情况。其实单个函数或单个程序的耗时分析是最简单的。而Mem Profiling或Goroutine Profiling则是相对比较困难的,因为一旦我们需要对程序进行Mem Profiling或Goroutine Profiling,往往就意味着我们的程序在线上出现了内存泄漏,这时就需要结合Mem Profiling和Goroutine Profiling去分析问题,看看到底是我们的程序内存分配不合理还是Gouroutine泄漏,或者是引入的第三方包有问题等等,导致的内存泄漏。

2.1 程序demo

我们把上面CPU Profiling的程序稍微改造一下,如下

package main

import (
   "flag"
   "log"
   "os"
   "os/signal"
   "runtime"
   "runtime/pprof"
   "strconv"
   "sync"
   "syscall"
)

var memProfiling = flag.String("memprofile", "", "write mem profile to `file`")
var buf []byte

func main()  {
   flag.Parse()
   clo := make(chan struct{})
   var g sync.WaitGroup
   g.Add(2)
   go func() {
      defer g.Done()
      sig := make(chan os.Signal, 1)
      signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
      for range sig {
         clo <- struct{}{}
      }
   }()
   go func() {
      defer g.Done()
      for {
         select {
         case <-clo :
            return
         default:
            currencyPattern()
         }
      }
   }()
   if *memProfiling != "" {
      f, err := os.Create(*memProfiling)
      if err != nil {
         log.Fatal("could not create memory profile: ", err)
      }
      defer f.Close()
      runtime.GC()

      if err := pprof.WriteHeapProfile(f); err != nil {
         log.Fatal("cound not write memory profile: ", err)
      }
   }
   g.Wait()
}

//扇入模型
func currencyPattern() {
   fanin := func(chs ...<-chan interface{}) <-chan interface{} {
      ch := make(chan interface{})
      var g sync.WaitGroup
      for _, c := range chs {
         g.Add(1)
         go func(c <-chan interface{}) {
            defer g.Done()
            for value := range c {
               ch <-value
            }
            sli := []string{"1","2","3","4","5","6","7","8","9","10","11","12","13"}
            for _, s := range sli {
               strconv.Atoi(s)
            }
         }(c)
      }
      go func() {
         g.Wait()
         close(ch)
      }()
      return ch
   }
   create := func() []<-chan interface{} {
      ret := make([]<-chan interface{}, 0)
      for i:=0; i<100; i++ {
         func(num int) {
            ch := make(chan interface{})
            go func() {
               defer close(ch)
               ch <- num
            }()
            ret = append(ret, ch)
         }(i)
      }
      return ret
   }

   total := fanin(create()...)
   for v := range total {
      size, ok := v.(int)
      if ok {
         buf = append(buf, make([]byte, 1024*size)...)
      }
   }
}

2.2 采集数据

同样的我们编译并运行以上代码

go build -o pprof
./pprof --memprofile=mem.pprof

在当前目录下会生成mem.pprof文件,下面先对mem.pprof文件进行Memory Profiling

2.3 分析数据

2.3.1 命令行交互式终端分析

go tool pprof mem.pprof

输入top指令可以看到main.currencyPattern函数消耗的堆内存最多(flat sum cum等含义和上面的CPU Profiling的含义类似,以此类推即可),再输入list main.currencyPattern可查看函数内具体哪行代码消耗了大量内存,traces指令查看函数调用栈,可以发现每次调用之后堆内存都是呈递增的趋势。所以我们明显可以优化一下 buf = append(buf, make([]byte, 1024*size)...) 这行代码 image.png

2.3.2 图形可视化分析

运行命令:go tool pprof -http=:8889 mem.pprof (端口任意指定,只要是空闲端口即可)

然后访问 http://localhost:8889/ui/ 就可以查看Graph以及Flame Graph等更清晰的图表,然后基于这些图表进行分析。 image.png

2.4 All in all

写过C/C++的小伙伴相信对内存分配都有着一定的敬畏之心,虽然golang有自身的GC机制,不像C/C++一样需要我们手动释放内存。但内存分配不合理,导致程序反复大量的触发GC,也会影响程序本身的性能。在日常开发中熟知哪些情况下会导致内存逃逸(因为内存一旦逃逸就需要GC进行回收),我们甚至可以不用进行Memory Profiling,也可以知道哪些内存操作是不合理的了

net/http/pprof

下面再分享一下如何基于net/http/pprof包对web程序进行性能调优。当我们使用标准库中的net/http包构建http server时,我们只用简单引入net/http/pprof即可采集server的性能数据进行分析,如下:

import _ "net/http/pprof"

但在实际业务中,我们构建的http服务基本都是比较复杂,并且要具备较高的性能。所以实际业务中我们往往会使用优秀的web框架,如gin,fasthttp,echo,iris等等。那么使用这些web框架时,我们又该怎样引用net/http/pprof包,来采集线上服务的性能数据呢。我们都知道 以import _ "net/http/pprof" 的方式引包,其实就是执行了包下的init函数,而net/http/pprof的init函数如下: image.png

可以看到,它其实就是拿标准库中的DefaultServeMux,注册了一些HandleFunc,那对于web框架我手动注册一下即可,下面以gin 框架构建的web server为例,进行Goroutine Profiling。CPU Profiling和Memory Profiling跟runtime/pprof包的类似,就不再分享了。

1 Goroutine Profiling

1.1 程序demo

这个程序每次请求后,都会有大量Goroutine未正常退出,最终会导致内存泄漏

package main

import (
   "fmt"
   "github.com/gin-gonic/gin"
   "log"
   "net/http"
   "net/http/pprof"
   "strconv"
)

var (
   AppRouter *gin.Engine
)

func InitRouter() {
   AppRouter = gin.Default()
   AppRouter.GET("/", func(c *gin.Context) {
      c.String(http.StatusOK, "ok")
   })
}

func pprofHandler(h http.HandlerFunc) gin.HandlerFunc {
   handler := http.HandlerFunc(h)
   return func(c *gin.Context) {
      handler.ServeHTTP(c.Writer, c.Request)
   }
}

func main()  {
   InitRouter()
   pprofgroup := AppRouter.Group("/debug/pprof")
   pprofgroup.GET("/", pprofHandler(pprof.Index))
   pprofgroup.GET("/cmdline", pprofHandler(pprof.Cmdline))
   pprofgroup.GET("/profile", pprofHandler(pprof.Profile))
   pprofgroup.GET("/symbol", pprofHandler(pprof.Symbol))
   pprofgroup.GET("/trace", pprofHandler(pprof.Trace))
   pprofgroup.GET("/goroutine", pprofHandler(pprof.Handler("goroutine").ServeHTTP))
   
   AppRouter.GET("/pprof/goroutine/profiling", dealFunc)

   if err := AppRouter.Run(":8888"); err!= nil {
      panic(err)
   }
}

func dealFunc(ctx *gin.Context){
   log.Println("dealFunc request begin")
   defer log.Println("dealFunc request end")
   count := ctx.Query("count")
   tmp,_ := strconv.Atoi(count)
   for i:=0; i<tmp; i++{
      go func() {
         sli := make([]int, 0)
         for j:=0; j<1000; j++ {
            sli = append(sli, j)
         }
         select {}
      }()
      fmt.Println(i)
   }
   ctx.JSON(http.StatusOK,"ok ok")
}

1.2 采集数据

同理我们先编译并运行以上代码:

go build -o pprof(编译)
./pprof(运行)

相比于runtime/pprof包的使用稍有不同的是,我们需要用pprof工具持续监听我们程序的profile接口,如下监测60秒,然后开启一个新窗口,并用hey工具持续的进行压测(以免采集到空数据):

go tool pprof --seconds 60 http://localhost:8888/debug/pprof/goroutine (监测)
go get -u github.com/rakyll/hey  (安装hey)
hey -n 1000 http://localhost:8888/pprof/goroutine/profiling?count=100  (压测)

image.png image.png image.png

1.3 分析数据

1.3.1 命令行交互式终端

输入top指令可以看到runtime.gopark挂起的goroutine有300000个(因为我实际压测了三回),明显出现了gouroutine泄漏,而导致runtime.gopark的是上一层main.dealFunc.func1函数,可以再执行list 看一下这个函数内部具体哪行代码导致,同理traces命令也可以看出来 image.png

1.3.2 图形可视化分析

执行命令:

go tool pprof -http="localhost:8889" pprof.pprof.goroutine.002.pb.gz

再访问 http://localhost:8889/ui/ ,即可查看Graph或Flame Graph,如下图: image.png

1.4 All in all

通过这个例子,可以看到net/http/pprof相比于runtime/pprof,只是在数据采集那一步稍有不同,数据分析的方式其实是一样的。还有就是net/http/pprof对于不同的web框架稍有不同,同理的对不同的rpc框架runtime/pporf包的引用也会稍有不同。所以大家务必灵活应用

总结

关于golang性能分析大杀器pprof的详细运用,就分享到此了。因为pprof较为强大,而且又分runtime/pprof和net/http/pprof两个包,两个包的使用又稍有不同,并且分别运用于不同的server框架时又有些许不同,所以没办法完全列举,但是相信如果你详细看完了本篇文章,并且理解透彻了每个点,对于pprof你就已经掌握的差不多了。关于Go程序性能分析,pprof属于一个相对宏观的工具,通过pprof能够知道哪个函数耗费了多少CPU,分配了多少内存等。但如果想要知道程序中更加细节的指标,比如每个goroutine执行了多长时间,gc的时间线,程序cpu使用率的时间线等等,pprof都是无能为力的。这些指标的分析,就要依赖Go后花园的另一个分析工具trace了,关于trace就请期待下一篇分享吧。