通過 eBPF 深入探究 Go GC
大家好,我是程序員幽鬼。
對程序員來說,內存管理是很重要的。編程語言按內存管理方式一般可以分為手動內存管理和自動內存管理。手動內存管理典型代表有 C、C++;自動內存管理代表有 Java、C# 等。通常,自動內存管理即自帶垃圾收集器,即 GC(當然,Rust 另辟蹊徑,它既沒有 GC,也不需要手動內存管理,感興趣的可以了解下)。Go 語言也采用了 GC 的方式管理內存,雖然 Gopher 不需要手動管理內存了,但了解 Go 如何分配和釋放內存可以讓我們編寫更好、更高效的應用程序。垃圾收集器是這個難題的關鍵部分。本文就探討 Go 中的 GC。
為了更好地理解垃圾收集器的工作原理,我決定在實時應用程序上跟蹤它的底層行為。本文將使用 eBPF uprobes 檢測 Go 垃圾收集器。這篇文章的源代碼在這里[1]。
1、前提知識
在深入研究之前,讓我們快速了解一下 uprobes、垃圾收集器的設計以及我們將使用的演示應用程序。
為什么用 uprobes?
uprobes[2] 很酷,因為它們讓我們無需修改代碼即可動態收集新信息。當你不能或不想重新部署你的應用程序時,這會非常有用。
函數參數、返回值、延遲和時間戳都可以通過 uprobes 收集。在這篇文章中,我將把 uprobes 部署到 Go 垃圾收集器的關鍵函數上。這讓我們能看到它在正在運行的應用程序中的實際表現。
uprobes 可以跟蹤延遲、時間戳、參數和函數的返回值片
注意:這篇文章使用的 Go 版本是 1.16。我將在 Go 運行時中跟蹤私有函數,因此這些功能在 Go 的后續版本中可能會發生變化。
垃圾回收的階段
Go 使用并發標記和清除垃圾收集器。對于那些不熟悉這些術語的人,閱讀以下內容,方便你理解本文其他內容。
- ??https://agrim123.github.io/posts/go-garbage-collector.html??
- ??https://en.wikipedia.org/wiki/Tracing_garbage_collection??
- ??https://go.dev/blog/ismmkeynote??
- ??https://www.iecc.com/gclist/GC-algorithms.html??
Go 的垃圾收集器被稱為并發的,因為它可以安全地與主程序并行運行。換句話說,它不需要停止你程序的執行來完成它的工作(稍后會詳細介紹)。
垃圾收集有兩個主要階段:
標記(Mark)階段:識別并標記程序不再需要的對象。
清除(Sweep)階段:對于標記階段標記為“無法訪問”的每個對象,釋放內存以供其他地方使用。
一種節點著色算法。黑色表示仍在使用中。白色表示已準備好清理。灰色表示仍然需要分類為黑色或白色
一個簡單的演示應用程序
這是一個簡單的端點(endpoint),我將使用它來觸發垃圾收集器。它創建一個可變大小的字符串數組,然后通過調用 runtime.GC() 來啟動垃圾收集器。
實際代碼中,你不需要手動調用垃圾收集器,因為 Go 會自動為你處理。
http.HandleFunc("/allocate-memory-and-run-gc", func(w http.ResponseWriter, r *http.Request) {
arrayLength, bytesPerElement := parseArrayArgs(r)
arr := generateRandomStringArray(arrayLength, bytesPerElement)
fmt.Fprintf(w, fmt.Sprintf("Generated string array with %d bytes of data\n", len(arr) * len(arr[0])))
runtime.GC()
fmt.Fprintf(w, "Ran garbage collector\n")
})
2、跟蹤垃圾收集的主要階段
我們已經了解了 uprobes 和 Go 垃圾收集器的基礎知識,接下來深入觀察它的行為。
跟蹤 runtime.GC()
首先,我們計劃在 Go 的 runtime 庫中的以下函數中添加 uprobes:
函數 | 描述 |
GC[3] | 調用 GC |
gcWaitOnMark[4] | 等待標記階段完成 |
gcSweep[5] | 執行清除階段 |
(如果你有興趣了解 uprobes 是如何生成的,這里是代碼[6]。)
部署 uprobes 后,點擊端點并生成了一個包含 10 個字符串的數組,每個字符串為 20 個字節。
$ curl '127.0.0.1:8080/allocate-memory-and-run-gc?arrayLength=10&bytesPerElement=20'
Generated string array with 200 bytes of data
Ran garbage collector
這時 uprobes 會觀察到以下事件:
在運行垃圾收集器后,為 GC、gcWaitOnMark 和 gcSweep 收集事件
從源代碼[7]來看這是有道理的——gcWaitOnMark被調用兩次,一次是在開始下一個循環之前對前一個循環進行驗證。標記階段觸發清除階段。
接下來,使用各種輸入請求 /allocate-memory-and-run-gc 端點對 runtime.GC 后的延遲進行了一些測量。
arrayLength | bytesPerElement | Approximate size (B) | GC latency (ms) | GC throughput (MB/s) |
100 | 1,000 | 100,000 | 3.2 | 31 |
1,000 | 1,000 | 1,000,000 | 8.5 | 118 |
10,000 | 1,000 | 10,000,000 | 53.7 | 186 |
100 | 10,000 | 1,000,000 | 3.2 | 313 |
1,000 | 10,000 | 10,000,000 | 12.4 | 807 |
10,000 | 10,000 | 100,000,000 | 96.2 | 1,039 |
跟蹤標記和清除階段
雖然這是一個很好的高級視圖,但我們可以使用更多細節。接下來探索一些用于內存分配、標記和清除的輔助函數,以獲取下一級信息。
這些輔助函數有參數或返回值,可以幫助我們更好地可視化正在發生的事情(例如分配的內存頁)。
函數 | 描述 | 捕獲的信息 |
allocSpan[8] | 分配新內存 | 分配的內存頁 |
gcDrainN[9] | 執行 N 個單位的標記工作 | 完成的標記工作單位 |
sweepone[10] | 從 span 中清除內存 | 清除的內存頁 |
$ curl '127.0.0.1:8080/allocate-memory-and-run-gc?arrayLength=20000&bytesPerElement=4096'
Generated string array with 81920000 bytes of data
Ran garbage collector
在以更大的負載命中垃圾收集器之后,以下是原始結果:
調用垃圾收集器后,allocSpan、gcDrainN 和 sweepone 收集的事件示例
繪制為時間序列更容易解釋:
allocSpan 隨時間分配的內存頁
gcDrain 標記在一段時間內完成的工作
sweepone 隨時間清除的內存頁
現在我們可以看到發生了什么:
- Go 分配了幾千內存頁,這是正常的,因為我們直接向堆中添加了大約 80MB 的字符串。
- 標記工作拉開了序幕(注意它的單位不是頁,而是標記工作單位)
- 有標記的內存頁被清除器清除。(這應該是所有內存頁,因為在調用完成后我們不會重用字符串數組)。
追蹤 Stop The World 事件
“Stopping the world”是指垃圾收集器暫時停止除自身之外的一切,以安全地修改狀態。我們通常更喜歡最小化 STW 階段,因為 STW 會減慢我們的程序速度(通常是在最不方便的時候……)。
一些垃圾收集器會在垃圾收集運行的整個過程中 stop the world。這些是“非并發”垃圾收集器。雖然 Go 的垃圾收集器在很大程度上是并發的,但我們可以從代碼中看到,它在技術上確實在兩個地方 STW 了。
我們跟蹤以下函數:
函數 | 描述 |
stopTheWorldWithSema[11] | 停止其他 goroutine 直到? |
startTheWorldWithSema[12] | 啟動暫停的 goroutine |
再次觸發 GC:
$ curl '127.0.0.1:8080/allocate-memory-and-run-gc?arrayLength=10&bytesPerElement=20'
Generated string array with 200 bytes of data
Ran garbage collector
這次產生了如下事件:
生成啟動和停止 STW 事件
我們可以從GC事件中看到垃圾收集需要 3.1 毫秒才能完成。在我檢查了確切的時間戳之后,事實證明 STW 第一次停止了 300 μs,第二次停止了 365 μs。換句話說,~80%垃圾收集是同時執行的。當垃圾收集器在實際內存壓力下自動調用時,我們預計這個比率會變得更好。
為什么 Go 垃圾收集器需要 STW?
1st Stop The World(標記階段之前):設置狀態并打開寫屏障。寫屏障確保在 GC 運行時正確跟蹤新的寫入(這樣它們就不會被意外釋放或保留)。
2nd Stop The World(標記階段之后):清理標記狀態并關閉寫屏障。
3、垃圾收集器如何調整自己的速度?
知道何時運行垃圾收集是 Go 等并發垃圾收集器的重要考慮因素。
早期的垃圾收集器被設計為一旦達到一定的內存消耗水平就會啟動。如果垃圾收集器是非并發的,這可以正常工作。但是使用并發垃圾收集器,主程序在垃圾收集期間仍在運行 —— 因此可能仍在進行內存分配。
這意味著如果太晚運行垃圾收集器,可能會超出內存目標。(Go 也不能一直運行垃圾收集 —— GC 會從主應用程序中奪走資源和性能。)
Go 的垃圾收集器使用 pacer[13] 來估計垃圾收集的最佳時間。這有助于 Go 滿足其內存和 CPU 目標,而不會犧牲不必要的應用程序性能。
pacer,可以理解為定速裝置
觸發率
Go 的并發垃圾收集器依賴于一個 pacer 來確定何時進行垃圾收集。但它是如何做出這個決定的呢?
每次調用垃圾收集器時,pacer 都會更新其內部目標,即下次應該何時運行 GC。這個目標稱為觸發率。觸發率0.6意味著一旦堆大小增加 60%,系統應該運行垃圾收集。觸發率是CPU、內存和其他因素共同決定的數字。
讓我們看看當我們一次分配大量內存時,垃圾收集器的觸發率是如何變化的。我們可以通過跟蹤函數來獲取觸發率gcSetTriggerRatio。
$ curl '127.0.0.1:8080/allocate-memory-and-run-gc?arrayLength=20000&bytesPerElement=4096'
Generated string array with 81920000 bytes of data
Ran garbage collector
觸發率隨時間的變化
從圖中可以看到,最初,觸發率相當高。運行時已經確定,在程序使用 450% 或更多內存之前,不需要進行垃圾收集。這是有道理的,因為應用程序沒有做太多事情(并且沒有使用很多堆)。
然而,一旦我們請求端點進行 ~81MB 堆分配時,觸發率迅速下降到 ~1。現在如果增加 100% 的內存就可以進行垃圾收集(因為我們的內存消耗增加了)。
標記和清除
助手當分配內存但不調用垃圾收集器會發生什么?接下來,請求 /allocate-memory 端點,它和 /allocate-memory-and-gc 類似,但不調用runtime.GC()。
$ curl '127.0.0.1/allocate-memory?arrayLength=10000&bytesPerElement=10000'
Generated string array with 100000000 bytes of data
根據最近的觸發率,垃圾收集器應該還沒有啟動。但是,我們看到標記和清除仍然發生了:
gcDrain 標記在一段時間內完成的工作
sweepone 隨時間清除的內存頁
事實證明,垃圾收集器還有另一個技巧可以防止失控的內存增長。如果堆內存開始增長過快,垃圾收集器將對任何分配新內存的請求收“稅”。請求新堆分配的 Goroutines 將必須先協助垃圾收集,然后才能獲得它們所要求的東西。
這種“輔助”系統增加了分配的延遲,因此有助于系統抗壓(backpressure)。這非常重要,因為它解決了并發垃圾收集器可能引起的問題。在并發垃圾收集器中,內存分配在垃圾收集運行時仍進行內存分配。如果程序分配內存的速度快于垃圾收集器釋放它的速度,那么內存增長將是無限的。通過減慢(背壓)新內存的凈分配來幫助解決這個問題。
我們可以跟蹤 gcAssistAlloc1[14] 以查看此過程的運行情況。gcAssistAlloc1 接受一個名為 scanWork 的參數,它是請求的輔助工作量。
gcAllocAssist1 在一段時間內執行的輔助工作量
可以看到,gcAssistAlloc1 就是 mark 和 sweep 工作的來源。它收到了完成大約 30 萬個工作單元的請求。在之前的標記階段圖中,gcDrainN 在相同的時間段完成了大約 30 萬個標記工作單元(只是稍微分散一點)。
4、總結
還有很多關于 Go 中的內存分配和垃圾收集的知識!這里有一些其他的資源可以查看:
- Go 對小對象的特殊清除[15]
- 通過逃逸分析[16]查看對象是分配在堆還是棧
- sync.Pool[17],一種并發數據結構,通過池的方式共享對象來減少分配[18]
就像我們在本文例子中所做的那樣,創建 uprobes 通常最好在更高級別的 BPF 框架中完成。對于這篇文章,我使用了 Pixie 的 Dynamic Go 日志記錄[19]功能(仍處于 alpha 階段)。bpftrace[20] 是另一個創建 uprobes 的好工具。
檢查 Go 垃圾收集器行為的另一個不錯的選擇是 gc 跟蹤器。只需在你啟動程序時傳入 GODEBUG=gctrace=1。這會輸出有關垃圾收集器正在做什么的各種有用信息。
原文鏈接:https://blog.px.dev/go-garbage-collector/。
參考資料
參考資料
[1]這里: https://github.com/pixie-io/pixie-demos/tree/main/go-garbage-collector
[2]uprobes: https://jvns.ca/blog/2017/07/05/linux-tracing-systems/#uprobes
[3]GC: https://github.com/golang/go/blob/go1.16/src/runtime/mgc.go#L1126
[4]gcWaitOnMark: https://github.com/golang/go/blob/go1.16/src/runtime/mgc.go#L1201
[5]gcSweep: https://github.com/golang/go/blob/go1.16/src/runtime/mgc.go#L2170
[6]代碼: https://github.com/pixie-io/pixie-demos/tree/main/go-garbage-collector
[7]從源代碼: https://github.com/golang/go/blob/go1.16/src/runtime/mgc.go#L1126
[8]allocSpan: https://github.com/golang/go/blob/go1.16/src/runtime/mheap.go#L1124
[9]gcDrainN: https://github.com/golang/go/blob/go1.16/src/runtime/mgcmark.go#L1095
[10]sweepone: https://github.com/golang/go/blob/go1.16/src/runtime/mgcsweep.go#L188
[11]stopTheWorldWithSema: https://github.com/golang/go/blob/go1.16/src/runtime/proc.go#L1073
[12]startTheWorldWithSema: https://github.com/golang/go/blob/go1.16/src/runtime/proc.go#L1151
[13]pacer: https://go.googlesource.com/proposal/+/a216b56e743c5b6b300b3ef1673ee62684b5b63b/design/44167-gc-pacer-redesign.md
[14]gcAssistAlloc1: https://github.com/golang/go/blob/go1.16/src/runtime/mgcmark.go#L504
[15]特殊清除: https://github.com/golang/go/blob/master/src/runtime/mgc.go#L93
[16]逃逸分析: https://medium.com/a-journey-with-go/go-introduction-to-the-escape-analysis-f7610174e890
[17]sync.Pool: https://pkg.go.dev/sync#Pool
[18]減少分配: https://medium.com/swlh/go-the-idea-behind-sync-pool-32da5089df72
[19]Dynamic Go 日志記錄: https://docs.px.dev/tutorials/custom-data/dynamic-go-logging/
[20]bpftrace: https://github.com/iovisor/bpftrace
本文轉載自微信公眾號「幽鬼」,可以通過以下二維碼關注。轉載本文請聯系幽鬼公眾號。