Skip to content

性能分析

當一個程序編寫完畢後,我們對它的要求不僅僅只是能運行,還希望它是一個穩定高效的應用。通過各種各樣的測試,我們可以保證程序大部分的穩定性,而程序是否高效,就需要我們對其進行性能分析,在此前的內容中,性能分析的唯一手段就只能通過 Benchmark 來測試某一個功能單元的平均執行耗時,內存分配情況等,然而現實中對程序性能分析的需求遠遠不止於此,有時候我們需要分析程序整體的 CPU 佔用,內存佔用,堆分配情況,協程狀態,熱點代碼路徑等等,這是 Benchmark 所不能滿足的。好在 go 工具鏈集成了許多性能分析工具以供開發者使用,下面就來逐一講解。

逃逸分析

在 go 中,變量的內存分配是由編譯器決定的,一般就分配到棧上和堆上這兩個地方。如果一個本該分配到棧上的變量被分配到了堆上,那麼這種情況就稱之為逃逸,逃逸分析便是要分析程序中的內存分配情況,由於它是在編譯期進行,所以是靜態分析的一種。

TIP

前往內存分配文章了解 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變量,由於它是函數內創建的,本來應該是將其分配到棧上,但是它被sonMom字段所引用了,並且son被作為了函數返回值返回出去,所以編譯器就將其分配到了堆上。這是一個很簡單的示例,所以理解起來不需要花費太多力氣,但如果是一個大點的項目,代碼行數有好幾萬,人工分析就不是那麼的輕松了,為此就需要使用工具來進行逃逸分析。前面提到過內存的分配是由編譯器主導的,所以逃逸分析也是由編譯器來完成,使用起來十分簡單,只需要執行如下命令:

bash
$ go build -gcflags="-m -m -l"

gcflags即編譯器gc的參數,

  • -m,打印出代碼優化建議,同時出現兩個會更加進行細節的輸出
  • -l,禁用內聯優化

輸出如下

bash
$ 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裡面了解到更多內容。

:::

閉包引用

閉包引用了函數外的變量,那麼該變量也會逃逸到堆上,這個很好理解。

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的容量

go
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 並不會)

go
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類型時也可能會發生逃逸

go
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 可以將其回收。

go
type Writer struct {
  buf []byte
}

func (w Writer) Close() error {
  w.buff = nil
  return nil
}

pprof

pprof(program profiling),是一個程序性能分析的利器,它會對程序運行時的數據進行部分采樣,涵蓋了 cpu,內存,協程,鎖,堆棧信息等許多方面,然後再使用工具對采樣的數據進行分析並展示結果。

所以 pprof 的使用步驟就只有兩步:

  1. 采集數據
  2. 分析結果

采集

數據采集的方式有兩種,自動和手動,各有優劣。在此之前,編寫一個簡單的函數來模擬內存和 cpu 的消耗

go
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

go
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支持的參數如下面代碼所示

go
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函數,它需要一定的時間進行采樣,且其原始數據不可讀,如下所示

go
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 的數據也是同樣如此

go
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 接口,並注冊到了默認路由中,如下所示

go
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 數據采集

go
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:當前程序執行情況的跟蹤,同樣會下載一個文件

這裡的數據大多數可讀性並不高,主要是拿來給工具分析用的,如下所圖

具體的分析工作要留到後面再進行,除了profiletrace兩個選項之外,如果你想要在網頁中下載數據文件,可以將query參數debug=1去掉。也可以將這些接口集成到自己的路由中而不是使用默認路由,如下所示

go
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 框架中,比如giniris等等。

分析

在得到了采集的數據文件後,有兩種方式進行分析,命令行或網頁,兩者都需要借助pprof命令行工具,go 默認集成該工具,所以不需要額外下載。

pprof 開源地址:google/pprof: pprof is a tool for visualization and analysis of profiling data (github.com)

命令行

將此前收集到的數據文件作為參數

bash
$ go tool pprof heap.pb

如果數據是由 web 采集的話,用 web url 替換掉文件名即可。

bash
$ go tool pprof -http :8080 http://127.0.0.1/debug/pprof/heap

然後就會出現一個交互式的命令行

bash
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/total
  • cum%,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 {

對於圖片和源代碼而言,還可以用webweblist命令在瀏覽器中查看圖片和源代碼。

網頁

在此之前為了數據更加多樣化,修改一下模擬的函數

go
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)
}

網頁分析可以可視化結果,免去了我們手動操作命令行,在使用網頁分析時,只需執行如下命令

bash
$ go tool pprof -http :8080 heap.pb

如果數據是由 web 采集的話,將 web url 替換掉文件名即可

bash
$ 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

TIP

關於如何分析數據,前往pprof: How to read the graph了解更多

網頁中總共有 6 個可查看的項

  • Top,同命令 top
  • Graph,直線圖
  • Flame Graph,火焰圖
  • Peek,
  • Source,查看源代碼
  • Disassemble,反匯編查看

對於內存而言四個維度可以分析

  • alloc_objects:目前已分配的所有對象數量,包括已釋放
  • alloc_spcae:目前為止已分配的所有內存空間,包括已釋放
  • inuse_objects:正在使用的對象數量
  • inuse_space:正在使用的內存空間

內存分析圖

上圖最下方的白色葉子節點代表著不同大小的對象佔用。

cpu分析圖

關於折線圖,有幾個點要注意

  • 塊的顏色越深,佔用越高,線越粗,佔用越高
  • 實線代表著直接調用,虛線代表著略過了一些調用鏈。

內存火焰圖

cpu火焰圖

對於火焰圖而言,從上往下看是調用鏈,從左往右看是 cum 的佔用百分比。

trace

pprof 主要負責分析程序的資源佔用,而 trace 更適合跟蹤程序的運行細節,它與前者的數據文件互不兼容,由go tool trace命令來完成相關的分析工作。

如果是手動采集的數據,可以將文件名作為參數

$ go tool trace trace.out

如果是自動采集,也是同樣的道理

bash
$ 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 的最大耗時

Golang學習網由www.golangdev.cn整理維護