通過 eBPF 深入探究 Go GC
大家好,我是程序員幽鬼。
對程序員來說,內(nèi)存管理是很重要的。編程語言按內(nèi)存管理方式一般可以分為手動內(nèi)存管理和自動內(nèi)存管理。手動內(nèi)存管理典型代表有 C、C++;自動內(nèi)存管理代表有 Java、C# 等。通常,自動內(nèi)存管理即自帶垃圾收集器,即 GC(當(dāng)然,Rust 另辟蹊徑,它既沒有 GC,也不需要手動內(nèi)存管理,感興趣的可以了解下)。Go 語言也采用了 GC 的方式管理內(nèi)存,雖然 Gopher 不需要手動管理內(nèi)存了,但了解 Go 如何分配和釋放內(nèi)存可以讓我們編寫更好、更高效的應(yīng)用程序。垃圾收集器是這個難題的關(guān)鍵部分。本文就探討 Go 中的 GC。
為了更好地理解垃圾收集器的工作原理,我決定在實(shí)時應(yīng)用程序上跟蹤它的底層行為。本文將使用 eBPF uprobes 檢測 Go 垃圾收集器。這篇文章的源代碼在這里[1]。
1、前提知識
在深入研究之前,讓我們快速了解一下 uprobes、垃圾收集器的設(shè)計(jì)以及我們將使用的演示應(yīng)用程序。
為什么用 uprobes?
uprobes[2] 很酷,因?yàn)樗鼈冏屛覀儫o需修改代碼即可動態(tài)收集新信息。當(dāng)你不能或不想重新部署你的應(yīng)用程序時,這會非常有用。
函數(shù)參數(shù)、返回值、延遲和時間戳都可以通過 uprobes 收集。在這篇文章中,我將把 uprobes 部署到 Go 垃圾收集器的關(guān)鍵函數(shù)上。這讓我們能看到它在正在運(yùn)行的應(yīng)用程序中的實(shí)際表現(xiàn)。
uprobes 可以跟蹤延遲、時間戳、參數(shù)和函數(shù)的返回值片
注意:這篇文章使用的 Go 版本是 1.16。我將在 Go 運(yùn)行時中跟蹤私有函數(shù),因此這些功能在 Go 的后續(xù)版本中可能會發(fā)生變化。
垃圾回收的階段
Go 使用并發(fā)標(biāo)記和清除垃圾收集器。對于那些不熟悉這些術(shù)語的人,閱讀以下內(nèi)容,方便你理解本文其他內(nèi)容。
- ??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 的垃圾收集器被稱為并發(fā)的,因?yàn)樗梢园踩嘏c主程序并行運(yùn)行。換句話說,它不需要停止你程序的執(zhí)行來完成它的工作(稍后會詳細(xì)介紹)。
垃圾收集有兩個主要階段:
標(biāo)記(Mark)階段:識別并標(biāo)記程序不再需要的對象。
清除(Sweep)階段:對于標(biāo)記階段標(biāo)記為“無法訪問”的每個對象,釋放內(nèi)存以供其他地方使用。
一種節(jié)點(diǎn)著色算法。黑色表示仍在使用中。白色表示已準(zhǔn)備好清理。灰色表示仍然需要分類為黑色或白色
一個簡單的演示應(yīng)用程序
這是一個簡單的端點(diǎn)(endpoint),我將使用它來觸發(fā)垃圾收集器。它創(chuàng)建一個可變大小的字符串?dāng)?shù)組,然后通過調(diào)用 runtime.GC() 來啟動垃圾收集器。
實(shí)際代碼中,你不需要手動調(diào)用垃圾收集器,因?yàn)?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、跟蹤垃圾收集的主要階段
我們已經(jīng)了解了 uprobes 和 Go 垃圾收集器的基礎(chǔ)知識,接下來深入觀察它的行為。
跟蹤 runtime.GC()
首先,我們計(jì)劃在 Go 的 runtime 庫中的以下函數(shù)中添加 uprobes:
函數(shù) | 描述 |
GC[3] | 調(diào)用 GC |
gcWaitOnMark[4] | 等待標(biāo)記階段完成 |
gcSweep[5] | 執(zhí)行清除階段 |
(如果你有興趣了解 uprobes 是如何生成的,這里是代碼[6]。)
部署 uprobes 后,點(diǎn)擊端點(diǎn)并生成了一個包含 10 個字符串的數(shù)組,每個字符串為 20 個字節(jié)。
$ 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 會觀察到以下事件:
在運(yùn)行垃圾收集器后,為 GC、gcWaitOnMark 和 gcSweep 收集事件
從源代碼[7]來看這是有道理的——gcWaitOnMark被調(diào)用兩次,一次是在開始下一個循環(huán)之前對前一個循環(huán)進(jìn)行驗(yàn)證。標(biāo)記階段觸發(fā)清除階段。
接下來,使用各種輸入請求 /allocate-memory-and-run-gc 端點(diǎn)對 runtime.GC 后的延遲進(jìn)行了一些測量。
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 |
跟蹤標(biāo)記和清除階段
雖然這是一個很好的高級視圖,但我們可以使用更多細(xì)節(jié)。接下來探索一些用于內(nèi)存分配、標(biāo)記和清除的輔助函數(shù),以獲取下一級信息。
這些輔助函數(shù)有參數(shù)或返回值,可以幫助我們更好地可視化正在發(fā)生的事情(例如分配的內(nèi)存頁)。
函數(shù) | 描述 | 捕獲的信息 |
allocSpan[8] | 分配新內(nèi)存 | 分配的內(nèi)存頁 |
gcDrainN[9] | 執(zhí)行 N 個單位的標(biāo)記工作 | 完成的標(biāo)記工作單位 |
sweepone[10] | 從 span 中清除內(nèi)存 | 清除的內(nèi)存頁 |
$ 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
在以更大的負(fù)載命中垃圾收集器之后,以下是原始結(jié)果:
調(diào)用垃圾收集器后,allocSpan、gcDrainN 和 sweepone 收集的事件示例
繪制為時間序列更容易解釋:
allocSpan 隨時間分配的內(nèi)存頁
gcDrain 標(biāo)記在一段時間內(nèi)完成的工作
sweepone 隨時間清除的內(nèi)存頁
現(xiàn)在我們可以看到發(fā)生了什么:
- Go 分配了幾千內(nèi)存頁,這是正常的,因?yàn)槲覀冎苯酉蚨阎刑砑恿舜蠹s 80MB 的字符串。
- 標(biāo)記工作拉開了序幕(注意它的單位不是頁,而是標(biāo)記工作單位)
- 有標(biāo)記的內(nèi)存頁被清除器清除。(這應(yīng)該是所有內(nèi)存頁,因?yàn)樵谡{(diào)用完成后我們不會重用字符串?dāng)?shù)組)。
追蹤 Stop The World 事件
“Stopping the world”是指垃圾收集器暫時停止除自身之外的一切,以安全地修改狀態(tài)。我們通常更喜歡最小化 STW 階段,因?yàn)?STW 會減慢我們的程序速度(通常是在最不方便的時候……)。
一些垃圾收集器會在垃圾收集運(yùn)行的整個過程中 stop the world。這些是“非并發(fā)”垃圾收集器。雖然 Go 的垃圾收集器在很大程度上是并發(fā)的,但我們可以從代碼中看到,它在技術(shù)上確實(shí)在兩個地方 STW 了。
我們跟蹤以下函數(shù):
函數(shù) | 描述 |
stopTheWorldWithSema[11] | 停止其他 goroutine 直到? |
startTheWorldWithSema[12] | 啟動暫停的 goroutine |
再次觸發(fā) 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
這次產(chǎn)生了如下事件:
生成啟動和停止 STW 事件
我們可以從GC事件中看到垃圾收集需要 3.1 毫秒才能完成。在我檢查了確切的時間戳之后,事實(shí)證明 STW 第一次停止了 300 μs,第二次停止了 365 μs。換句話說,~80%垃圾收集是同時執(zhí)行的。當(dāng)垃圾收集器在實(shí)際內(nèi)存壓力下自動調(diào)用時,我們預(yù)計(jì)這個比率會變得更好。
為什么 Go 垃圾收集器需要 STW?
1st Stop The World(標(biāo)記階段之前):設(shè)置狀態(tài)并打開寫屏障。寫屏障確保在 GC 運(yùn)行時正確跟蹤新的寫入(這樣它們就不會被意外釋放或保留)。
2nd Stop The World(標(biāo)記階段之后):清理標(biāo)記狀態(tài)并關(guān)閉寫屏障。
3、垃圾收集器如何調(diào)整自己的速度?
知道何時運(yùn)行垃圾收集是 Go 等并發(fā)垃圾收集器的重要考慮因素。
早期的垃圾收集器被設(shè)計(jì)為一旦達(dá)到一定的內(nèi)存消耗水平就會啟動。如果垃圾收集器是非并發(fā)的,這可以正常工作。但是使用并發(fā)垃圾收集器,主程序在垃圾收集期間仍在運(yùn)行 —— 因此可能仍在進(jìn)行內(nèi)存分配。
這意味著如果太晚運(yùn)行垃圾收集器,可能會超出內(nèi)存目標(biāo)。(Go 也不能一直運(yùn)行垃圾收集 —— GC 會從主應(yīng)用程序中奪走資源和性能。)
Go 的垃圾收集器使用 pacer[13] 來估計(jì)垃圾收集的最佳時間。這有助于 Go 滿足其內(nèi)存和 CPU 目標(biāo),而不會犧牲不必要的應(yīng)用程序性能。
pacer,可以理解為定速裝置
觸發(fā)率
Go 的并發(fā)垃圾收集器依賴于一個 pacer 來確定何時進(jìn)行垃圾收集。但它是如何做出這個決定的呢?
每次調(diào)用垃圾收集器時,pacer 都會更新其內(nèi)部目標(biāo),即下次應(yīng)該何時運(yùn)行 GC。這個目標(biāo)稱為觸發(fā)率。觸發(fā)率0.6意味著一旦堆大小增加 60%,系統(tǒng)應(yīng)該運(yùn)行垃圾收集。觸發(fā)率是CPU、內(nèi)存和其他因素共同決定的數(shù)字。
讓我們看看當(dāng)我們一次分配大量內(nèi)存時,垃圾收集器的觸發(fā)率是如何變化的。我們可以通過跟蹤函數(shù)來獲取觸發(fā)率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
觸發(fā)率隨時間的變化
從圖中可以看到,最初,觸發(fā)率相當(dāng)高。運(yùn)行時已經(jīng)確定,在程序使用 450% 或更多內(nèi)存之前,不需要進(jìn)行垃圾收集。這是有道理的,因?yàn)閼?yīng)用程序沒有做太多事情(并且沒有使用很多堆)。
然而,一旦我們請求端點(diǎn)進(jìn)行 ~81MB 堆分配時,觸發(fā)率迅速下降到 ~1?,F(xiàn)在如果增加 100% 的內(nèi)存就可以進(jìn)行垃圾收集(因?yàn)槲覀兊膬?nèi)存消耗增加了)。
標(biāo)記和清除
助手當(dāng)分配內(nèi)存但不調(diào)用垃圾收集器會發(fā)生什么?接下來,請求 /allocate-memory 端點(diǎn),它和 /allocate-memory-and-gc 類似,但不調(diào)用runtime.GC()。
$ curl '127.0.0.1/allocate-memory?arrayLength=10000&bytesPerElement=10000'
Generated string array with 100000000 bytes of data
根據(jù)最近的觸發(fā)率,垃圾收集器應(yīng)該還沒有啟動。但是,我們看到標(biāo)記和清除仍然發(fā)生了:
gcDrain 標(biāo)記在一段時間內(nèi)完成的工作
sweepone 隨時間清除的內(nèi)存頁
事實(shí)證明,垃圾收集器還有另一個技巧可以防止失控的內(nèi)存增長。如果堆內(nèi)存開始增長過快,垃圾收集器將對任何分配新內(nèi)存的請求收“稅”。請求新堆分配的 Goroutines 將必須先協(xié)助垃圾收集,然后才能獲得它們所要求的東西。
這種“輔助”系統(tǒng)增加了分配的延遲,因此有助于系統(tǒng)抗壓(backpressure)。這非常重要,因?yàn)樗鉀Q了并發(fā)垃圾收集器可能引起的問題。在并發(fā)垃圾收集器中,內(nèi)存分配在垃圾收集運(yùn)行時仍進(jìn)行內(nèi)存分配。如果程序分配內(nèi)存的速度快于垃圾收集器釋放它的速度,那么內(nèi)存增長將是無限的。通過減慢(背壓)新內(nèi)存的凈分配來幫助解決這個問題。
我們可以跟蹤 gcAssistAlloc1[14] 以查看此過程的運(yùn)行情況。gcAssistAlloc1 接受一個名為 scanWork 的參數(shù),它是請求的輔助工作量。
gcAllocAssist1 在一段時間內(nèi)執(zhí)行的輔助工作量
可以看到,gcAssistAlloc1 就是 mark 和 sweep 工作的來源。它收到了完成大約 30 萬個工作單元的請求。在之前的標(biāo)記階段圖中,gcDrainN 在相同的時間段完成了大約 30 萬個標(biāo)記工作單元(只是稍微分散一點(diǎn))。
4、總結(jié)
還有很多關(guān)于 Go 中的內(nèi)存分配和垃圾收集的知識!這里有一些其他的資源可以查看:
- Go 對小對象的特殊清除[15]
- 通過逃逸分析[16]查看對象是分配在堆還是棧
- sync.Pool[17],一種并發(fā)數(shù)據(jù)結(jié)構(gòu),通過池的方式共享對象來減少分配[18]
就像我們在本文例子中所做的那樣,創(chuàng)建 uprobes 通常最好在更高級別的 BPF 框架中完成。對于這篇文章,我使用了 Pixie 的 Dynamic Go 日志記錄[19]功能(仍處于 alpha 階段)。bpftrace[20] 是另一個創(chuàng)建 uprobes 的好工具。
檢查 Go 垃圾收集器行為的另一個不錯的選擇是 gc 跟蹤器。只需在你啟動程序時傳入 GODEBUG=gctrace=1。這會輸出有關(guān)垃圾收集器正在做什么的各種有用信息。
原文鏈接: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
本文轉(zhuǎn)載自微信公眾號「幽鬼」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系幽鬼公眾號。