Go Wiki: 除錯 Go 程式中的效能問題
– 最初由 Dmitry Vyukov 撰寫
假設你有一個 Go 程式,並想提高它的效能。有幾種工具可以幫助完成這項任務。這些工具可以幫助你識別各種型別的熱點(CPU、IO、記憶體),熱點是你需要重點關注以顯著提高效能的地方。然而,也可能出現另一種結果——這些工具可以幫助你識別程式中明顯的效能缺陷。例如,你在每次查詢前準備一個 SQL 語句,而實際上你可以在程式啟動時只准備一次。另一個例子是,如果一個 O(N^2) 的演算法不知何故被用在了本應使用 O(N) 演算法的地方。為了識別這種情況,你需要對配置檔案中看到的內容進行健全性檢查。例如,對於第一種情況,在 SQL 語句準備上花費大量時間將是一個危險訊號。
瞭解效能的各種限制因素也很重要。例如,如果程式透過一個 100 Mbps 的網路鏈路通訊,並且它已經佔用了 >90Mbps,那麼你對程式本身能做的來提高其效能的空間就很小了。對於磁碟 IO、記憶體消耗和計算任務也有類似的限制因素。考慮到這一點,我們可以來看看可用的工具。
注意:這些工具可能會相互干擾。例如,精確的記憶體剖析會扭曲 CPU 剖析,goroutine 阻塞剖析會影響排程器跟蹤,等等。請單獨使用這些工具以獲得更精確的資訊。
CPU 剖析器
Go 執行時包含內建的 CPU 剖析器,它可以顯示哪些函式佔用了多少百分比的 CPU 時間。有 3 種方法可以訪問它:
-
最簡單的方法是使用 `go test` 命令的 `-cpuprofile` 標誌([https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags](https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags))。例如,以下命令
$ go test -run=none -bench=ClientServerParallel4 -cpuprofile=cprof net/http將剖析給定的基準測試,並將 CPU 剖析結果寫入 `cprof` 檔案。然後
$ go tool pprof --text http.test cprof將打印出最耗時的函式列表。
有幾種可用的輸出型別,最有用的是:`--text`、`--web` 和 `--list`。執行 `go tool pprof` 以獲取完整列表。此選項的明顯缺點是它僅適用於測試。
-
[`net/http/pprof`](https://pkg.go.dev/net/http/pprof): 這是網路伺服器的理想解決方案。你只需匯入 `net/http/pprof`,然後透過以下方式收集剖析資料:
$ go tool pprof --text mybin http://myserver:6060:/debug/pprof/profile -
手動收集剖析資料。你需要匯入 [`runtime/pprof`](https://pkg.go.dev/runtime/pprof/) 並將以下程式碼新增到 `main` 函式中:
if *flagCpuprofile != "" { f, err := os.Create(*flagCpuprofile) if err != nil { log.Fatal(err) } pprof.StartCPUProfile(f) defer pprof.StopCPUProfile() }剖析結果將寫入指定的檔案,視覺化方式與第一個選項相同。
這是使用 `--web` 選項視覺化剖析結果的示例:[cpu_profile.png]
你可以使用 `--list=funcname` 選項來檢查單個函式。例如,以下剖析結果顯示時間花費在 `append` 函式上:
. . 93: func (bp *buffer) WriteRune(r rune) error {
. . 94: if r < utf8.RuneSelf {
5 5 95: *bp = append(*bp, byte(r))
. . 96: return nil
. . 97: }
. . 98:
. . 99: b := *bp
. . 100: n := len(b)
. . 101: for n+utf8.UTFMax > cap(b) {
. . 102: b = append(b, 0)
. . 103: }
. . 104: w := utf8.EncodeRune(b[n:n+utf8.UTFMax], r)
. . 105: *bp = b[:n+w]
. . 106: return nil
. . 107: }
還有 3 個特殊的條目,當剖析器無法展開堆疊時會使用它們:`GC`、`System` 和 `ExternalCode`。`GC` 表示花費在垃圾回收上的時間,有關最佳化建議,請參閱下面的記憶體剖析器和垃圾回收器跟蹤部分。`System` 表示花費在 goroutine 排程器、堆疊管理程式碼和其他輔助執行時程式碼上的時間。`ExternalCode` 表示花費在原生動態庫上的時間。
以下是一些關於如何解釋剖析結果的提示。
如果你看到大量時間花費在 `runtime.mallocgc` 函式上,則程式可能進行了過多的記憶體分配。剖析結果會告訴你分配的來源。有關最佳化此情況的建議,請參閱記憶體剖析器部分。
如果大量時間花費在通道操作、`sync.Mutex` 程式碼和其他同步原語或 `System` 元件上,則程式可能存在競爭。考慮重構程式以消除頻繁訪問的共享資源。常見的技術包括分片/分割槽、本地緩衝/批次處理和寫時複製技術。
如果大量時間花費在 `syscall.Read/Write` 上,則程式可能進行了過多的少量讀寫操作。在這種情況下,可以使用 `bufio` 包裝 `os.File` 或 `net.Conn`。
如果大量時間花費在 `GC` 元件上,則程式要麼分配了太多臨時物件,要麼堆大小非常小,導致垃圾回收過於頻繁。有關最佳化建議,請參閱垃圾回收器跟蹤器和記憶體剖析器部分。
注意:對於 Darwin,CPU 剖析器目前僅在 El Capitan 或更新版本上可用([https://code.google.com/p/go/issues/detail?id=6047](https://code.google.com/p/go/issues/detail?id=6047))。
注意:在 Windows 上,你需要安裝 Cygwin、Perl 和 Graphviz 才能生成 svg/web 剖析檔案。
記憶體剖析器
記憶體剖析器顯示哪些函式分配了堆記憶體。你可以透過與 CPU 剖析類似的方式收集它:使用 `go test --memprofile`([https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags)`](https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags))、透過 [`net/http/pprof`](https://pkg.go.dev/net/http/pprof) 訪問 `http://myserver:6060:/debug/pprof/heap` 或呼叫 [`runtime/pprof.WriteHeapProfile`](https://pkg.go.dev/runtime/pprof/#WriteHeapProfile)。
你只能視覺化剖析資料收集時存在的分配(`pprof` 的 `--inuse_space` 標誌,預設),或者自程式啟動以來發生的所有分配(`pprof` 的 `--alloc_space` 標誌)。前者適用於使用 `net/http/pprof` 對正在執行的應用程式收集的剖析資料,後者適用於在程式結束時收集的剖析資料(否則你將看到幾乎為空的剖析資料)。
注意:記憶體剖析器是取樣式的,也就是說,它只收集部分記憶體分配的資訊。物件被取樣的機率與其大小成正比。你可以透過 `go test` 的 `--memprofilerate` 標誌,或在程式啟動時設定 `runtime.MemProfileRate` 變數來更改取樣率。將速率設定為 1 將收集所有分配的資訊,但這會減慢執行速度。預設取樣率為每 512KB 分配記憶體進行一次取樣。
你還可以視覺化分配的位元組數或分配的物件數(分別為 `--inuse/alloc_space` 和 `--inuse/alloc_objects` 標誌)。剖析器在剖析時傾向於對較大的物件進行取樣。但重要的是要理解,大物件會影響記憶體消耗和 GC 時間,而大量微小的分配會影響執行速度(在一定程度上也會影響 GC 時間)。因此,同時檢視兩者可能很有用。
物件可以是持久的或臨時的。如果你在程式啟動時分配了幾個大的持久物件,它們很可能被剖析器取樣(因為它們很大)。這些物件確實會影響記憶體消耗和 GC 時間,但它們不會影響正常的執行速度(不會對其進行記憶體管理操作)。另一方面,如果你有大量生命週期非常短的物件,它們可能在剖析中幾乎沒有體現(如果你使用預設的 `--inuse_space` 模式)。但它們確實會顯著影響執行速度,因為它們在不斷地分配和釋放。所以,再次強調,同時檢視這兩種物件可能很有用。因此,通常情況下,如果你想減少記憶體消耗,你需要檢視在正常程式執行期間收集的 `--inuse_space` 剖析資料。如果你想提高執行速度,請檢視在顯著執行一段時間後或程式結束時收集的 `--alloc_objects` 剖析資料。
有幾個標誌控制報告的粒度。`--functions` 使 pprof 在函式級別報告(預設)。`--lines` 使 pprof 在原始碼行級別報告,這在熱函式在不同行上分配時很有用。還有 `--addresses` 和 `--files` 分別用於精確的指令地址和檔案級別。
記憶體剖析有一個有用的選項——你可以在瀏覽器中直接檢視它(前提是你匯入了 `net/http/pprof`)。如果你開啟 `http://myserver:6060/debug/pprof/heap?debug=1`,你應該會看到類似以下的堆剖析資料:
heap profile: 4: 266528 [123: 11284472] @ heap/1048576
1: 262144 [4: 376832] @ 0x28d9f 0x2a201 0x2a28a 0x2624d 0x26188 0x94ca3 0x94a0b 0x17add6 0x17ae9f 0x1069d3 0xfe911 0xf0a3e 0xf0d22 0x21a70
# 0x2a201 cnew+0xc1 runtime/malloc.goc:718
# 0x2a28a runtime.cnewarray+0x3a runtime/malloc.goc:731
# 0x2624d makeslice1+0x4d runtime/slice.c:57
# 0x26188 runtime.makeslice+0x98 runtime/slice.c:38
# 0x94ca3 bytes.makeSlice+0x63 bytes/buffer.go:191
# 0x94a0b bytes.(*Buffer).ReadFrom+0xcb bytes/buffer.go:163
# 0x17add6 io/ioutil.readAll+0x156 io/ioutil/ioutil.go:32
# 0x17ae9f io/ioutil.ReadAll+0x3f io/ioutil/ioutil.go:41
# 0x1069d3 godoc/vfs.ReadFile+0x133 godoc/vfs/vfs.go:44
# 0xfe911 godoc.func·023+0x471 godoc/meta.go:80
# 0xf0a3e godoc.(*Corpus).updateMetadata+0x9e godoc/meta.go:101
# 0xf0d22 godoc.(*Corpus).refreshMetadataLoop+0x42 godoc/meta.go:141
2: 4096 [2: 4096] @ 0x28d9f 0x29059 0x1d252 0x1d450 0x106993 0xf1225 0xe1489 0xfbcad 0x21a70
# 0x1d252 newdefer+0x112 runtime/panic.c:49
# 0x1d450 runtime.deferproc+0x10 runtime/panic.c:132
# 0x106993 godoc/vfs.ReadFile+0xf3 godoc/vfs/vfs.go:43
# 0xf1225 godoc.(*Corpus).parseFile+0x75 godoc/parser.go:20
# 0xe1489 godoc.(*treeBuilder).newDirTree+0x8e9 godoc/dirtrees.go:108
# 0xfbcad godoc.func·002+0x15d godoc/dirtrees.go:100
每個條目開頭的數字(“1: 262144 [4: 376832]”)分別表示當前活動的物件的數量、活動物件佔用的記憶體量、分配的總數以及所有分配佔用的記憶體量。
最佳化通常是應用程式特定的,但這裡有一些常見的建議。
-
將物件合併成更大的物件。例如,將 `*bytes.Buffer` 結構體成員替換為 `bytes.Buffer`(你可以稍後透過呼叫 `bytes.Buffer.Grow` 來預分配寫入緩衝區)。這將減少記憶體分配的數量(更快),並減輕垃圾回收器的壓力(更快的垃圾回收)。
-
區域性變數如果逃逸出其宣告的範圍,將被提升到堆分配。編譯器通常無法證明幾個變數具有相同的生命週期,因此它會單獨分配每個這樣的變數。所以你也可以對區域性變數應用上述建議。例如,將
for k, v := range m { k, v := k, v // copy for capturing by the goroutine go func() { // use k and v }() }替換為
for k, v := range m { x := struct{ k, v string }{k, v} // copy for capturing by the goroutine go func() { // use x.k and x.v }() }這將用一次記憶體分配替換兩次記憶體分配。但是,這種最佳化通常會降低程式碼的可讀性,所以要合理使用。
-
分配合並的一個特例是切片陣列預分配。如果你知道切片的典型大小,你可以如下預分配其底層陣列:
type X struct { buf []byte bufArray [16]byte // Buf usually does not grow beyond 16 bytes. } func MakeX() *X { x := &X{} // Preinitialize buf with the backing array. x.buf = x.bufArray[:0] return x } -
如果可能,使用較小的資料型別。例如,使用 `int8` 而不是 `int`。
-
不包含任何指標的物件(注意字串、切片、對映和通道包含隱式指標),不會被垃圾回收器掃描。例如,一個 1GB 的位元組切片對垃圾回收時間幾乎沒有影響。所以,如果你從活動使用的物件中移除指標,可以對垃圾回收時間產生積極影響。一些可能性是:用索引替換指標,將物件拆分成兩部分,其中一部分不包含指標。
-
使用空閒列表重用臨時物件並減少分配數量。標準庫包含 [`sync.Pool`](http://tip.golang.org/pkg/sync/#Pool) 型別,它允許在垃圾回收之間重用同一個物件多次。但是,請注意,與任何手動記憶體管理方案一樣,錯誤使用 `sync.Pool` 可能導致使用後釋放錯誤。
你也可以使用垃圾回收器跟蹤(見下文)來了解記憶體問題。
TODO(dvyukov): 提及統計資料是延遲更新的:“Memprof 統計資料是延遲更新的。這是為了在分配持續不斷而釋放分批進行的情況下呈現一致的畫面。幾次連續的 GC 會推動更新管道前進。這就是你觀察到的。因此,如果你對一個正在執行的伺服器進行剖析,任何樣本都會給你一個一致的快照。但是,如果程式完成了某項活動,並且你想在活動之後收集快照,那麼你需要執行 2 或 3 次 GC 才能收集。”
阻塞剖析器
阻塞剖析器顯示 goroutine 在哪些地方阻塞等待同步原語(包括定時器通道)。你可以透過與 CPU 剖析類似的方式收集它:使用 `go test --blockprofile`([https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags)`](https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags))、透過 [`net/http/pprof`](https://pkg.go.dev/net/http/pprof) 訪問 `http://myserver:6060:/debug/pprof/block` 或呼叫 [`runtime/pprof.Lookup(“block”).WriteTo`](https://pkg.go.dev/runtime/pprof/#Profile.WriteTo)。
但是有一個重要的注意事項——阻塞剖析器預設情況下是停用的。`go test --blockprofile` 會自動為你啟用它。但是,如果你使用 `net/http/pprof` 或 `runtime/pprof`,你需要手動啟用它(否則剖析資料將為空)。要啟用阻塞剖析器,請呼叫 [`runtime.SetBlockProfileRate`](https://pkg.go.dev/runtime/#SetBlockProfileRate)。`SetBlockProfileRate` 控制阻塞剖析中報告的 goroutine 阻塞事件的比例。剖析器旨在對指定的阻塞納秒數進行平均取樣。要包含剖析中的每一個阻塞事件,請將速率設定為 1。
如果一個函式包含多個阻塞操作,並且不清楚哪個操作導致了阻塞,請使用 `pprof` 的 `--lines` 標誌。
請注意,並非所有阻塞都是壞事。當一個 goroutine 阻塞時,底層的 worker 執行緒會切換到另一個 goroutine。因此,在協作式的 Go 環境中阻塞與在非協作式系統(例如典型的 C++ 或 Java 執行緒庫,其中阻塞會導致執行緒空閒和昂貴的執行緒上下文切換)中阻塞互斥鎖非常不同。為了讓你有一些感覺,讓我們看一些例子。
`time.Ticker` 上的阻塞通常是沒問題的。如果一個 goroutine 在 `Ticker` 上阻塞 10 秒鐘,你會在剖析中看到 10 秒鐘的阻塞,這完全沒問題。`sync.WaitGroup` 上的阻塞通常也是沒問題的。例如,如果一項任務花費 10 秒鐘,等待 WaitGroup 完成的 goroutine 將在剖析中計入 10 秒鐘的阻塞。`sync.Cond` 上的阻塞可能還好,也可能不好,取決於具體情況。消費者阻塞在通道上表明生產者很慢或沒有工作。生產者阻塞在通道上表明消費者更慢,但這通常是可以接受的。基於通道的訊號量上的阻塞顯示有多少 goroutine 在訊號量上受限。`sync.Mutex` 或 `sync.RWMutex` 上的阻塞通常是糟糕的。你可以使用 `pprof` 的 `--ignore` 標誌來排除剖析中已知的不感興趣的阻塞。
goroutine 的阻塞可能導致兩個負面後果:
-
由於缺乏工作,程式無法與處理器擴充套件。排程器跟蹤器可以幫助識別這種情況。
-
過度的 goroutine 阻塞/取消阻塞會消耗 CPU 時間。CPU 剖析器可以幫助識別這種情況(檢視 `System` 元件)。
以下是一些有助於減少 goroutine 阻塞的常見建議:
-
在生產者-消費者場景中使用足夠緩衝的通道。無緩衝通道會嚴重限制程式的可用並行性。
-
對於以讀取為主的工作負載,使用 `sync.RWMutex` 而不是 `sync.Mutex`。在 `sync.RWMutex` 中,讀取者永遠不會阻塞其他讀取者,即使在實現層面也是如此。
-
在某些情況下,可以透過使用寫時複製技術來完全移除互斥鎖。如果受保護的資料結構不經常修改,並且可以建立其副本,那麼可以按如下方式更新:
type Config struct { Routes map[string]net.Addr Backends []net.Addr } var config atomic.Value // actual type is *Config // Worker goroutines use this function to obtain the current config. // UpdateConfig must be called at least once before this func. func CurrentConfig() *Config { return config.Load().(*Config) } // Background goroutine periodically creates a new Config object // as sets it as current using this function. func UpdateConfig(cfg *Config) { config.Store(cfg) }此模式可以防止寫入者在更新期間阻塞讀取者。
-
分割槽是另一種減少對共享可變資料結構進行爭用/阻塞的通用技術。下面是如何分割槽一個雜湊表的示例:
type Partition struct { sync.RWMutex m map[string]string } const partCount = 64 var m [partCount]Partition func Find(k string) string { idx := hash(k) % partCount part := &m[idx] part.RLock() v := part.m[k] part.RUnlock() return v } -
本地快取和批次更新可以幫助減少對不可分割槽資料結構的爭用。下面展示瞭如何批次傳送到通道:
const CacheSize = 16 type Cache struct { buf [CacheSize]int pos int } func Send(c chan [CacheSize]int, cache *Cache, value int) { cache.buf[cache.pos] = value cache.pos++ if cache.pos == CacheSize { c <- cache.buf cache.pos = 0 } }此技術不限於通道。它可以用於批次更新對映、批次分配等。
-
使用 [`sync.Pool`](http://tip.golang.org/pkg/sync/#Pool) 作為空閒列表,而不是基於通道或受互斥鎖保護的空閒列表。`sync.Pool` 在內部使用智慧技術來減少阻塞。
goroutine 剖析器
goroutine 剖析器僅提供程序中所有活動 goroutine 的當前堆疊。它可以方便地除錯負載均衡問題(請參閱下面的排程器跟蹤部分)或除錯死鎖。剖析資料僅對正在執行的應用程式有意義,因此 `go test` 不支援它。你可以透過 [`net/http/pprof`](https://pkg.go.dev/net/http/pprof) 訪問 `http://myserver:6060:/debug/pprof/goroutine` 來收集剖析資料,並將其視覺化為 svg/pdf,或透過呼叫 [`runtime/pprof.Lookup(“goroutine”).WriteTo`](https://pkg.go.dev/runtime/pprof/#Profile.WriteTo) 進行視覺化。但最有用的是在瀏覽器中輸入 `http://myserver:6060:/debug/pprof/goroutine?debug=2`,這將提供符號化的堆疊,類似於程式崩潰時看到的。請注意,處於“syscall”狀態的 goroutine 會消耗一個 OS 執行緒,而其他 goroutine 則不會(除了那些呼叫了 `runtime.LockOSThread` 的 goroutine,不幸的是,這在剖析中是不可見的)。請注意,處於“IO wait”狀態的 goroutine 也不會消耗執行緒,它們被停在非阻塞網路輪詢器上(該輪詢器使用 epoll/kqueue/GetQueuedCompletionStatus 稍後喚醒 goroutine)。
垃圾回收器跟蹤
除了剖析工具,還有另一類工具——跟蹤器。它們允許跟蹤垃圾回收、記憶體分配器和 goroutine 排程器的狀態。要啟用垃圾回收器 (GC) 跟蹤,請使用 `GODEBUG=gctrace=1` 環境變數執行程式:
$ GODEBUG=gctrace=1 ./myserver
然後程式在執行過程中會輸出類似以下的內容:
gc9(2): 12+1+744+8 us, 2 -> 10 MB, 108615 (593983-485368) objects, 4825/3620/0 sweeps, 0(0) handoff, 6(91) steal, 16/1/0 yields
gc10(2): 12+6769+767+3 us, 1 -> 1 MB, 4222 (593983-589761) objects, 4825/0/1898 sweeps, 0(0) handoff, 6(93) steal, 16/10/2 yields
gc11(2): 799+3+2050+3 us, 1 -> 69 MB, 831819 (1484009-652190) objects, 4825/691/0 sweeps, 0(0) handoff, 5(105) steal, 16/1/0 yields
讓我們看一下這些數字的含義。每行代表一次 GC。第一個數字 (“gc9”) 是 GC 的次數(這是自程式啟動以來的第 9 次 GC)。括號中的數字 (“(2)”) 是參與 GC 的工作執行緒數。接下來的 4 個數字 (“12+1+744+8 us”) 分別是以微秒為單位的 stop-the-world、sweeping、marking 和等待工作執行緒完成的時間。接下來的 2 個數字 (“2 -> 10 MB”) 是上一次 GC 後活動堆的大小和當前 GC 之前的完整堆大小(包括垃圾)。接下來的 3 個數字 (“108615 (593983-485368) objects”) 是堆中物件的總數(包括垃圾)以及記憶體分配和釋放操作的總數。接下來的 3 個數字 (“4825/3620/0 sweeps”) 描述了 sweep 階段(上一次 GC 的):總共有 4825 個記憶體 span,3620 個是按需或在後臺 sweep 的,0 個是在 stop-the-world 階段 sweep 的(其餘是未使用 span)。接下來的 4 個數字 (“0(0) handoff, 6(91) steal”) 描述了並行 marking 階段的負載均衡:有 0 次物件交接操作(0 個物件被交接),以及 6 次 steal 操作(91 個物件被 steal)。最後的 3 個數字 (“16/1/0 yields”) 描述了並行 marking 階段的有效性:總共有 17 次 yield 操作,用於等待其他執行緒。
GC 是 標記-清除型別。總 GC 時間可以表示為:
Tgc = Tseq + Tmark + Tsweep
其中 Tseq 是停止使用者 goroutine 和一些準備活動的時間(通常很小);Tmark 是堆標記時間,標記發生在所有使用者 goroutine 都停止時,因此會顯著影響處理的延遲;Tsweep 是堆 sweep 時間,sweep 通常與正常程式執行併發進行,因此對延遲的影響不大。
標記時間可以近似表示為:
Tmark = C1*Nlive + C2*MEMlive_ptr + C3*Nlive_ptr
其中 Nlive 是 GC 期間堆中活動物件的數量,MEMlive_ptr 是包含指標的活動物件佔用的記憶體量,Nlive_ptr 是活動物件中指標的數量。
Sweep 時間可以近似表示為:
Tsweep = C4*MEMtotal + C5*MEMgarbage
其中 MEMtotal 是堆的總記憶體量,MEMgarbage 是堆中垃圾的量。
下次 GC 發生的時間是,程式分配的記憶體量超過當前使用量的一定比例後。這個比例由 GOGC 環境變數控制(預設為 100)。如果 GOGC=100 且程式使用了 4MB 的堆記憶體,那麼當程式達到 8MB 時,執行時將再次觸發 GC。這使得 GC 的成本與分配成本成線性比例關係。調整 GOGC 會改變線性常數以及使用的額外記憶體量。
只有 sweep 受堆總大小的影響,並且 sweep 與正常程式執行併發進行。因此,如果你能承受額外的記憶體消耗,將 GOGC 設定為更高的值(200、300、500 等)是明智的。例如,GOGC=300 可以在保持延遲相同的情況下,將垃圾回收開銷減少最多 2 倍(但代價是堆大小增加 2 倍)。
GC 是並行的,並且通常能很好地與硬體並行性擴充套件。因此,即使對於順序程式,將 GOMAXPROCS 設定為更高的值以加快垃圾回收速度也是有意義的。但是,請注意,目前垃圾回收執行緒的數量限制為 8。
記憶體分配器跟蹤
記憶體分配器跟蹤只是將所有記憶體分配和釋放操作轉儲到控制檯。它透過 `GODEBUG=allocfreetrace=1` 環境變數啟用。輸出類似如下:
tracealloc(0xc208062500, 0x100, array of parse.Node)
goroutine 16 [running]:
runtime.mallocgc(0x100, 0x3eb7c1, 0x0)
runtime/malloc.goc:190 +0x145 fp=0xc2080b39f8
runtime.growslice(0x31f840, 0xc208060700, 0x8, 0x8, 0x1, 0x0, 0x0, 0x0)
runtime/slice.goc:76 +0xbb fp=0xc2080b3a90
text/template/parse.(*Tree).parse(0xc2080820e0, 0xc208023620, 0x0, 0x0)
text/template/parse/parse.go:289 +0x549 fp=0xc2080b3c50
...
tracefree(0xc208002d80, 0x120)
goroutine 16 [running]:
runtime.MSpan_Sweep(0x73b080)
runtime/mgc0.c:1880 +0x514 fp=0xc20804b8f0
runtime.MCentral_CacheSpan(0x69c858)
runtime/mcentral.c:48 +0x2b5 fp=0xc20804b920
runtime.MCache_Refill(0x737000, 0xc200000012)
runtime/mcache.c:78 +0x119 fp=0xc20804b950
...
跟蹤包含記憶體塊的地址、大小、型別、goroutine ID 和堆疊跟蹤。它可能對除錯更有用,但也可以為分配最佳化提供非常細粒度的資訊。
排程器跟蹤
排程器跟蹤可以深入瞭解 goroutine 排程器的動態行為,並允許除錯負載均衡和可伸縮性問題。要啟用排程器跟蹤,請使用 `GODEBUG=schedtrace=1000` 環境變數執行程式(該值表示輸出週期,單位為毫秒,在本例中為每秒一次):
$ GODEBUG=schedtrace=1000 ./myserver
然後程式在執行過程中會輸出類似以下的內容:
SCHED 1004ms: gomaxprocs=4 idleprocs=0 threads=11 idlethreads=4 runqueue=8 [0 1 0 3]
SCHED 2005ms: gomaxprocs=4 idleprocs=0 threads=11 idlethreads=5 runqueue=6 [1 5 4 0]
SCHED 3008ms: gomaxprocs=4 idleprocs=0 threads=11 idlethreads=4 runqueue=10 [2 2 2 1]
第一個數字 (“1004ms”) 是程式啟動以來的時間。Gomaxprocs 是 GOMAXPROCS 的當前值。Idleprocs 是空閒處理器的數量(其餘的正在執行 Go 程式碼)。Threads 是排程器建立的總 worker 執行緒數(執行緒有 3 種狀態:執行 Go 程式碼 (gomaxprocs-idleprocs)、執行 syscall/cgocalls 或空閒)。Idlethreads 是空閒 worker 執行緒的數量。Runqueue 是可執行 goroutine 的全域性佇列的長度。全域性佇列和處理器本地佇列的長度之和代表了可執行 goroutine 的總數。
注意:你可以組合使用任何跟蹤器,例如 `GODEBUG=gctrace=1,allocfreetrace=1,schedtrace=1000`。
注意:還有一個詳細的排程器跟蹤,你可以透過 `GODEBUG=schedtrace=1000,scheddetail=1` 啟用。它會列印有關每個 goroutine、worker 執行緒和處理器的詳細資訊。我們在這裡不描述其格式,因為它主要對排程器開發人員有用;但你可以在 [`src/pkg/runtime/proc.c`](https://code.google.com/p/go/source/browse/src/pkg/runtime/proc.c) 中找到詳細資訊。
當程式未能與 GOMAXPROCS 線性擴充套件和/或未能 100% 消耗 CPU 時間時,排程器跟蹤非常有用。理想情況是所有處理器都在忙於執行 Go 程式碼,執行緒數量合理,所有佇列都有大量工作,並且工作分配相對均勻。
gomaxprocs=8 idleprocs=0 threads=40 idlethreads=5 runqueue=10 [20 20 20 20 20 20 20 20]
不良情況是以上某項不成立。例如,以下示例演示了工作量不足以使所有處理器保持忙碌:
gomaxprocs=8 idleprocs=6 threads=40 idlethreads=30 runqueue=0 [0 2 0 0 0 1 0 0]
注意:使用作業系統提供的工具來衡量實際 CPU 利用率作為最終指標。在類 Unix 作業系統上是 `top` 命令;在 Windows 上是任務管理器。
你可以在工作量不足的情況下使用 goroutine 剖析器來了解 goroutine 在哪裡阻塞。請注意,負載不平衡並非絕對有害,只要所有處理器都保持忙碌,只會產生一些適度的負載均衡開銷。
記憶體統計
Go 執行時透過 [`runtime.ReadMemStats`](https://pkg.go.dev/runtime/#ReadMemStats) 函式暴露粗粒度的記憶體統計資訊。統計資訊也可透過 `net/http/pprof` 在 `http://myserver:6060/debug/pprof/heap?debug=1` 的底部找到。統計資訊在此處 [https://pkg.go.dev/runtime/#MemStats](https://pkg.go.dev/runtime/#MemStats) 描述。一些有趣的欄位是:
- HeapAlloc - 當前堆大小。
- HeapSys - 總堆大小。
- HeapObjects - 堆中物件的總數。
- HeapReleased - 釋放給作業系統的記憶體量;執行時會將 5 分鐘內未使用的記憶體釋放給作業系統,你可以透過 `runtime/debug.FreeOSMemory` 強制執行此過程。
- Sys - 從作業系統分配的總記憶體量。
- Sys-HeapReleased - 程式的有效記憶體消耗。
- StackSys - goroutine 堆疊消耗的記憶體(請注意,一些堆疊是從堆中分配的,並且不在此處計算,不幸的是,無法獲得堆疊的總大小([https://code.google.com/p/go/issues/detail?id=7468](https://code.google.com/p/go/issues/detail?id=7468))。
- MSpanSys/MCacheSys/BuckHashSys/GCSys/OtherSys - 執行時為各種輔助目的分配的記憶體量;它們通常不那麼重要,除非它們太高。
- PauseNs - 最近幾次垃圾回收的持續時間。
堆轉儲器
最後一個可用的工具是堆轉儲器,它可以將整個堆的狀態寫入檔案以供將來探索。它有助於識別記憶體洩漏並瞭解程式的記憶體消耗。
首先,你需要使用 [`runtime/debug.WriteHeapDump`](http://tip.golang.org/pkg/runtime/debug/#WriteHeapDump) 函式來寫入轉儲:
f, err := os.Create("heapdump")
if err != nil { ... }
debug.WriteHeapDump(f.Fd())
然後,你可以將其渲染為具有堆圖形表示的 dot 檔案,或將其轉換為 hprof 格式。將其渲染為 dot 檔案:
$ go get github.com/randall77/hprof/dumptodot
$ dumptodot heapdump mybinary > heap.dot
並使用 Graphviz 開啟 `heap.dot`。
將其轉換為 `hprof` 格式:
$ go get github.com/randall77/hprof/dumptohprof
$ dumptohprof heapdump heap.hprof
$ jhat heap.hprof
並將你的瀏覽器導航到 `http://myserver:7000`。
總結
最佳化是一個開放性問題,有一些簡單的技巧可以用來提高效能。有時最佳化需要對程式進行徹底的重新架構。但我們希望這些工具能成為你工具箱中的寶貴補充,你可以用它們來至少分析和理解發生了什麼。 [Profiling Go Programs](https://golang.com.tw/blog/profiling-go-programs) 是一個關於如何使用 CPU 和記憶體剖析器最佳化簡單程式的優秀教程。
此內容是 Go Wiki 的一部分。