深究 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 :

  1. 追蹤型:任何時候觸發提前設定的事件就會做測量,例如:函數調用,函數退出,等等

  2. 採樣型:常規時間間隔做一次測量

Go CPU profiler 是一個採樣型的 profiler。也有一個追蹤型的 profiler,Go 執行追蹤器,用來追蹤特定事件像請求鎖,GC 相關的事件,等等。

採樣型 profiler 通常包含兩個主要部分:

  1. 採樣器:一個在時間間隔觸發的回調,一個堆棧信息一般會被收集成 profiling data。不同的 profiler 用不同的策略去觸發回調。

  2. 數據收集:這個是 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 中斷下會做如下事情:

  1. 一個隨機執行中的 goroutine 上下文切換去執行 SIGPROF 處理器代碼,

  2. stack walk 發生,然後 Go runtime 會把這個堆棧追蹤保存到一個無鎖的環形緩存內,

  3. 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 充滿了像這樣聰明的優化,我強烈建議你花時間深入研究你認爲有趣的部分。

我希望你能享受它!

參考資料

  1. https://github.com/DataDog/go-profiler-notes/blob/main/guide/README.md

  2. https://easyperf.net/blog/2018/06/01/PMU-counters-and-profiling-basics

  3. https://www.instana.com/blog/go-profiler-internals/

  4. https://www.datadoghq.com/blog/engineering/profiling-improvements-in-go-1-18/

  5. 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