新聞中心
對程序員來說,內(nèi)存管理是很重要的。編程語言按內(nèi)存管理方式一般可以分為手動內(nèi)存管理和自動內(nèi)存管理。手動內(nèi)存管理典型代表有 C、C++;自動內(nèi)存管理代表有 Java、C# 等。通常,自動內(nèi)存管理即自帶垃圾收集器,即 GC(當然,Rust 另辟蹊徑,它既沒有 GC,也不需要手動內(nèi)存管理,感興趣的可以了解下)。Go 語言也采用了 GC 的方式管理內(nèi)存,雖然 Gopher 不需要手動管理內(nèi)存了,但了解 Go 如何分配和釋放內(nèi)存可以讓我們編寫更好、更高效的應用程序。垃圾收集器是這個難題的關鍵部分。本文就探討 Go 中的 GC。

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


咨詢
建站咨詢
