Go GC 20 问
本文作者歐長坤,德國慕尼黑大學在讀博士,Go/etcd/Tensorflow contributor,開源書籍《Go 語言原本》作者,《Go 夜讀》SIG 成員/講師,對 Go 有很深的研究。Github:@changkun,https://changkun.de。
本文首發于 Github 開源項目 《Go-Questions》,點擊閱讀原文直達。全文不計代碼,共 1.7w+ 字,建議收藏后精讀。另外,本文結尾有彩蛋。
按慣例,貼上本文的目錄:
本文寫于 Go 1.14 beta1,當文中提及目前、目前版本等字眼時均指 Go 1.14,此外,文中所有 go 命令版本均為 Go 1.14。
GC 的認識
1. 什么是 GC,有什么作用?
GC,全稱 GarbageCollection,即垃圾回收,是一種自動內存管理的機制。
當程序向操作系統申請的內存不再需要時,垃圾回收主動將其回收并供其他代碼進行內存申請時候復用,或者將其歸還給操作系統,這種針對內存級別資源的自動回收過程,即為垃圾回收。而負責垃圾回收的程序組件,即為垃圾回收器。
垃圾回收其實一個完美的 “Simplicity is Complicated” 的例子。一方面,程序員受益于 GC,無需操心、也不再需要對內存進行手動的申請和釋放操作,GC 在程序運行時自動釋放殘留的內存。另一方面,GC 對程序員幾乎不可見,僅在程序需要進行特殊優化時,通過提供可調控的 API,對 GC 的運行時機、運行開銷進行把控的時候才得以現身。
通常,垃圾回收器的執行過程被劃分為兩個半獨立的組件:
賦值器(Mutator):這一名稱本質上是在指代用戶態的代碼。因為對垃圾回收器而言,用戶態的代碼僅僅只是在修改對象之間的引用關系,也就是在對象圖(對象之間引用關系的一個有向圖)上進行操作。
回收器(Collector):負責執行垃圾回收的代碼。
2. 根對象到底是什么?
根對象在垃圾回收的術語中又叫做根集合,它是垃圾回收器在標記過程時最先檢查的對象,包括:
全局變量:程序在編譯期就能確定的那些存在于程序整個生命周期的變量。
執行棧:每個 goroutine 都包含自己的執行棧,這些執行棧上包含棧上的變量及指向分配的堆內存區塊的指針。
寄存器:寄存器的值可能表示一個指針,參與計算的這些指針可能指向某些賦值器分配的堆內存區塊。
3. 常見的 GC 實現方式有哪些?Go 語言的 GC 使用的是什么?
所有的 GC 算法其存在形式可以歸結為追蹤(Tracing)和引用計數(Reference Counting)這兩種形式的混合運用。
追蹤式 GC
從根對象出發,根據對象之間的引用信息,一步步推進直到掃描完畢整個堆并確定需要保留的對象,從而回收所有可回收的對象。Go、 Java、V8 對 JavaScript 的實現等均為追蹤式 GC。
引用計數式 GC
每個對象自身包含一個被引用的計數器,當計數器歸零時自動得到回收。因為此方法缺陷較多,在追求高性能時通常不被應用。Python、Objective-C 等均為引用計數式 GC。
目前比較常見的 GC 實現方式包括:
追蹤式,分為多種不同類型,例如:
標記清掃:從根對象出發,將確定存活的對象進行標記,并清掃可以回收的對象。
標記整理:為了解決內存碎片問題而提出,在標記過程中,將對象盡可能整理到一塊連續的內存上。
增量式:將標記與清掃的過程分批執行,每次執行很小的部分,從而增量的推進垃圾回收,達到近似實時、幾乎無停頓的目的。
增量整理:在增量式的基礎上,增加對對象的整理過程。
分代式:將對象根據存活時間的長短進行分類,存活時間小于某個值的為年輕代,存活時間大于某個值的為老年代,永遠不會參與回收的對象為永久代。并根據分代假設(如果一個對象存活時間不長則傾向于被回收,如果一個對象已經存活很長時間則傾向于存活更長時間)對對象進行回收。
引用計數:根據對象自身的引用計數來回收,當引用計數歸零時立即回收。
關于各類方法的詳細介紹及其實現不在本文中詳細討論。對于 Go 而言,Go 的 GC 目前使用的是無分代(對象沒有代際之分)、不整理(回收過程中不對對象進行移動與整理)、并發(與用戶代碼并發執行)的三色標記清掃算法。原因在于:
對象整理的優勢是解決內存碎片問題以及“允許”使用順序內存分配器。但 Go 運行時的分配算法基于 tcmalloc,基本上沒有碎片問題。并且順序內存分配器在多線程的場景下并不適用。Go 使用的是基于 tcmalloc 的現代內存分配算法,對對象進行整理不會帶來實質性的性能提升。
分代 GC 依賴分代假設,即 GC 將主要的回收目標放在新創建的對象上(存活時間短,更傾向于被回收),而非頻繁檢查所有對象。但 Go 的編譯器會通過逃逸分析將大部分新生對象存儲在棧上(棧直接被回收),只有那些需要長期存在的對象才會被分配到需要進行垃圾回收的堆中。也就是說,分代 GC 回收的那些存活時間短的對象在 Go 中是直接被分配到棧上,當 goroutine 死亡后棧也會被直接回收,不需要 GC 的參與,進而分代假設并沒有帶來直接優勢。并且 Go 的垃圾回收器與用戶代碼并發執行,使得 STW 的時間與對象的代際、對象的 size 沒有關系。Go 團隊更關注于如何更好地讓 GC 與用戶代碼并發執行(使用適當的 CPU 來執行垃圾回收),而非減少停頓時間這一單一目標上。
4. 三色標記法是什么?
理解三色標記法的關鍵是理解對象的三色抽象以及波面(wavefront)推進這兩個概念。三色抽象只是一種描述追蹤式回收器的方法,在實踐中并沒有實際含義,它的重要作用在于從邏輯上嚴密推導標記清理這種垃圾回收方法的正確性。也就是說,當我們談及三色標記法時,通常指標記清掃的垃圾回收。
從垃圾回收器的視角來看,三色抽象規定了三種不同類型的對象,并用不同的顏色相稱:
白色對象(可能死亡):未被回收器訪問到的對象。在回收開始階段,所有對象均為白色,當回收結束后,白色對象均不可達。
灰色對象(波面):已被回收器訪問到的對象,但回收器需要對其中的一個或多個指針進行掃描,因為他們可能還指向白色對象。
黑色對象(確定存活):已被回收器訪問到的對象,其中所有字段都已被掃描,黑色對象中任何一個指針都不可能直接指向白色對象。
這樣三種不變性所定義的回收過程其實是一個波面不斷前進的過程,這個波面同時也是黑色對象和白色對象的邊界,灰色對象就是這個波面。
當垃圾回收開始時,只有白色對象。隨著標記過程開始進行時,灰色對象開始出現(著色),這時候波面便開始擴大。當一個對象的所有子節點均完成掃描時,會被著色為黑色。當整個堆遍歷完成時,只剩下黑色和白色對象,這時的黑色對象為可達對象,即存活;而白色對象為不可達對象,即死亡。這個過程可以視為以灰色對象為波面,將黑色對象和白色對象分離,使波面不斷向前推進,直到所有可達的灰色對象都變為黑色對象為止的過程。如下圖所示:
圖中展示了根對象、可達對象、不可達對象,黑、灰、白對象以及波面之間的關系。
5. STW 是什么意思?
STW 是 StoptheWorld 的縮寫,即萬物靜止,是指在垃圾回收過程中為了保證實現的正確性、防止無止境的內存增長等問題而不可避免的需要停止賦值器進一步操作對象圖的一段過程。
在這個過程中整個用戶代碼被停止或者放緩執行, STW 越長,對用戶代碼造成的影響(例如延遲)就越大,早期 Go 對垃圾回收器的實現中 STW 長達幾百毫秒,對時間敏感的實時通信等應用程序會造成巨大的影響。我們來看一個例子:
package mainimport ("runtime""time" )func main() {go func() {for {}}()time.Sleep(time.Millisecond)runtime.GC()println("OK") }上面的這個程序在 Go 1.14 以前永遠都不會輸出 OK,其罪魁禍首是 STW 無限制的被延長。
盡管 STW 如今已經優化到了半毫秒級別以下,但這個程序被卡死原因在于仍然是 STW 導致的。原因在于,GC 在進入 STW 時,需要等待讓所有的用戶態代碼停止,但是 for{} 所在的 goroutine 永遠都不會被中斷,從而停留在 STW 階段。實際實踐中也是如此,當程序的某個 goroutine 長時間得不到停止,強行拖慢 STW,這種情況下造成的影響(卡死)是非常可怕的。好在自 Go 1.14 之后,這類 goroutine 能夠被異步地搶占,從而使得 STW 的時間如同普通程序那樣,不會超過半個毫秒,程序也不會因為僅僅等待一個 goroutine 的停止而停頓在 STW 階段。
6. 如何觀察 Go GC?
我們以下面的程序為例,先使用四種不同的方式來介紹如何觀察 GC,并在后面的問題中通過幾個詳細的例子再來討論如何優化 GC。
package mainfunc allocate() {_ = make([]byte, 1<<20) }func main() {for n := 1; n < 100000; n++ {allocate()} }方式1:?GODEBUG=gctrace=1
我們首先可以通過:
$ go build -o main $ GODEBUG=gctrace=1 ./maingc 1 @0.000s 2%: 0.009+0.23+0.004 ms clock, 0.11+0.083/0.019/0.14+0.049 ms cpu, 4->6->2 MB, 5 MB goal, 12 P scvg: 8 KB released scvg: inuse: 3, idle: 60, sys: 63, released: 57, consumed: 6 (MB) gc 2 @0.001s 2%: 0.018+1.1+0.029 ms clock, 0.22+0.047/0.074/0.048+0.34 ms cpu, 4->7->3 MB, 5 MB goal, 12 P scvg: inuse: 3, idle: 60, sys: 63, released: 56, consumed: 7 (MB) gc 3 @0.003s 2%: 0.018+0.59+0.011 ms clock, 0.22+0.073/0.008/0.042+0.13 ms cpu, 5->6->1 MB, 6 MB goal, 12 P scvg: 8 KB released scvg: inuse: 2, idle: 61, sys: 63, released: 56, consumed: 7 (MB) gc 4 @0.003s 4%: 0.019+0.70+0.054 ms clock, 0.23+0.051/0.047/0.085+0.65 ms cpu, 4->6->2 MB, 5 MB goal, 12 P scvg: 8 KB released scvg: inuse: 3, idle: 60, sys: 63, released: 56, consumed: 7 (MB) scvg: 8 KB released scvg: inuse: 4, idle: 59, sys: 63, released: 56, consumed: 7 (MB) gc 5 @0.004s 12%: 0.021+0.26+0.49 ms clock, 0.26+0.046/0.037/0.11+5.8 ms cpu, 4->7->3 MB, 5 MB goal, 12 P scvg: inuse: 5, idle: 58, sys: 63, released: 56, consumed: 7 (MB) gc 6 @0.005s 12%: 0.020+0.17+0.004 ms clock, 0.25+0.080/0.070/0.053+0.051 ms cpu, 5->6->1 MB, 6 MB goal, 12 P scvg: 8 KB released scvg: inuse: 1, idle: 62, sys: 63, released: 56, consumed: 7 (MB)在這個日志中可以觀察到兩類不同的信息:
gc 1 @0.000s 2%: 0.009+0.23+0.004 ms clock, 0.11+0.083/0.019/0.14+0.049 ms cpu, 4->6->2 MB, 5 MB goal, 12 P gc 2 @0.001s 2%: 0.018+1.1+0.029 ms clock, 0.22+0.047/0.074/0.048+0.34 ms cpu, 4->7->3 MB, 5 MB goal, 12 P ...以及:
scvg: 8 KB released scvg: inuse: 3, idle: 60, sys: 63, released: 57, consumed: 6 (MB) scvg: inuse: 3, idle: 60, sys: 63, released: 56, consumed: 7 (MB) ...對于用戶代碼向運行時申請內存產生的垃圾回收:
gc 2 @0.001s 2%: 0.018+1.1+0.029 ms clock, 0.22+0.047/0.074/0.048+0.34 ms cpu, 4->7->3 MB, 5 MB goal, 12 P含義由下表所示:
| gc 2 | 第二個 GC 周期 |
| 0.001 | 程序開始后的 0.001 秒 |
| 2% | 該 GC 周期中 CPU 的使用率 |
| 0.018 | 標記開始時, STW 所花費的時間(wall clock) |
| 1.1 | 標記過程中,并發標記所花費的時間(wall clock) |
| 0.029 | 標記終止時, STW 所花費的時間(wall clock) |
| 0.22 | 標記開始時, STW 所花費的時間(cpu time) |
| 0.047 | 標記過程中,標記輔助所花費的時間(cpu time) |
| 0.074 | 標記過程中,并發標記所花費的時間(cpu time) |
| 0.048 | 標記過程中,GC 空閑的時間(cpu time) |
| 0.34 | 標記終止時, STW 所花費的時間(cpu time) |
| 4 | 標記開始時,堆的大小的實際值 |
| 7 | 標記結束時,堆的大小的實際值 |
| 3 | 標記結束時,標記為存活的對象大小 |
| 5 | 標記結束時,堆的大小的預測值 |
| 12 | P 的數量 |
wall clock 是指開始執行到完成所經歷的實際時間,包括其他程序和本程序所消耗的時間;cpu time 是指特定程序使用 CPU 的時間;他們存在以下關系:
wall clock < cpu time: 充分利用多核
wall clock ≈ cpu time: 未并行執行
wall clock > cpu time: 多核優勢不明顯
對于運行時向操作系統申請內存產生的垃圾回收(向操作系統歸還多余的內存):
scvg: 8 KB released scvg: inuse: 3, idle: 60, sys: 63, released: 57, consumed: 6 (MB)含義由下表所示:
| 8 KB released | 向操作系統歸還了 8 KB 內存 |
| 3 | 已經分配給用戶代碼、正在使用的總內存大小 (MB)。MB used or partially used spans |
| 60 | 空閑以及等待歸還給操作系統的總內存大小(MB)。MB spans pending scavenging |
| 63 | 通知操作系統中保留的內存大小(MB)MB mapped from the system |
| 57 | 已經歸還給操作系統的(或者說還未正式申請)的內存大小(MB)。MB released to the system |
| 6 | 已經從操作系統中申請的內存大小(MB)。MB allocated from the system |
方式2:?go tool trace
go tool trace 的主要功能是將統計而來的信息以一種可視化的方式展示給用戶。要使用此工具,可以通過調用 trace API:
package mainfunc main() {f, _ := os.Create("trace.out")defer f.Close()trace.Start(f)defer trace.Stop()(...) }并通過:
$ go tool trace trace.out 2019/12/30 15:50:33 Parsing trace... 2019/12/30 15:50:38 Splitting trace... 2019/12/30 15:50:45 Opening browser. Trace viewer is listening on http://127.0.0.1:51839命令來啟動可視化界面:
選擇第一個鏈接可以獲得如下圖示:
右上角的問號可以打開幫助菜單,主要使用方式包括:
w/s 鍵可以用于放大或者縮小視圖
a/d 鍵可以用于左右移動
方式3:?debug.ReadGCStats
此方式可以通過代碼的方式來直接實現對感興趣指標的監控,例如我們希望每隔一秒鐘監控一次 GC 的狀態:
func printGCStats() {t := time.NewTicker(time.Second)s := debug.GCStats{}for {select {case <-t.C:debug.ReadGCStats(&s)fmt.Printf("gc %d last@%v, PauseTotal %v\n", s.NumGC, s.LastGC, s.PauseTotal)}} } func main() {go printGCStats()(...) }我們能夠看到如下輸出:
$ go run main.gogc 4954 last@2019-12-30 15:19:37.505575 +0100 CET, PauseTotal 29.901171ms gc 9195 last@2019-12-30 15:19:38.50565 +0100 CET, PauseTotal 77.579622ms gc 13502 last@2019-12-30 15:19:39.505714 +0100 CET, PauseTotal 128.022307ms gc 17555 last@2019-12-30 15:19:40.505579 +0100 CET, PauseTotal 182.816528ms gc 21838 last@2019-12-30 15:19:41.505595 +0100 CET, PauseTotal 246.618502ms方式4:?runtime.ReadMemStats
除了使用 debug 包提供的方法外,還可以直接通過運行時的內存相關的 API 進行監控:
func printMemStats() {t := time.NewTicker(time.Second)s := runtime.MemStats{}for {select {case <-t.C:runtime.ReadMemStats(&s)fmt.Printf("gc %d last@%v, next_heap_size@%vMB\n", s.NumGC, time.Unix(int64(time.Duration(s.LastGC).Seconds()), 0), s.NextGC/(1<<20))}} } func main() {go printMemStats()(...) }運行:
$ go run main.gogc 4887 last@2019-12-30 15:44:56 +0100 CET, next_heap_size@4MB gc 10049 last@2019-12-30 15:44:57 +0100 CET, next_heap_size@4MB gc 15231 last@2019-12-30 15:44:58 +0100 CET, next_heap_size@4MB gc 20378 last@2019-12-30 15:44:59 +0100 CET, next_heap_size@6MB當然,后兩種方式能夠監控的指標很多,讀者可以自行查看 debug.GCStats 和runtime.MemStats 的字段,這里不再贅述。
7. 有了 GC,為什么還會發生內存泄露?
在一個具有 GC 的語言中,我們常說的內存泄漏,用嚴謹的話來說應該是:預期的能很快被釋放的內存由于附著在了長期存活的內存上、或生命期意外地被延長,導致預計能夠立即回收的內存而長時間得不到回收。
在 Go 中,由于 goroutine 的存在,所謂的內存泄漏除了附著在長期對象上之外,還存在多種不同的形式。
形式1:預期能被快速釋放的內存因被根對象引用而沒有得到迅速釋放
當有一個全局對象時,可能不經意間將某個變量附著在其上,且忽略的將其進行釋放,則該內存永遠不會得到釋放。例如:
var cache = map[interface{}]interface{}{}func keepalloc() {for i := 0; i < 10000; i++ {m := make([]byte, 1<<10)cache[i] = m} }形式2:goroutine 泄漏
Goroutine 作為一種邏輯上理解的輕量級線程,需要維護執行用戶代碼的上下文信息。在運行過程中也需要消耗一定的內存來保存這類信息,而這些內存在目前版本的 Go 中是不會被釋放的。因此,如果一個程序持續不斷地產生新的 goroutine、且不結束已經創建的 goroutine 并復用這部分內存,就會造成內存泄漏的現象,例如:
func keepalloc2() {for i := 0; i < 100000; i++ {go func() {select {}}()} }驗證
我們可以通過如下形式來調用上述兩個函數:
package mainimport ("os""runtime/trace" )func main() {f, _ := os.Create("trace.out")defer f.Close()trace.Start(f)defer trace.Stop()keepalloc()keepalloc2() }運行程序:
go run main.go會看到程序中生成了 trace.out 文件,我們可以使用 go tool trace trace.out 命令得到下圖:
可以看到,途中的 Heap 在持續增長,沒有內存被回收,產生了內存泄漏的現象。
值得一提的是,這種形式的 goroutine 泄漏還可能由 channel 泄漏導致。而 channel 的泄漏本質上與 goroutine 泄漏存在直接聯系。Channel 作為一種同步原語,會連接兩個不同的 goroutine,如果一個 goroutine 嘗試向一個沒有接收方的無緩沖 channel 發送消息,則該 goroutine 會被永久的休眠,整個 goroutine 及其執行棧都得不到釋放,例如:
var ch = make(chan struct{})func keepalloc3() {for i := 0; i < 100000; i++ {// 沒有接收方,goroutine 會一直阻塞go func() { ch <- struct{}{} }()} }8. 并發標記清除法的難點是什么?
在沒有用戶態代碼并發修改 三色抽象的情況下,回收可以正常結束。但是并發回收的根本問題在于,用戶態代碼在回收過程中會并發地更新對象圖,從而造成賦值器和回收器可能對對象圖的結構產生不同的認知。這時以一個固定的三色波面作為回收過程前進的邊界則不再合理。
我們不妨考慮賦值器寫操作的例子:
| 1 | shade(A, gray) | 回收器:根對象的子節點著色為灰色對象 | |
| 2 | shade(C, black) | 回收器:當所有子節點著色為灰色后,將節點著為黑色 | |
| 3 | C.ref3 = C.ref2.ref1 | 賦值器:并發的修改了 C 的子節點 | |
| 4 | A.ref1 = nil | 賦值器:并發的修改了 A 的子節點 | |
| 5 | shade(A.ref1, gray) | 回收器:進一步灰色對象的子節點并著色為灰色對象,這時由于?A.ref1?為?nil,什么事情也沒有發生 | |
| 6 | shade(A, black) | 回收器:由于所有子節點均已標記,回收器也不會重新掃描已經被標記為黑色的對象,此時 A 被著色為黑色,?scan(A)?什么也不會發生,進而 B 在此次回收過程中永遠不會被標記為黑色,進而錯誤地被回收 |
初始狀態:假設某個黑色對象 C 指向某個灰色對象 A ,而 A 指向白色對象 B;
C.ref3 = C.ref2.ref1:賦值器并發地將黑色對象 C 指向(ref3)了白色對象 B;
A.ref1 = nil:移除灰色對象 A 對白色對象 B 的引用(ref2);
最終狀態:在繼續掃描的過程中,白色對象 B 永遠不會被標記為黑色對象了(回收器不會重新掃描黑色對象),進而對象 B 被錯誤地回收。
總而言之,并發標記清除中面臨的一個根本問題就是如何保證標記與清除過程的正確性。
9. 什么是寫屏障、混合寫屏障,如何實現?
要講清楚寫屏障,就需要理解三色標記清除算法中的強弱不變性以及賦值器的顏色,理解他們需要一定的抽象思維。寫屏障是一個在并發垃圾回收器中才會出現的概念,垃圾回收器的正確性體現在:不應出現對象的丟失,也不應錯誤的回收還不需要回收的對象。
可以證明,當以下兩個條件同時滿足時會破壞垃圾回收器的正確性:
條件 1: 賦值器修改對象圖,導致某一黑色對象引用白色對象;
條件 2: 從灰色對象出發,到達白色對象的、未經訪問過的路徑被賦值器破壞。
只要能夠避免其中任何一個條件,則不會出現對象丟失的情況,因為:
如果條件 1 被避免,則所有白色對象均被灰色對象引用,沒有白色對象會被遺漏;
如果條件 2 被避免,即便白色對象的指針被寫入到黑色對象中,但從灰色對象出發,總存在一條沒有訪問過的路徑,從而找到到達白色對象的路徑,白色對象最終不會被遺漏。
我們不妨將三色不變性所定義的波面根據這兩個條件進行削弱:
當滿足原有的三色不變性定義(或上面的兩個條件都不滿足時)的情況稱為強三色不變性(strong tricolor invariant)
當賦值器令黑色對象引用白色對象時(滿足條件 1 時)的情況稱為弱三色不變性(weak tricolor invariant)
當賦值器進一步破壞灰色對象到達白色對象的路徑時(進一步滿足條件 2 時),即打破弱三色不變性,也就破壞了回收器的正確性;或者說,在破壞強弱三色不變性時必須引入額外的輔助操作。弱三色不變形的好處在于:只要存在未訪問的能夠到達白色對象的路徑,就可以將黑色對象指向白色對象。
如果我們考慮并發的用戶態代碼,回收器不允許同時停止所有賦值器,就是涉及了存在的多個不同狀態的賦值器。為了對概念加以明確,還需要換一個角度,把回收器視為對象,把賦值器視為影響回收器這一對象的實際行為(即影響 GC 周期的長短),從而引入賦值器的顏色:
黑色賦值器:已經由回收器掃描過,不會再次對其進行掃描。
灰色賦值器:尚未被回收器掃描過,或盡管已經掃描過但仍需要重新掃描。
賦值器的顏色對回收周期的結束產生影響:
如果某種并發回收器允許灰色賦值器的存在,則必須在回收結束之前重新掃描對象圖。
如果重新掃描過程中發現了新的灰色或白色對象,回收器還需要對新發現的對象進行追蹤,但是在新追蹤的過程中,賦值器仍然可能在其根中插入新的非黑色的引用,如此往復,直到重新掃描過程中沒有發現新的白色或灰色對象。
于是,在允許灰色賦值器存在的算法,最壞的情況下,回收器只能將所有賦值器線程停止才能完成其跟對象的完整掃描,也就是我們所說的 STW。
為了確保強弱三色不變性的并發指針更新操作,需要通過賦值器屏障技術來保證指針的讀寫操作一致。因此我們所說的 Go 中的寫屏障、混合寫屏障,其實是指賦值器的寫屏障,賦值器的寫屏障用來保證賦值器在進行指針寫操作時,不會破壞弱三色不變性。
有兩種非常經典的寫屏障:Dijkstra 插入屏障和 Yuasa 刪除屏障。
灰色賦值器的 Dijkstra 插入屏障的基本思想是避免滿足條件 1:
// 灰色賦值器 Dijkstra 插入屏障 func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {shade(ptr)*slot = ptr }?為了防止黑色對象指向白色對象,應該假設 *slot 可能會變為黑色,為了確保 ptr 不會在被賦值到 *slot 前變為白色, shade(ptr) 會先將指針 ptr 標記為灰色,進而避免了條件 1。但是,由于并不清楚賦值器以后會不會將這個引用刪除,因此還需要重新掃描來重新確定關系圖,這時需要 STW,如圖所示:
Dijkstra 插入屏障的好處在于可以立刻開始并發標記,但由于產生了灰色賦值器,缺陷是需要標記終止階段 STW 時進行重新掃描。
黑色賦值器的 Yuasa 刪除屏障的基本思想是避免滿足條件 2:
// 黑色賦值器 Yuasa 屏障 func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {shade(*slot)*slot = ptr }為了防止丟失從灰色對象到白色對象的路徑,應該假設 *slot 可能會變為黑色,為了確保 ptr 不會在被賦值到 *slot 前變為白色, shade(*slot) 會先將 *slot 標記為灰色,進而該寫操作總是創造了一條灰色到灰色或者灰色到白色對象的路徑,進而避免了條件 2。
Yuasa 刪除屏障的優勢則在于不需要標記結束階段的重新掃描,缺陷是依然會產生丟失的對象,需要在標記開始前對整個對象圖進行快照。
Go 在 1.8 的時候為了簡化 GC 的流程,同時減少標記終止階段的重掃成本,將 Dijkstra 插入屏障和 Yuasa 刪除屏障進行混合,形成混合寫屏障。該屏障提出時的基本思想是:對正在被覆蓋的對象進行著色,且如果當前棧未掃描完成,則同樣對指針進行著色。
但在最終實現時原提案中對 ptr 的著色還額外包含對執行棧的著色檢查,但由于時間有限,并未完整實現過,所以混合寫屏障在目前的實現偽代碼是:
// 混合寫屏障 func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) {shade(*slot)shade(ptr)*slot = ptr }在這個實現中,如果無條件對引用雙方進行著色,自然結合了 Dijkstra 和 Yuasa 寫屏障的優勢,但缺點也非常明顯,因為著色成本是雙倍的,而且編譯器需要插入的代碼也成倍增加,隨之帶來的結果就是編譯后的二進制文件大小也進一步增加。為了針對寫屏障的性能進行優化,Go 1.10 前后,Go 團隊隨后實現了批量寫屏障機制。其基本想法是將需要著色的指針同一寫入一個緩存,每當緩存滿時統一對緩存中的所有 ptr 指針進行著色。
GC 的實現細節
10. Go 語言中 GC 的流程是什么?
當前版本的 Go 以 STW 為界限,可以將 GC 劃分為五個階段:
| GCMark | 標記準備階段,為并發標記做準備工作,啟動寫屏障 | STW |
| GCMark | 掃描標記階段,與賦值器并發執行,寫屏障開啟 | 并發 |
| GCMarkTermination | 標記終止階段,保證一個周期內標記任務完成,停止寫屏障 | STW |
| GCoff | 內存清掃階段,將需要回收的內存歸還到堆中,寫屏障關閉 | 并發 |
| GCoff | 內存歸還階段,將過多的內存歸還給操作系統,寫屏障關閉 | 并發 |
具體而言,各個階段的觸發函數分別為:
11. 觸發 GC 的時機是什么?
Go 語言中對 GC 的觸發時機存在兩種形式:
主動觸發,通過調用 runtime.GC 來觸發 GC,此調用阻塞式地等待當前 GC 運行完畢。
被動觸發,分為兩種方式:
使用系統監控,當超過兩分鐘沒有產生任何 GC 時,強制觸發 GC。
使用步調(Pacing)算法,其核心思想是控制內存增長的比例。
由于本問題剩余內容公式太多,無法完美在公眾號文章展示,建議點擊閱讀原文,直達原文,享受更好的閱讀體驗。
12. 如果內存分配速度超過了標記清除的速度怎么辦?
目前的 Go 實現中,當 GC 觸發后,會首先進入并發標記的階段。并發標記會設置一個標志,并在 mallocgc 調用時進行檢查。當存在新的內存分配時,會暫停分配內存過快的那些 goroutine,并將其轉去執行一些輔助標記(Mark Assist)的工作,從而達到放緩繼續分配、輔助 GC 的標記工作的目的。
編譯器會分析用戶代碼,并在需要分配內存的位置,將申請內存的操作翻譯為 mallocgc 調用,而 mallocgc 的實現決定了標記輔助的實現,其偽代碼思路如下:
func mallocgc(t typ.Type, size uint64) {if enableMarkAssist {// 進行標記輔助,此時用戶代碼沒有得到執行(...)}// 執行內存分配(...) }GC 的優化問題
13. GC 關注的指標有哪些?
Go 的 GC 被設計為成比例觸發、大部分工作與賦值器并發、不分代、無內存移動且會主動向操作系統歸還申請的內存。因此最主要關注的、能夠影響賦值器的性能指標有:
CPU 利用率:回收算法會在多大程度上拖慢程序?有時候,這個是通過回收占用的 CPU 時間與其它 CPU 時間的百分比來描述的。
GC 停頓時間:回收器會造成多長時間的停頓?目前的 GC 中需要考慮 STW 和 Mark Assist 兩個部分可能造成的停頓。
GC 停頓頻率:回收器造成的停頓頻率是怎樣的?目前的 GC 中需要考慮 STW 和 Mark Assist 兩個部分可能造成的停頓。
GC 可擴展性:當堆內存變大時,垃圾回收器的性能如何?但大部分的程序可能并不一定關心這個問題。
14. Go 的 GC 如何調優?
Go 的 GC 被設計為極致簡潔,與較為成熟的 Java GC 的數十個可控參數相比,嚴格意義上來講,Go 可供用戶調整的參數只有 GOGC 環境變量。當我們談論 GC 調優時,通常是指減少用戶代碼對 GC 產生的壓力,這一方面包含了減少用戶代碼分配內存的數量(即對程序的代碼行為進行調優),另一方面包含了最小化 Go 的 GC 對 CPU 的使用率(即調整 GOGC)。
GC 的調優是在特定場景下產生的,并非所有程序都需要針對 GC 進行調優。只有那些對執行延遲非常敏感、當 GC 的開銷成為程序性能瓶頸的程序,才需要針對 GC 進行性能調優,幾乎不存在于實際開發中 99% 的情況。除此之外,Go 的 GC 也仍然有一定的可改進的空間,也有部分 GC 造成的問題,目前仍屬于 Open Problem。
總的來說,我們可以在現在的開發中處理的有以下幾種情況:
對停頓敏感:GC 過程中產生的長時間停頓、或由于需要執行 GC 而沒有執行用戶代碼,導致需要立即執行的用戶代碼執行滯后。
對資源消耗敏感:對于頻繁分配內存的應用而言,頻繁分配內存增加 GC 的工作量,原本可以充分利用 CPU 的應用不得不頻繁地執行垃圾回收,影響用戶代碼對 CPU 的利用率,進而影響用戶代碼的執行效率。
從這兩點來看,所謂 GC 調優的核心思想也就是充分的圍繞上面的兩點來展開:優化內存的申請速度,盡可能的少申請內存,復用已申請的內存。或者簡單來說,不外乎這三個關鍵字:控制、減少、復用。
我們將通過三個實際例子介紹如何定位 GC 的存在的問題,并一步一步進行性能調優。當然,在實際情況中問題遠比這些例子要復雜,這里也只是討論調優的核心思想,更多的時候也只能具體問題具體分析。
例1:合理化內存分配的速度、提高賦值器的 CPU 利用率
我們來看這樣一個例子。在這個例子中, concat 函數負責拼接一些長度不確定的字符串。并且為了快速完成任務,出于某種原因,在兩個嵌套的 for 循環中一口氣創建了 800 個 goroutine。在 main 函數中,啟動了一個 goroutine 并在程序結束前不斷的觸發 GC,并嘗試輸出 GC 的平均執行時間:
package mainimport ("fmt""os""runtime""runtime/trace""sync/atomic""time" )var (stop int32count int64sum time.Duration )func concat() {for n := 0; n < 100; n++ {for i := 0; i < 8; i++ {go func() {s := "Go GC"s += " " + "Hello"s += " " + "World"_ = s}()}} }func main() {f, _ := os.Create("trace.out")defer f.Close()trace.Start(f)defer trace.Stop()go func() {var t time.Timefor atomic.LoadInt32(&stop) == 0 {t = time.Now()runtime.GC()sum += time.Since(t)count++}fmt.Printf("GC spend avg: %v\n", time.Duration(int64(sum)/count))}()concat()atomic.StoreInt32(&stop, 1) }這個程序的執行結果是:
$ go build -o main $ ./main GC spend avg: 2.583421msGC 平均執行一次需要長達 2ms 的時間,我們再進一步觀察 trace 的結果:
程序的整個執行過程中僅執行了一次 GC,而且僅 Sweep STW 就耗費了超過 1 ms,非常反常。甚至查看賦值器 mutator 的 CPU 利用率,在整個 trace 尺度下連 40% 都不到:
主要原因是什么呢?我們不妨查看 goroutine 的分析:
在這個榜單中我們不難發現,goroutine 的執行時間占其生命周期總時間非常短的一部分,但大部分時間都花費在調度器的等待上了(藍色的部分),說明同時創建大量 goroutine 對調度器產生的壓力確實不小,我們不妨將這一產生速率減慢,一批一批地創建 goroutine:
這時候我們再來看:
$ go build -o main $ ./main GC spend avg: 328.54μsGC 的平均時間就降到 300 微秒了。這時的賦值器 CPU 使用率也提高到了 60%,相對來說就很可觀了:
當然,這個程序仍然有優化空間,例如我們其實沒有必要等待很多 goroutine 同時執行完畢才去執行下一組 goroutine。而可以當一個 goroutine 執行完畢時,直接啟動一個新的 goroutine,也就是 goroutine 池的使用。有興趣的讀者可以沿著這個思路進一步優化這個程序中賦值器對 CPU 的使用率。
例2:降低并復用已經申請的內存
我們通過一個非常簡單的 Web 程序來說明復用內存的重要性。在這個程序中,每當產生一個 /example2的請求時,都會創建一段內存,并用于進行一些后續的工作。
package mainimport ("fmt""net/http"_ "net/http/pprof" )func newBuf() []byte {return make([]byte, 10<<20) }func main() {go func() {http.ListenAndServe("localhost:6060", nil)}()http.HandleFunc("/example2", func(w http.ResponseWriter, r *http.Request) {b := newBuf()// 模擬執行一些工作for idx := range b {b[idx] = 1}fmt.Fprintf(w, "done, %v", r.URL.Path[1:])})http.ListenAndServe(":8080", nil) }為了進行性能分析,我們還額外創建了一個監聽 6060 端口的 goroutine,用于使用 pprof 進行分析。我們先讓服務器跑起來:
$ go build -o main $ ./main我們這次使用 pprof 的 trace 來查看 GC 在此服務器中面對大量請求時候的狀態,要使用 trace 可以通過訪問 /debug/pprof/trace 路由來進行,其中 seconds 參數設置為 20s,并將 trace 的結果保存為 trace.out:
$ wget http://127.0.0.1:6060/debug/pprof/trace\?seconds\=20 -O trace.out --2020-01-01 22:13:34-- http://127.0.0.1:6060/debug/pprof/trace?seconds=20 Connecting to 127.0.0.1:6060... connected. HTTP request sent, awaiting response...這時候我們使用一個壓測工具 ab,來同時產生 500 個請求( -n 一共 500 個請求, -c 一個時刻執行請求的數量,每次 100 個并發請求):
$ ab -n 500 -c 100 http://127.0.0.1:8080/example2 This is ApacheBench, Version 2.3 <$Revision: 1843412 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/Benchmarking 127.0.0.1 (be patient) Completed 100 requests Completed 200 requests Completed 300 requests Completed 400 requests Completed 500 requests Finished 500 requestsServer Software: Server Hostname: 127.0.0.1 Server Port: 8080Document Path: /example2 Document Length: 14 bytesConcurrency Level: 100 Time taken for tests: 0.987 seconds Complete requests: 500 Failed requests: 0 Total transferred: 65500 bytes HTML transferred: 7000 bytes Requests per second: 506.63 [#/sec] (mean) Time per request: 197.382 [ms] (mean) Time per request: 1.974 [ms] (mean, across all concurrent requests) Transfer rate: 64.81 [Kbytes/sec] receivedConnection Times (ms)min mean[+/-sd] median max Connect: 0 1 1.1 0 7 Processing: 13 179 77.5 170 456 Waiting: 10 168 78.8 162 455 Total: 14 180 77.3 171 458Percentage of the requests served within a certain time (ms)50% 17166% 20375% 22280% 23990% 28195% 33598% 36599% 400100% 458 (longest request)GC 反復被觸發,一個顯而易見的原因就是內存分配過多。我們可以通過 go tool pprof 來查看究竟是誰分配了大量內存(使用 web 指令來使用瀏覽器打開統計信息的可視化圖形):
可見 newBuf 產生的申請的內存過多,現在我們使用 sync.Pool 來復用 newBuf 所產生的對象:
package mainimport ("fmt""net/http"_ "net/http/pprof""sync" )// 使用 sync.Pool 復用需要的 buf var bufPool = sync.Pool{New: func() interface{} {return make([]byte, 10<<20)}, }func main() {go func() {http.ListenAndServe("localhost:6060", nil)}()http.HandleFunc("/example2", func(w http.ResponseWriter, r *http.Request) {b := bufPool.Get().([]byte)for idx := range b {b[idx] = 0}fmt.Fprintf(w, "done, %v", r.URL.Path[1:])bufPool.Put(b)})http.ListenAndServe(":8080", nil) }其中 ab 輸出的統計結果為:
$ ab -n 500 -c 100 http://127.0.0.1:8080/example2 This is ApacheBench, Version 2.3 <$Revision: 1843412 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/Benchmarking 127.0.0.1 (be patient) Completed 100 requests Completed 200 requests Completed 300 requests Completed 400 requests Completed 500 requests Finished 500 requestsServer Software: Server Hostname: 127.0.0.1 Server Port: 8080Document Path: /example2 Document Length: 14 bytesConcurrency Level: 100 Time taken for tests: 0.427 seconds Complete requests: 500 Failed requests: 0 Total transferred: 65500 bytes HTML transferred: 7000 bytes Requests per second: 1171.32 [#/sec] (mean) Time per request: 85.374 [ms] (mean) Time per request: 0.854 [ms] (mean, across all concurrent requests) Transfer rate: 149.85 [Kbytes/sec] receivedConnection Times (ms)min mean[+/-sd] median max Connect: 0 1 1.4 1 9 Processing: 5 75 48.2 66 211 Waiting: 5 72 46.8 63 207 Total: 5 77 48.2 67 211Percentage of the requests served within a certain time (ms)50% 6766% 8975% 10780% 12290% 14895% 16798% 19699% 204100% 211 (longest request)但從 Requestsper second 每秒請求數來看,從原來的 506.63 變為 1171.32 得到了近乎一倍的提升。從 trace 的結果來看,GC 也沒有頻繁的被觸發從而長期消耗 CPU 使用率:
sync.Pool 是內存復用的一個最為顯著的例子,從語言層面上還有很多類似的例子,例如在例 1 中, concat 函數可以預先分配一定長度的緩存,而后再通過 append 的方式將字符串存儲到緩存中:
原因在于 + 運算符會隨著字符串長度的增加而申請更多的內存,并將內容從原來的內存位置拷貝到新的內存位置,造成大量不必要的內存分配,先提前分配好足夠的內存,再慢慢地填充,也是一種減少內存分配、復用內存形式的一種表現。
例3:調整 GOGC
我們已經知道了 GC 的觸發原則是由步調算法來控制的,其關鍵在于估計下一次需要觸發 GC 時,堆的大小。可想而知,如果我們在遇到海量請求的時,為了避免 GC 頻繁觸發,是否可以通過將 GOGC 的值設置得更大,讓 GC 觸發的時間變得更晚,從而減少其觸發頻率,進而增加用戶代碼對機器的使用率呢?答案是肯定的。
我們可以非常簡單粗暴的將 GOGC 調整為 1000,來執行上一個例子中未復用對象之前的程序:
$ GOGC=1000 ./main這時我們再重新執行壓測:
$ ab -n 500 -c 100 http://127.0.0.1:8080/example2 This is ApacheBench, Version 2.3 <$Revision: 1843412 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/Benchmarking 127.0.0.1 (be patient) Completed 100 requests Completed 200 requests Completed 300 requests Completed 400 requests Completed 500 requests Finished 500 requestsServer Software: Server Hostname: 127.0.0.1 Server Port: 8080Document Path: /example2 Document Length: 14 bytesConcurrency Level: 100 Time taken for tests: 0.923 seconds Complete requests: 500 Failed requests: 0 Total transferred: 65500 bytes HTML transferred: 7000 bytes Requests per second: 541.61 [#/sec] (mean) Time per request: 184.636 [ms] (mean) Time per request: 1.846 [ms] (mean, across all concurrent requests) Transfer rate: 69.29 [Kbytes/sec] receivedConnection Times (ms)min mean[+/-sd] median max Connect: 0 1 1.8 0 20 Processing: 9 171 210.4 66 859 Waiting: 5 158 199.6 62 813 Total: 9 173 210.6 68 860Percentage of the requests served within a certain time (ms)50% 6866% 13375% 19880% 29290% 56695% 69698% 72399% 743100% 860 (longest request)可以看到,壓測的結果得到了一定幅度的改善( Requestsper second 從原來的 506.63 提高為了 541.61),
并且 GC 的執行頻率明顯降低:
在實際實踐中可表現為需要緊急處理一些由 GC 帶來的瓶頸時,人為將 GOGC 調大,加錢加內存,扛過這一段峰值流量時期。
當然,這種做法其實是治標不治本,并沒有從根本上解決內存分配過于頻繁的問題,極端情況下,反而會由于 GOGC 太大而導致回收不及時而耗費更多的時間來清理產生的垃圾,這對時間不算敏感的應用還好,但對實時性要求較高的程序來說就是致命的打擊了。
因此這時更妥當的做法仍然是,定位問題的所在,并從代碼層面上進行優化。
小結
通過上面的三個例子我們可以看到在 GC 調優過程中 go tool pprof 和 go tool trace 的強大作用是幫助我們快速定位 GC 導致瓶頸的具體位置,但這些例子中僅僅覆蓋了其功能的很小一部分,我們也沒有必要完整覆蓋所有的功能,因為總是可以通過http pprof 官方文檔、runtime pprof官方文檔以及trace 官方文檔來舉一反三。
現在我們來總結一下前面三個例子中的優化情況:
控制內存分配的速度,限制 goroutine 的數量,從而提高賦值器對 CPU 的利用率。
減少并復用內存,例如使用 sync.Pool 來復用需要頻繁創建臨時對象,例如提前分配足夠的內存來降低多余的拷貝。
需要時,增大 GOGC 的值,降低 GC 的運行頻率。
這三種情況幾乎涵蓋了 GC 調優中的核心思路,雖然從語言上還有很多小技巧可說,但我們并不會在這里事無巨細的進行總結。實際情況也是千變萬化,我們更應該著重于培養具體問題具體分析的能力。
當然,我們還應該謹記 過早優化是萬惡之源這一警語,在沒有遇到應用的真正瓶頸時,將寶貴的時間分配在開發中其他優先級更高的任務上。
15. Go 的垃圾回收器有哪些相關的 API?其作用分別是什么?
在 Go 中存在數量極少的與 GC 相關的 API,它們是
runtime.GC:手動觸發 GC
runtime.ReadMemStats:讀取內存相關的統計信息,其中包含部分 GC 相關的統計信息
debug.FreeOSMemory:手動將內存歸還給操作系統
debug.ReadGCStats:讀取關于 GC 的相關統計信息
debug.SetGCPercent:設置 GOGC 調步變量
debug.SetMaxHeap(尚未發布):設置 Go 程序堆的上限值
GC 的歷史及演進
16. Go 歷史各個版本在 GC 方面的改進?
Go 1:串行三色標記清掃
Go 1.3:并行清掃,標記過程需要 STW,停頓時間在約幾百毫秒
Go 1.5:并發標記清掃,停頓時間在一百毫秒以內
Go 1.6:使用 bitmap 來記錄回收內存的位置,大幅優化垃圾回收器自身消耗的內存,停頓時間在十毫秒以內
Go 1.7:停頓時間控制在兩毫秒以內
Go 1.8:混合寫屏障,停頓時間在半個毫秒左右
Go 1.9:徹底移除了棧的重掃描過程
Go 1.12:整合了兩個階段的 Mark Termination,但引入了一個嚴重的 GC Bug 至今未修(見問題 20),尚無該 Bug 對 GC 性能影響的報告
Go 1.13:著手解決向操作系統歸還內存的,提出了新的 Scavenger
Go 1.14:替代了僅存活了一個版本的 scavenger,全新的頁分配器,優化分配內存過程的速率與現有的擴展性問題,并引入了異步搶占,解決了由于密集循環導致的 STW 時間過長的問題
可以用下圖直觀地說明 GC 的演進歷史:
在 Go 1 剛發布時的版本中,甚至沒有將 Mark-Sweep 的過程并行化,當需要進行垃圾回收時,所有的代碼都必須進入 STW 的狀態。而到了 Go 1.1 時,官方迅速地將清掃過程進行了并行化的處理,即僅在標記階段進入 STW。
這一想法很自然,因為并行化導致算法結果不一致的情況僅僅發生在標記階段,而當時的垃圾回收器沒有針對并行結果的一致性進行任何優化,因此才需要在標記階段進入 STW。對于 Scavenger 而言,早期的版本中會有一個單獨的線程來定期將多余的內存歸還給操作系統。
而到了 Go 1.5 后,Go 團隊花費了相當大的力氣,通過引入寫屏障的機制來保證算法的一致性,才得以將整個 GC 控制在很小的 STW 內,而到了 1.8 時,由于新的混合屏障的出現,消除了對棧本身的重新掃描,STW 的時間進一步縮減。
從這個時候開始,Scavenger 已經從獨立線程中移除,并合并至系統監控這個獨立的線程中,并周期性地向操作系統歸還內存,但仍然會有內存溢出這種比較極端的情況出現,因為程序可能在短時間內應對突發性的內存申請需求時,內存還沒來得及歸還操作系統,導致堆不斷向操作系統申請內存,從而出現內存溢出。
到了 Go 1.13,定期歸還操作系統的問題得以解決,Go 團隊開始將周期性的 Scavenger 轉化為可被調度的 goroutine,并將其與用戶代碼并發執行。而到了 Go 1.14,這一向操作系統歸還內存的操作時間進一步得到縮減。
17. Go GC 在演化過程中還存在哪些其他設計?為什么沒有被采用?
并發棧重掃
正如我們前面所說,允許灰色賦值器存在的垃圾回收器需要引入重掃過程來保證算法的正確性,除了引入混合屏障來消除重掃這一過程外,有另一種做法可以提高重掃過程的性能,那就是將重掃的過程并發執行。然而這一方案并沒有得以實現,原因很簡單:實現過程相比引入混合屏障而言十分復雜,而且引入混合屏障能夠消除重掃這一過程,將簡化垃圾回收的步驟。
ROC
ROC 的全稱是面向請求的回收器(Request Oriented Collector),它其實也是分代 GC 的一種重新敘述。它提出了一個請求假設(Request Hypothesis):與一個完整請求、休眠 goroutine 所關聯的對象比其他對象更容易死亡。這個假設聽起來非常符合直覺,但在實現上,由于垃圾回收器必須確保是否有 goroutine 私有指針被寫入公共對象,因此寫屏障必須一直打開,這也就產生了該方法的致命缺點:昂貴的寫屏障及其帶來的緩存未命中,這也是這一設計最終沒有被采用的主要原因。
傳統分代 GC
在發現 ROC 性能不行之后,作為備選方案,Go 團隊還嘗試了實現傳統的分代式 GC。但最終同樣發現分代假設并不適用于 Go 的運行棧機制,年輕代對象在棧上就已經死亡,掃描本就該回收的執行棧并沒有為由于分代假設帶來明顯的性能提升。這也是這一設計最終沒有被采用的主要原因。
18. 目前提供 GC 的語言以及不提供 GC 的語言有哪些?GC 和 No GC 各自的優缺點是什么?
從原理上而言,所有的語言都能夠自行實現 GC。從語言誕生之初就提供 GC 的語言,例如:
Python
JavaScript
Java
Objective-C
Swift
而不以 GC 為目標,被直接設計為手動管理內存、但可以自行實現 GC 的語言有:
C
C++
也有一些語言可以在編譯期,依靠編譯器插入清理代碼的方式,實現精準的清理,例如:
Rust
垃圾回收使程序員無需手動處理內存釋放,從而能夠消除一些需要手動管理內存才會出現的運行時錯誤:
在仍然有指向內存區塊的指針的情況下釋放這塊內存時,會產生懸掛指針,從而后續可能錯誤的訪問已經用于他用的內存區域。
多重釋放同一塊申請的內存區域可能導致不可知的內存損壞。
當然,垃圾回收也會伴隨一些缺陷,這也就造就了沒有 GC 的一些優勢:
沒有額外的性能開銷
精準的手動內存管理,極致的利用機器的性能
19. Go 對比 Java、V8 中 JavaScript 的 GC 性能如何?
無論是 Java 還是 JavaScript 中的 GC 均為分代式 GC。分代式 GC 的一個核心假設就是分代假說:將對象依據存活時間分配到不同的區域,每次回收只回收其中的一個區域。
V8 的 GC
在 V8 中主要將內存分為新生代和老生代。新生代中的對象為存活時間較短的對象,老生代中的對象為存活時間較長、常駐內存、占用內存較大的對象:
新生代中的對象主要通過副垃圾回收器進行回收。該回收過程是一種采用復制的方式實現的垃圾回收算法,它將堆內存一分為二,這兩個空間中只有一個處于使用中,另一個則處于閑置狀態。處于使用狀態的空間稱為 From 空間,處于閑置的空間稱為 To 空間。分配對象時,先是在 From 空間中進行分配,當開始垃圾回收時,會檢查 From 空間中的存活對象,并將這些存活對象復制到 To 空間中,而非存活對象占用的空間被釋放。完成復制后,From 空間和 To 空間的角色互換。也就是通過將存活對象在兩個空間中進行復制。
老生代則由主垃圾回收器負責。它實現的是標記清掃過程,但略有不同之處在于它還會在清掃完成后對內存碎片進行整理,進而是一種標記整理的回收器。
Java 的 GC
Java 的 GC 稱之為 G1,并將整個堆分為年輕代、老年代和永久代。包括四種不同的收集操作,從上往下的這幾個階段會選擇性地執行,觸發條件是用戶的配置和實際代碼行為的預測。
年輕代收集周期:只對年輕代對象進行收集與清理
老年代收集周期:只對老年代對象進行收集與清理
混合式收集周期:同時對年輕代和老年代進行收集與清理
完整 GC 周期:完整的對整個堆進行收集與清理
在回收過程中,G1 會對停頓時間進行預測,竭盡所能地調整 GC 的策略從而達到用戶代碼通過系統參數( -XX:MaxGCPauseMillis)所配置的對停頓時間的要求。
這四個周期的執行成本逐漸上升,優化得當的程序可以完全避免完整 GC 周期。
性能比較
在 Go、Java 和 V8 JavaScript 之間比較 GC 的性能本質上是一個不切實際的問題。如前面所說,垃圾回收器的設計權衡了很多方面的因素,同時還受語言自身設計的影響,因為語言的設計也直接影響了程序員編寫代碼的形式,也就自然影響了產生垃圾的方式。
但總的來說,他們三者對垃圾回收的實現都需要 STW,并均已達到了用戶代碼幾乎無法感知到的狀態(據 Go GC 作者 Austin 宣稱 STW 小于 100 微秒)。當然,隨著 STW 的減少,垃圾回收器會增加 CPU 的使用率,這也是程序員在編寫代碼時需要手動進行優化的部分,即充分考慮內存分配的必要性,減少過多申請內存帶給垃圾回收器的壓力。
20. 目前 Go 語言的 GC 還存在哪些問題?
盡管 Go 團隊宣稱 STW 停頓時間得以優化到 100 微秒級別,但這本質上是一種取舍。原本的 STW 某種意義上來說其實轉移到了可能導致用戶代碼停頓的幾個位置;除此之外,由于運行時調度器的實現方式,同樣對 GC 存在一定程度的影響。
目前 Go 中的 GC 仍然存在以下問題:
1. Mark Assist 停頓時間過長
package mainimport ("fmt""os""runtime""runtime/trace""time" )const (windowSize = 200000msgCount = 1000000 )var (best time.Duration = time.SecondbestAt time.Timeworst time.DurationworstAt time.Timestart = time.Now() )func main() {f, _ := os.Create("trace.out")defer f.Close()trace.Start(f)defer trace.Stop()for i := 0; i < 5; i++ {measure()worst = 0best = time.Secondruntime.GC()} }func measure() {var c channelfor i := 0; i < msgCount; i++ {c.sendMsg(i)}fmt.Printf("Best send delay %v at %v, worst send delay: %v at %v. Wall clock: %v \n", best, bestAt.Sub(start), worst, worstAt.Sub(start), time.Since(start)) }type channel [windowSize][]bytefunc (c *channel) sendMsg(id int) {start := time.Now()// 模擬發送(*c)[id%windowSize] = newMsg(id)end := time.Now()elapsed := end.Sub(start)if elapsed > worst {worst = elapsedworstAt = end}if elapsed < best {best = elapsedbestAt = end} }func newMsg(n int) []byte {m := make([]byte, 1024)for i := range m {m[i] = byte(n)}return m }運行此程序我們可以得到類似下面的結果:
$ go run main.goBest send delay 330ns at 773.037956ms, worst send delay: 7.127915ms at 579.835487ms. Wall clock: 831.066632ms Best send delay 331ns at 873.672966ms, worst send delay: 6.731947ms at 1.023969626s. Wall clock: 1.515295559s Best send delay 330ns at 1.812141567s, worst send delay: 5.34028ms at 2.193858359s. Wall clock: 2.199921749s Best send delay 338ns at 2.722161771s, worst send delay: 7.479482ms at 2.665355216s. Wall clock: 2.920174197s Best send delay 337ns at 3.173649445s, worst send delay: 6.989577ms at 3.361716121s. Wall clock: 3.615079348s在這個結果中,第一次的最壞延遲時間高達 7.12 毫秒,發生在程序運行 578 毫秒左右。通過 go tool trace 可以發現,這個時間段中,Mark Assist 執行了 7112312ns,約為 7.127915ms;可見,此時最壞情況下,標記輔助拖慢了用戶代碼的執行,是造成 7 毫秒延遲的原因。
2. Sweep 停頓時間過長
同樣還是剛才的例子,如果我們仔細觀察 Mark Assist 后發生的 Sweep 階段,竟然對用戶代碼的影響長達約 30ms,根據調用棧信息可以看到,該 Sweep 過程發生在內存分配階段:
3. 由于 GC 算法的不正確性導致 GC 周期被迫重新執行
此問題很難復現,但是一個已知的問題,根據 Go 團隊的描述,能夠在 1334 次構建中發生一次,我們可以計算出其觸發概率約為 0.0007496251874。雖然發生概率很低,但一旦發生,GC 需要被重新執行,非常不幸。
4. 創建大量 Goroutine 后導致 GC 消耗更多的 CPU
這個問題可以通過以下程序進行驗證:
func BenchmarkGCLargeGs(b *testing.B) {wg := sync.WaitGroup{}for ng := 100; ng <= 1000000; ng *= 10 {b.Run(fmt.Sprintf("#g-%d", ng), func(b *testing.B) {// 創建大量 goroutine,由于每次創建的 goroutine 會休眠// 從而運行時不會復用正在休眠的 goroutine,進而不斷創建新的 gwg.Add(ng)for i := 0; i < ng; i++ {go func() {time.Sleep(100 * time.Millisecond)wg.Done()}()}wg.Wait()// 現運行一次 GC 來提供一致的內存環境runtime.GC()// 記錄運行 b.N 次 GC 需要的時間b.ResetTimer()for i := 0; i < b.N; i++ {runtime.GC()}})} }其結果可以通過如下指令來獲得:
$ go test -bench=BenchmarkGCLargeGs -run=^$ -count=5 -v . | tee 4.txt $ benchstat 4.txt name time/op GCLargeGs/#g-100-12 192μs ± 5% GCLargeGs/#g-1000-12 331μs ± 1% GCLargeGs/#g-10000-12 1.22ms ± 1% GCLargeGs/#g-100000-12 10.9ms ± 3% GCLargeGs/#g-1000000-12 32.5ms ± 4%這種情況通常發生于峰值流量后,大量 goroutine 由于任務等待被休眠,從而運行時不斷創建新的 goroutine,舊的 goroutine 由于休眠未被銷毀且得不到復用,導致 GC 需要掃描的執行棧越來越多,進而完成 GC 所需的時間越來越長。一個解決辦法是使用 goroutine 池來限制創建的 goroutine 數量。
總結
GC 是一個復雜的系統工程,本文討論的二十個問題盡管已經展現了一個相對全面的 Go GC。但它們仍然只是 GC 這一宏觀問題的一些較為重要的部分,還有非常多的細枝末節、研究進展無法在有限的篇幅內完整討論。
從 Go 誕生之初,Go 團隊就一直在對 GC 的表現進行實驗與優化,但仍然有諸多未解決的問題,我們不妨對 GC 未來的改進拭目以待。
推薦閱讀
【Why golang garbage-collector not implement Generational and Compact gc?】https://groups.google.com/forum/#!msg/golang-nuts/KJiyv2mV2pU/wdBUH1mHCAAJ
【寫一個內存分配器】http://dmitrysoshnikov.com/compilers/writing-a-memory-allocator/#more-3590
【觀察 GC】https://www.ardanlabs.com/blog/2019/05/garbage-collection-in-go-part2-gctraces.html
【煎魚 Go debug】https://segmentfault.com/a/1190000020255157
【煎魚 go tool trace】https://eddycjy.gitbook.io/golang/di-9-ke-gong-ju/go-tool-trace
【trace 講解】https://www.itcodemonkey.com/article/5419.html
【An Introduction to go tool trace】https://about.sourcegraph.com/go/an-introduction-to-go-tool-trace-rhys-hiltner
【http pprof 官方文檔】https://golang.org/pkg/net/http/pprof/
【runtime pprof 官方文檔】https://golang.org/pkg/runtime/pprof/
【trace 官方文檔】https://golang.org/pkg/runtime/trace/
下面是彩蛋時間:
新建立了一個免費的知識星球,大家可以在這里分享? Go 相關的面試、筆試經驗,提出Go 相關的問題,分享 Go 相關的文章等等。
我也會邀請一些業界大佬來分享經驗,解答問題。
當然,星球不會僅限于 Go,也不僅限于技術,任何能幫助大家在職場中成長的內容都歡迎分享。
最重要的是希望大家都能得到成長!成為更好的自己!
歐神和多位大佬已經在星球等你了,你不來嗎?
總結
以上是生活随笔為你收集整理的Go GC 20 问的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 走过 2019
- 下一篇: defer 的前世今生