深究 Go CPU profiler
在 profiling 方面,Go 是獨一無二的。在 runtime 裏面它包含強大的,有自主意識的 profilers。其他編程語言像 Ruby,Python,或者 Node.js,包含了 profilers 或者一些 APIs 接口用來寫 profiler。但與 Go 提供的開箱即用的服務相比,它們的範圍還是有限的。如果你想學習更多關於 Go 提供的可觀測性工具,我強烈建議 Felix Geisendörfer 的 忙碌的開發人員指南 - Go 性能分析,追蹤和可觀測性
作爲一個好奇的工程師,我喜歡鑽研底層的實現原理,並且我一直希望能學習 Go CPU profiler 是如何實現的。這個博客就是這個學習的過程。毫無例外的,我總是能在我閱讀 Go runtime 的時候找到並學習新的知識點。
基礎知識
有兩種類型的 profiler :
-
追蹤型:任何時候觸發提前設定的事件就會做測量,例如:函數調用,函數退出,等等
-
採樣型:常規時間間隔做一次測量
Go CPU profiler 是一個採樣型的 profiler。也有一個追蹤型的 profiler,Go 執行追蹤器,用來追蹤特定事件像請求鎖,GC 相關的事件,等等。
採樣型 profiler 通常包含兩個主要部分:
-
採樣器:一個在時間間隔觸發的回調,一個堆棧信息一般會被收集成 profiling data。不同的 profiler 用不同的策略去觸發回調。
-
數據收集:這個是 profiler 收集數據的地方:它可能是內存佔用或者是調用統計,基本上跟堆棧追蹤相關的數據
其他 profiler 如何工作的小調研
Linux perf
使用 PMU
(Performance Monitor Unit)計數器進行採樣。你指示 PMU
在某些事件發生 N 次後產生一箇中斷。一個例子,可能是每 1000 個 CPU 時鐘週期進行一次採樣。Denis Bakhvalov 寫了一篇詳細的文章,解釋了像 perf
和 VTune
這樣的工具如何使用 PMU 計數器來實現。一旦數據收集回調被定期觸發,剩下的就是收集堆棧痕跡並適當地彙總。爲了完整起見,Linux perf
使用 perf_event_open(PERF_SAMPLE_STACK_USER,...)
來獲取堆棧追蹤信息。捕獲的堆棧痕跡通過 mmap'd
環形緩衝區寫到用戶空間。
pyspy
和 rbspy
是 Python 和 Ruby 著名的採樣分析器。它們都作爲外部進程運行,定期讀取目標應用程序的內存,以捕獲運行線程的堆棧追蹤。在 Linux 中,它們使用 process_vm_readv
,如果我沒記錯的話,這個 API 在讀取內存時,會讓目標程序暫停幾毫秒。然後他們在讀取的內存中跟蹤指針,來找到當前運行的線程結構和堆棧追蹤信息。正如人們所猜測的那樣,這是一個容易出錯和複雜的方法,但效果卻出奇的好。IIRC, pyflame
也是使用類似的方法實現。
最近的 profiler 如 Parca(還有其他一些)使用 eBPF。eBPF 是一項最近出現的技術,允許在內核虛擬機中運行用戶區代碼。它是一項傑出的技術,被用於許多領域,如安全、網絡和可觀察性。我強烈建議閱讀一些關於 eBPF 的資料;這是一個龐大的話題,遠遠超出了這篇博文的範圍。
profiler 是如何週期性觸發的?
Go CPU profiler 是一個採樣型 profiler。在 Linux 中,Go runtime 使用setitimer/timer_create/timer_settime
API 來設置 SIGPROF
信號處理器。這個處理器在runtime.SetCPUProfileRate
控制的週期內被觸發,默認爲 100Mz(10ms)。順便提一下:令人驚訝的是,在 Go 1.18 之前,圍繞 Go CPU profiler 存在着一些嚴重的問題!你可以在這裏看到這些問題的詳細細節。IIUC,setitimer
API 是在 Linux 中每線程觸發基於時間的信號的推薦方式,但它並沒有像宣傳的那樣工作。如果這個說法是錯誤的,請隨時糾正我。(Felix Geisendörfer 糾正了這個說法):從技術上講,它的工作原理和你對進程導向的信號機制的期望是一樣的。但對於多核 profiling 來說不是一個好的機制。
讓我們看下你如何開啓 Go CPU profiler:
f, err := os.Create("profile.pb.gz")
if err != nil {
...
}
if err := pprof.StartCPUProfile(f); err != nil {
...
}
defer pprof.StopCPUProfile()
一旦 pprof.StartCPUProfile
被調用,Go runtime 就會在特定的時間間隔產生SIGPROF
信號。內核嚮應用程序中的一個運行線程發送 SIGPROF
信號。由於 Go 使用非阻塞式 I/O,等待 I/O 的 goroutines 不被計算爲運行,Go CPU profiler 不捕獲這些。順便提一下:這是實現 fgprof 的基本原因。fgprof
使用 runtime.GoroutineProfile
來獲得等待和非等待的 goroutines 的 profile 數據。
一圖勝千言,下面是 Go runtime 如何處理 SIGPROF
信號:
profiler 是如何收集數據?
一旦一個隨機運行的 goroutine 收到 SIGPROF
信號,它就會被中斷,然後信號處理器的程序開始運行。被中斷的 goroutine 的堆棧追蹤在這個信號處理器的上下文中被檢索出來,然後和當前的 profiler 標籤一起被保存到一個無鎖的日誌結構中(每個捕獲的堆棧追蹤都可以和一個自定義的標籤相關聯,你可以用這些標籤在以後做過濾)。這個特殊的無鎖結構被命名爲 profBuf
,它被定義在 runtime/profbuf.go 中,並對其工作原理做了詳細的解釋。它是一個單一寫、單一讀的無鎖環形緩衝 結構,與這裏發表的結構相似。writer 是 profiler 的信號處理器,reader 是一個 goroutine(profileWriter
),定期讀取這個緩衝區的數據,並將結果彙總到最終的 hashmap。這個最終的 hashmap 結構被命名爲 profMap
,並在 runtime/pprof/map.go
中定義。
下面是一個關於這一切如何結合在一起的簡單的示意圖:
可以看到,最終的結構與普通的 pprof.Profile
對象有很多相似之處:這是一個 hashmap,鍵是 stack trace + label,值是在應用程序中該調用棧被觀察到的次數。當 pprof.StopCPUProfile()
被調用時,profiling 停止,profileWriter
goroutine 調用 build()
函數,該函數在此實現。這個函數負責將 profMap
結構的數據寫入一個 io.Writer
的對象,這個對象在最初 pprof.StartCPUProfile
調用的時候提供。實際上,這裏就是最終 pprof.Profile
對象生成的位置。
profileWriter
的僞代碼如下,可能會有些幫助:
func profileWriter(w io.Writer) {
...
for {
time.Sleep(100 * time.Millisecond)
data, tags := readProfile()
if e := b.addCPUData(data, tags); e != nil {
...
}
if profilerStopped {
break
}
}
...
b.build() // -> generates final pprof.Profile
...
}
一旦我對設計有一個更高階的理解,我就會問我自己以下問題:
爲什麼 Go 要大費周章地實現一個獨特的無鎖結構來保存臨時的 profiling 數據?爲什麼不定期地將所有數據寫入 hashmap?
這個答案在設計本身。
看一下 SIGPROF
處理器做的第一件事就是禁用內存分配。此外,profiler 的代碼路徑不涉及任何鎖,甚至堆棧追蹤的最大深度也是硬編碼的。從 Go 1.19
開始,它是 64。所有這些細節都是爲了給 profiler 提供一個更有效和可預測的開銷。對於一個可應用於生產的 profiler 來說,低開銷且可預測是關鍵。
開銷
基於這個設計,profiler 的開銷是否會是恆定的嗎?其實,是分情況的。讓我來解釋一下:在一個單一 profiler 中斷下會做如下事情:
-
一個隨機執行中的 goroutine 上下文切換去執行
SIGPROF
處理器代碼, -
stack walk 發生,然後 Go runtime 會把這個堆棧追蹤保存到一個無鎖的環形緩存內,
-
goroutine 被恢復。
理論上,由於沒有分配和鎖的發生,上述所有情況似乎應該在恆定時間內運行。雖然我找不到參考資料,但我記得所有的而且這是真的:所有上述情況都發生在大約~10 納秒~1 微秒(在一個典型的 CPU 上)(由 Felix Geisendörfer 糾正)。但是,在實際中,它變得更糟糕。如果你搜索 “Go CPU profiler 的開銷”,你會看到從 %0.5 到 %1-%5 甚至 %10
的數字(* 關於這個數字沒有公開提及,也沒有實證證據 *)。這背後的原因主要與 CPU 的工作方式有關。現代 CPU 複雜得像只野獸。他們肆意地緩存。一個典型的單個 CPU 核心有三層高速緩存。L1、L2 和 L3。當一個特定的 CPU 密集型代碼運行時,這些緩存被高度利用。對於一些大量讀取小數據(可放入緩存的數據)和順序數據的應用來說,緩存的高利用率尤其如此。
矩陣乘法就是一個很好的例子:在矩陣乘法過程中,CPU 大量訪問內存中連續的獨立單元。這類有利於緩存的應用可能會對採樣 profiler 產生 最壞
的開銷。雖然很想用 perf
做一些 benchmark 來驗證這一說法,但這已經超出了本博文的範圍。
綜上所述,Go runtime 在保持 profiler 開銷的可預測性和儘可能的低開銷方面做得很好。如果你不相信我,你不應該不相信,也許下面可以說服你:
在谷歌,我們不斷地對 Go 生產服務進行 profile,而且這樣做是安全的
上述是從 Google thread 的引用。
還有一個是來自 DataDog 的連續 profiler 實現的提交,使 profiler 總是被啓用:
在對許多高容量的內部工作負載進行測試後,我們已經確定這個默認值對生產來說是安全的。
最後,基於上述理論,我們可以做出以下觀察:
在典型的 I/O 綁定的應用程序上,profiler 的開銷將是最小的。
這是因爲當有許多休眠 / 空閒的 goroutine 時,CPU 的緩存垃圾並不會產生太大的影響。我們在 Go CPU profiler 的 benchmark 中反覆觀察到這一點:在典型的 I/O 綁定的應用程序中,開銷簡直爲零(或統計上微不足道)。但是,同樣的,提供經驗證據超出了這篇博文的範圍,可以在 profiler 開啓和關閉的情況下,通過觀察 Go 網絡應用程序的負載測試中的吞吐量來實現。
總結
我喜歡這種設計的一個原因是,它證明了你能多好地優化代碼,這取決於你對底層數據結構的訪問模式的理解程度。在這種情況下,Go runtime 使用了無鎖結構,儘管它在大多數情況下是完全多餘的。Go runtime 充滿了像這樣聰明的優化,我強烈建議你花時間深入研究你認爲有趣的部分。
我希望你能享受它!
參考資料
-
https://github.com/DataDog/go-profiler-notes/blob/main/guide/README.md
-
https://easyperf.net/blog/2018/06/01/PMU-counters-and-profiling-basics
-
https://www.instana.com/blog/go-profiler-internals/
-
https://www.datadoghq.com/blog/engineering/profiling-improvements-in-go-1-18/
-
http://www.cse.cuhk.edu.hk/~pclee/www/pubs/ancs09poster.pdf
原文地址:
Inside the Go CPU profiler (sumercip.com)
原文作者:
Sümer Cip
本文永久鏈接:
https://github.com/gocn/translator/blob/master/2022/w41_Inside_the_Go_CPU_profiler_sumercip.md
譯者:zxmfke
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/DRQWcU2dN-FycoyFZfnklA