Go語言核心36講(Go語言實戰與應用二十七)--學習筆記

MingsonZheng發表於2021-12-22

49 | 程式效能分析基礎(下)

在上一篇文章中,我們圍繞著“怎樣讓程式對 CPU 概要資訊進行取樣”這一問題進行了探討,今天,我們再來一起看看它的擴充問題。

知識擴充套件

問題 1:怎樣設定記憶體概要資訊的取樣頻率?

針對記憶體概要資訊的取樣會按照一定比例收集 Go 程式在執行期間的堆記憶體使用情況。設定記憶體概要資訊取樣頻率的方法很簡單,只要為runtime.MemProfileRate變數賦值即可。

這個變數的含義是,平均每分配多少個位元組,就對堆記憶體的使用情況進行一次取樣。如果把該變數的值設為0,那麼,Go 語言執行時系統就會完全停止對記憶體概要資訊的取樣。該變數的預設值是512 KB,也就是512千位元組。

注意,如果你要設定這個取樣頻率,那麼越早設定越好,並且只應該設定一次,否則就可能會對 Go 語言執行時系統的取樣工作,造成不良影響。比如,只在main函式的開始處設定一次。

在這之後,當我們想獲取記憶體概要資訊的時候,還需要呼叫runtime/pprof包中的WriteHeapProfile函式。該函式會把收集好的記憶體概要資訊,寫到我們指定的寫入器中。

注意,我們通過WriteHeapProfile函式得到的記憶體概要資訊並不是實時的,它是一個快照,是在最近一次的記憶體垃圾收集工作完成時產生的。如果你想要實時的資訊,那麼可以呼叫runtime.ReadMemStats函式。不過要特別注意,該函式會引起 Go 語言排程器的短暫停頓。

以上,就是關於記憶體概要資訊的取樣頻率設定問題的簡要回答。

package main

import (
	"048程式效能分析基礎/common"
	"048程式效能分析基礎/common/op"
	"errors"
	"fmt"
	"os"
	"runtime"
	"runtime/pprof"
)

var (
	profileName    = "memprofile.out"
	memProfileRate = 8
)

func main() {
	f, err := common.CreateFile("", profileName)
	if err != nil {
		fmt.Printf("memory profile creation error: %v\n", err)
		return
	}
	defer f.Close()
	startMemProfile()
	if err = common.Execute(op.MemProfile, 10); err != nil {
		fmt.Printf("execute error: %v\n", err)
		return
	}
	if err := stopMemProfile(f); err != nil {
		fmt.Printf("memory profile stop error: %v\n", err)
		return
	}
}

func startMemProfile() {
	runtime.MemProfileRate = memProfileRate
}

func stopMemProfile(f *os.File) error {
	if f == nil {
		return errors.New("nil file")
	}
	return pprof.WriteHeapProfile(f)
}

問題 2:怎樣獲取到阻塞概要資訊?

我們呼叫runtime包中的SetBlockProfileRate函式,即可對阻塞概要資訊的取樣頻率進行設定。該函式有一個名叫rate的引數,它是int型別的。

這個引數的含義是,只要發現一個阻塞事件的持續時間達到了多少個納秒,就可以對其進行取樣。如果這個引數的值小於或等於0,那麼就意味著 Go 語言執行時系統將會完全停止對阻塞概要資訊的取樣。

在runtime包中,還有一個名叫blockprofilerate的包級私有變數,它是uint64型別的。這個變數的含義是,只要發現一個阻塞事件的持續時間跨越了多少個 CPU 時鐘週期,就可以對其進行取樣。它的含義與我們剛剛提到的rate引數的含義非常相似,不是嗎?

實際上,這兩者的區別僅僅在於單位不同。runtime.SetBlockProfileRate函式會先對引數rate的值進行單位換算和必要的型別轉換,然後,它會把換算結果用原子操作賦給blockprofilerate變數。由於此變數的預設值是0,所以 Go 語言執行時系統在預設情況下並不會記錄任何在程式中發生的阻塞事件。

另一方面,當我們需要獲取阻塞概要資訊的時候,需要先呼叫runtime/pprof包中的Lookup函式並傳入引數值"block",從而得到一個*runtime/pprof.Profile型別的值(以下簡稱Profile值)。在這之後,我們還需要呼叫這個Profile值的WriteTo方法,以驅使它把概要資訊寫進我們指定的寫入器中。

這個WriteTo方法有兩個引數,一個引數就是我們剛剛提到的寫入器,它是io.Writer型別的。而另一個引數則是代表了概要資訊詳細程度的int型別引數debug。

debug引數主要的可選值有兩個,即:0和1。當debug的值為0時,通過WriteTo方法寫進寫入器的概要資訊僅會包含go tool pprof工具所需的記憶體地址,這些記憶體地址會以十六進位制的形式展現出來。

當該值為1時,相應的包名、函式名、原始碼檔案路徑、程式碼行號等資訊就都會作為註釋被加入進去。另外,debug為0時的概要資訊,會經由 protocol buffers 轉換為位元組流。而在debug為1的時候,WriteTo方法輸出的這些概要資訊就是我們可以讀懂的普通文字了。

除此之外,debug的值也可以是2。這時,被輸出的概要資訊也會是普通的文字,並且通常會包含更多的細節。至於這些細節都包含了哪些內容,那就要看我們呼叫runtime/pprof.Lookup函式的時候傳入的是什麼樣的引數值了。下面,我們就來一起看一下這個函式。

package main

import (
	"048程式效能分析基礎/common"
	"048程式效能分析基礎/common/op"
	"errors"
	"fmt"
	"os"
	"runtime"
	"runtime/pprof"
)

var (
	profileName      = "blockprofile.out"
	blockProfileRate = 2
	debug            = 0
)

func main() {
	f, err := common.CreateFile("", profileName)
	if err != nil {
		fmt.Printf("block profile creation error: %v\n", err)
		return
	}
	defer f.Close()
	startBlockProfile()
	if err = common.Execute(op.BlockProfile, 10); err != nil {
		fmt.Printf("execute error: %v\n", err)
		return
	}
	if err := stopBlockProfile(f); err != nil {
		fmt.Printf("block profile stop error: %v\n", err)
		return
	}
}

func startBlockProfile() {
	runtime.SetBlockProfileRate(blockProfileRate)
}

func stopBlockProfile(f *os.File) error {
	if f == nil {
		return errors.New("nil file")
	}
	return pprof.Lookup("block").WriteTo(f, debug)
}

問題 3:runtime/pprof.Lookup函式的正確呼叫方式是什麼?

runtime/pprof.Lookup函式(以下簡稱Lookup函式)的功能是,提供與給定的名稱相對應的概要資訊。這個概要資訊會由一個Profile值代表。如果該函式返回了一個nil,那麼就說明不存在與給定名稱對應的概要資訊。

runtime/pprof包已經為我們預先定義了 6 個概要名稱。它們對應的概要資訊收集方法和輸出方法也都已經準備好了。我們直接拿來使用就可以了。它們是:goroutine、heap、allocs、threadcreate、block和mutex。

當我們把"goroutine"傳入Lookup函式的時候,該函式會利用相應的方法,收集到當前正在使用的所有 goroutine 的堆疊跟蹤資訊。注意,這樣的收集會引起 Go 語言排程器的短暫停頓。

當呼叫該函式返回的Profile值的WriteTo方法時,如果引數debug的值大於或等於2,那麼該方法就會輸出所有 goroutine 的堆疊跟蹤資訊。這些資訊可能會非常多。

如果它們佔用的空間超過了64 MB(也就是64兆位元組),那麼相應的方法就會將超出的部分截掉。如果Lookup函式接到的引數值是"heap",那麼它就會收集與堆記憶體的分配和釋放有關的取樣資訊。這實際上就是我們在前面討論過的記憶體概要資訊。

在我們傳入"allocs"的時候,後續的操作會與之非常的相似。在這兩種情況下,Lookup函式返回的Profile值也會極其相像。只不過,在這兩種Profile值的WriteTo方法被呼叫時,它們輸出的概要資訊會有細微的差別,而且這僅僅體現在引數debug等於0的時候。

"heap"會使得被輸出的記憶體概要資訊預設以“在用空間”(inuse_space)的視角呈現,而"allocs"對應的預設視角則是“已分配空間”(alloc_space)。

“在用空間”是指,已經被分配但還未被釋放的記憶體空間。在這個視角下,go tool pprof工具並不會去理會與已釋放空間有關的那部分資訊。而在“已分配空間”的視角下,所有的記憶體分配資訊都會被展現出來,無論這些記憶體空間在取樣時是否已被釋放。

此外,無論是"heap"還是"allocs",在我們呼叫Profile值的WriteTo方法的時候,只要賦予debug引數的值大於0,那麼該方法輸出內容的規格就會是相同的。

引數值"threadcreate"會使Lookup函式去收集一些堆疊跟蹤資訊。這些堆疊跟蹤資訊中的每一個都會描繪出一個程式碼呼叫鏈,這些呼叫鏈上的程式碼都導致新的作業系統執行緒產生。這樣的Profile值的輸出規格也只有兩種,取決於我們傳給其WriteTo方法的引數值是否大於0。

再說"block"和"mutex"。"block"代表的是,因爭用同步原語而被阻塞的那些程式碼的堆疊跟蹤資訊。還記得嗎?這就是我們在前面講過的阻塞概要資訊。

與之相對應,"mutex"代表的是,曾經作為同步原語持有者的那些程式碼,它們的堆疊跟蹤資訊。它們的輸出規格也都只有兩種,取決於debug是否大於0。

這裡所說的同步原語,指的是存在於 Go 語言執行時系統內部的一種底層的同步工具,或者說一種同步機制。

它是直接面向記憶體地址的,並以非同步訊號量和原子操作作為實現手段。我們已經熟知的通道、互斥鎖、條件變數、”WaitGroup“,以及 Go 語言執行時系統本身,都會利用它來實現自己的功能。

image

好了,關於這個問題,我們已經談了不少了。我相信,你已經對Lookup函式的呼叫方式及其背後的含義有了比較深刻的理解了。demo99.go 檔案中包含了一些示例程式碼,可供你參考。

package main

import (
	"048程式效能分析基礎/common"
	"048程式效能分析基礎/common/op"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"runtime/pprof"
	"time"
)

// profileNames 代表概要資訊名稱的列表。
var profileNames = []string{
	"goroutine",
	"heap",
	"allocs",
	"threadcreate",
	"block",
	"mutex",
}

// profileOps 代表為了生成不同的概要資訊而準備的負載函式的字典。
var profileOps = map[string]common.OpFunc{
	"goroutine":    op.BlockProfile,
	"heap":         op.MemProfile,
	"allocs":       op.MemProfile,
	"threadcreate": op.BlockProfile,
	"block":        op.BlockProfile,
	"mutex":        op.BlockProfile,
}

// debugOpts 代表debug引數的可選值列表。
var debugOpts = []int{
	0,
	1,
	2,
}

func main() {
	prepare()
	dir, err := createDir()
	if err != nil {
		fmt.Printf("dir creation error: %v\n", err)
		return
	}
	for _, name := range profileNames {
		for _, debug := range debugOpts {
			err = genProfile(dir, name, debug)
			if err != nil {
				return
			}
			time.Sleep(time.Millisecond)
		}
	}
}

func genProfile(dir string, name string, debug int) error {
	fmt.Printf("Generate %s profile (debug: %d) ...\n", name, debug)
	fileName := fmt.Sprintf("%s_%d.out", name, debug)
	f, err := common.CreateFile(dir, fileName)
	if err != nil {
		fmt.Printf("create error: %v (%s)\n", err, fileName)
		return err
	}
	defer f.Close()
	if err = common.Execute(profileOps[name], 10); err != nil {
		fmt.Printf("execute error: %v (%s)\n", err, fileName)
		return err
	}
	profile := pprof.Lookup(name)
	err = profile.WriteTo(f, debug)
	if err != nil {
		fmt.Printf("write error: %v (%s)\n", err, fileName)
		return err
	}
	return nil
}

func createDir() (string, error) {
	currDir, err := os.Getwd()
	if err != nil {
		return "", err
	}
	path := filepath.Join(currDir, "profiles")
	err = os.Mkdir(path, 0766)
	if err != nil && !os.IsExist(err) {
		return "", err
	}
	return path, nil
}

func prepare() {
	runtime.MemProfileRate = 8
	runtime.SetBlockProfileRate(2)
}

問題 4:如何為基於 HTTP 協議的網路服務新增效能分析介面?

這個問題說起來還是很簡單的。這是因為我們在一般情況下只要在程式中匯入net/http/pprof程式碼包就可以了,就像這樣:

import _ "net/http/pprof"

然後,啟動網路服務並開始監聽,比如:

log.Println(http.ListenAndServe("localhost:8082", nil))

在執行這個程式之後,我們就可以通過在網路瀏覽器中訪問http://localhost:8082/debug/pprof這個地址看到一個簡約的網頁。如果你認真地看了上一個問題的話,那麼肯定可以快速搞明白這個網頁中各個部分的含義。

在/debug/pprof/這個 URL 路徑下還有很多可用的子路徑,這一點你通過點選網頁中的連結就可以瞭解到。像allocs、block、goroutine、heap、mutex、threadcreate這 6 個子路徑,在底層其實都是通過Lookup函式來處理的。關於這個函式,你應該已經很熟悉了。

這些子路徑都可以接受查詢引數debug。它用於控制概要資訊的格式和詳細程度。至於它的可選值,我就不再贅述了。它的預設值是0。另外,還有一個名叫gc的查詢引數。它用於控制是否在獲取概要資訊之前強制地執行一次垃圾回收。只要它的值大於0,程式就會這樣做。不過,這個引數僅在/debug/pprof/heap路徑下有效。

一旦/debug/pprof/profile路徑被訪問,程式就會去執行對 CPU 概要資訊的取樣。它接受一個名為seconds的查詢引數。該引數的含義是,取樣工作需要持續多少秒。如果這個引數未被顯式地指定,那麼取樣工作會持續30秒。注意,在這個路徑下,程式只會響應經 protocol buffers 轉換的位元組流。我們可以通過go tool pprof工具直接讀取這樣的 HTTP 響應,例如:

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=60

除此之外,還有一個值得我們關注的路徑,即:/debug/pprof/trace。在這個路徑下,程式主要會利用runtime/trace程式碼包中的 API 來處理我們的請求。

更具體地說,程式會先呼叫trace.Start函式,然後在查詢引數seconds指定的持續時間之後再呼叫trace.Stop函式。這裡的seconds的預設值是1秒。至於runtime/trace程式碼包的功用,我就留給你自己去查閱和探索吧。

前面說的這些 URL 路徑都是固定不變的。這是預設情況下的訪問規則。我們還可以對它們進行定製,就像這樣:

mux := http.NewServeMux()
pathPrefix := "/d/pprof/"
mux.HandleFunc(pathPrefix,
  func(w http.ResponseWriter, r *http.Request) {
    name := strings.TrimPrefix(r.URL.Path, pathPrefix)
    if name != "" {
      pprof.Handler(name).ServeHTTP(w, r)
      return
    }
    pprof.Index(w, r)
  })
mux.HandleFunc(pathPrefix+"cmdline", pprof.Cmdline)
mux.HandleFunc(pathPrefix+"profile", pprof.Profile)
mux.HandleFunc(pathPrefix+"symbol", pprof.Symbol)
mux.HandleFunc(pathPrefix+"trace", pprof.Trace)

server := http.Server{
  Addr:    "localhost:8083",
  Handler: mux,
}

可以看到,我們幾乎只使用了net/http/pprof程式碼包中的幾個程式實體,就完成了這樣的定製。這在我們使用第三方的網路服務開發框架時尤其有用。

我們自定義的 HTTP 請求多路複用器mux所包含的訪問規則與預設的規則很相似,只不過 URL 路徑的字首更短了一些而已。

我們定製mux的過程與net/http/pprof包中的init函式所做的事情也是類似的。這個init函式的存在,其實就是我們在前面僅僅匯入"net/http/pprof"程式碼包就能夠訪問相關路徑的原因。

在我們編寫網路服務程式的時候,使用net/http/pprof包要比直接使用runtime/pprof包方便和實用很多。通過合理運用,這個程式碼包可以為網路服務的監測提供有力的支撐。關於這個包的知識,我就先介紹到這裡。

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof"
)

func main() {
	log.Println(http.ListenAndServe("localhost:8082", nil))
}
package main

import (
	"log"
	"net/http"
	"net/http/pprof"
	"strings"
)

func main() {
	mux := http.NewServeMux()
	pathPrefix := "/d/pprof/"
	mux.HandleFunc(pathPrefix,
		func(w http.ResponseWriter, r *http.Request) {
			name := strings.TrimPrefix(r.URL.Path, pathPrefix)
			if name != "" {
				pprof.Handler(name).ServeHTTP(w, r)
				return
			}
			pprof.Index(w, r)
		})
	mux.HandleFunc(pathPrefix+"cmdline", pprof.Cmdline)
	mux.HandleFunc(pathPrefix+"profile", pprof.Profile)
	mux.HandleFunc(pathPrefix+"symbol", pprof.Symbol)
	mux.HandleFunc(pathPrefix+"trace", pprof.Trace)

	server := http.Server{
		Addr:    "localhost:8083",
		Handler: mux,
	}

	if err := server.ListenAndServe(); err != nil {
		if err == http.ErrServerClosed {
			log.Println("HTTP server closed.")
		} else {
			log.Printf("HTTP server error: %v\n", err)
		}
	}
}

總結

這兩篇文章中,我們主要講了 Go 程式的效能分析,提到的很多內容都是你必備的知識和技巧。這些有助於你真正地理解以取樣、收集、輸出為代表的一系列操作步驟。

我提到的幾種概要資訊有關的問題。你需要記住的是,每一種概要資訊都代表了什麼,它們分別都包含了什麼樣的內容。

你還需要知道獲取它們的正確方式,包括怎樣啟動和停止取樣、怎樣設定取樣頻率,以及怎樣控制輸出內容的格式和詳細程度。

此外,runtime/pprof包中的Lookup函式的正確呼叫方式也很重要。對於除了 CPU 概要資訊之外的其他概要資訊,我們都可以通過呼叫這個函式獲取到。

除此之外,我還提及了一個上層的應用,即:為基於 HTTP 協議的網路服務,新增效能分析介面。這也是很實用的一個部分。

雖然net/http/pprof包提供的程式實體並不多,但是它卻能夠讓我們用不同的方式,實現效能分析介面的嵌入。這些方式有的是極簡的、開箱即用的,而有的則用於滿足各種定製需求。

以上這些,就是我今天為你講述的 Go 語言知識,它們是程式效能分析的基礎。如果你把 Go 語言程式運用於生產環境,那麼肯定會涉及它們。對於這裡提到的所有內容和問題,我都希望你能夠認真地去思考和領會。這樣才能夠讓你在真正使用它們的時候信手拈來。

思考題

我今天留給你的思考題其實在前面已經透露了,那就是:runtime/trace程式碼包的功用是什麼?

筆記原始碼

https://github.com/MingsonZheng/go-core-demo

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章