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 的運行時機、運行開銷進行把控的時候才得以現身。

通常,垃圾回收器的執行過程被劃分爲兩個半獨立的組件:

2. 根對象到底是什麼?

根對象在垃圾回收的術語中又叫做根集合,它是垃圾回收器在標記過程時最先檢查的對象,包括:

  1. 全局變量:程序在編譯期就能確定的那些存在於程序整個生命週期的變量。

  2. 執行棧:每個 goroutine 都包含自己的執行棧,這些執行棧上包含棧上的變量及指向分配的堆內存區塊的指針。

  3. 寄存器:寄存器的值可能表示一個指針,參與計算的這些指針可能指向某些賦值器分配的堆內存區塊。

**3. 常見的 GC 實現方式有哪些?**Go 語言的 GC 使用的是什麼?

所有的 GC 算法其存在形式可以歸結爲追蹤(Tracing)和引用計數(Reference Counting)這兩種形式的混合運用。

從根對象出發,根據對象之間的引用信息,一步步推進直到掃描完畢整個堆並確定需要保留的對象,從而回收所有可回收的對象。Go、 Java、V8 對 JavaScript 的實現等均爲追蹤式 GC。

每個對象自身包含一個被引用的計數器,當計數器歸零時自動得到回收。因爲此方法缺陷較多,在追求高性能時通常不被應用。Python、Objective-C 等均爲引用計數式 GC。

目前比較常見的 GC 實現方式包括:

關於各類方法的詳細介紹及其實現不在本文中詳細討論。對於 Go 而言,Go 的 GC 目前使用的是無分代(對象沒有代際之分)、不整理(回收過程中不對對象進行移動與整理)、併發(與用戶代碼併發執行)的三色標記清掃算法。原因在於:

  1. 對象整理的優勢是解決內存碎片問題以及 “允許” 使用順序內存分配器。但 Go 運行時的分配算法基於 tcmalloc,基本上沒有碎片問題。並且順序內存分配器在多線程的場景下並不適用。Go 使用的是基於 tcmalloc 的現代內存分配算法,對對象進行整理不會帶來實質性的性能提升。

  2. 分代 GC 依賴分代假設,即 GC 將主要的回收目標放在新創建的對象上(存活時間短,更傾向於被回收),而非頻繁檢查所有對象。但 Go 的編譯器會通過逃逸分析將大部分新生對象存儲在棧上(棧直接被回收),只有那些需要長期存在的對象纔會被分配到需要進行垃圾回收的堆中。也就是說,分代 GC 回收的那些存活時間短的對象在 Go 中是直接被分配到棧上,當 goroutine 死亡後棧也會被直接回收,不需要 GC 的參與,進而分代假設並沒有帶來直接優勢。並且 Go 的垃圾回收器與用戶代碼併發執行,使得 STW 的時間與對象的代際、對象的 size 沒有關係。Go 團隊更關注於如何更好地讓 GC 與用戶代碼併發執行(使用適當的 CPU 來執行垃圾回收),而非減少停頓時間這一單一目標上。

4. 三色標記法是什麼?

理解三色標記法的關鍵是理解對象的三色抽象以及波面(wavefront)推進這兩個概念。三色抽象只是一種描述追蹤式回收器的方法,在實踐中並沒有實際含義,它的重要作用在於從邏輯上嚴密推導標記清理這種垃圾回收方法的正確性。也就是說,當我們談及三色標記法時,通常指標記清掃的垃圾回收。

從垃圾回收器的視角來看,三色抽象規定了三種不同類型的對象,並用不同的顏色相稱:

這樣三種不變性所定義的回收過程其實是一個波面不斷前進的過程,這個波面同時也是黑色對象和白色對象的邊界,灰色對象就是這個波面。

當垃圾回收開始時,只有白色對象。隨着標記過程開始進行時,灰色對象開始出現(着色),這時候波面便開始擴大。當一個對象的所有子節點均完成掃描時,會被着色爲黑色。當整個堆遍歷完成時,只剩下黑色和白色對象,這時的黑色對象爲可達對象,即存活;而白色對象爲不可達對象,即死亡。這個過程可以視爲以灰色對象爲波面,將黑色對象和白色對象分離,使波面不斷向前推進,直到所有可達的灰色對象都變爲黑色對象爲止的過程。如下圖所示:

圖中展示了根對象、可達對象、不可達對象,黑、灰、白對象以及波面之間的關係。

5. STW 是什麼意思?

STWStoptheWorld 的縮寫,即萬物靜止,是指在垃圾回收過程中爲了保證實現的正確性、防止無止境的內存增長等問題而不可避免的需要停止賦值器進一步操作對象圖的一段過程。

在這個過程中整個用戶代碼被停止或者放緩執行, STW 越長,對用戶代碼造成的影響(例如延遲)就越大,早期 Go 對垃圾回收器的實現中 STW 長達幾百毫秒,對時間敏感的實時通信等應用程序會造成巨大的影響。我們來看一個例子:

package main
import (
  "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 main
func 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 ./main
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
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

含義由下表所示:

rr2fxc

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)

含義由下表所示:

PfQict

方式 2: go tool trace

go tool trace 的主要功能是將統計而來的信息以一種可視化的方式展示給用戶。要使用此工具,可以通過調用 trace API:

package main
func 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

命令來啓動可視化界面:

選擇第一個鏈接可以獲得如下圖示:

右上角的問號可以打開幫助菜單,主要使用方式包括:

方式 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.go
gc 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.go
gc 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.GCStatsruntime.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 main
import (
  "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. 併發標記清除法的難點是什麼?

在沒有用戶態代碼併發修改 三色抽象的情況下,回收可以正常結束。但是併發回收的根本問題在於,用戶態代碼在回收過程中會併發地更新對象圖,從而造成賦值器和回收器可能對對象圖的結構產生不同的認知。這時以一個固定的三色波面作爲回收過程前進的邊界則不再合理。

我們不妨考慮賦值器寫操作的例子:

z3edr4

總而言之,併發標記清除中面臨的一個根本問題就是如何保證標記與清除過程的正確性。

9. 什麼是寫屏障、混合寫屏障,如何實現?

要講清楚寫屏障,就需要理解三色標記清除算法中的強弱不變性以及賦值器的顏色,理解他們需要一定的抽象思維。寫屏障是一個在併發垃圾回收器中才會出現的概念,垃圾回收器的正確性體現在:不應出現對象的丟失,也不應錯誤的回收還不需要回收的對象。

可以證明,當以下兩個條件同時滿足時會破壞垃圾回收器的正確性:

只要能夠避免其中任何一個條件,則不會出現對象丟失的情況,因爲:

我們不妨將三色不變性所定義的波面根據這兩個條件進行削弱:

當賦值器進一步破壞灰色對象到達白色對象的路徑時(進一步滿足條件 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 劃分爲五個階段:

WWmLjn

具體而言,各個階段的觸發函數分別爲:

11. 觸發 GC 的時機是什麼?

Go 語言中對 GC 的觸發時機存在兩種形式:

  1. 主動觸發,通過調用 runtime.GC 來觸發 GC,此調用阻塞式地等待當前 GC 運行完畢。

  2. 被動觸發,分爲兩種方式:

由於本問題剩餘內容公式太多,無法完美在公衆號文章展示,建議點擊閱讀原文,直達原文,享受更好的閱讀體驗。

12. 如果內存分配速度超過了標記清除的速度怎麼辦?

目前的 Go 實現中,當 GC 觸發後,會首先進入併發標記的階段。併發標記會設置一個標誌,並在 mallocgc 調用時進行檢查。當存在新的內存分配時,會暫停分配內存過快的那些 goroutine,並將其轉去執行一些輔助標記(Mark Assist)的工作,從而達到放緩繼續分配、輔助 GC 的標記工作的目的。

編譯器會分析用戶代碼,並在需要分配內存的位置,將申請內存的操作翻譯爲 mallocgc 調用,而 mallocgc 的實現決定了標記輔助的實現,其僞代碼思路如下:

func mallocgc(t typ.Type, size uint64) {
  if enableMarkAssist {
    // 進行標記輔助,此時用戶代碼沒有得到執行
    (...)
  }
  // 執行內存分配
  (...)
}

GC 的優化問題

13. GC 關注的指標有哪些?

Go 的 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。

總的來說,我們可以在現在的開發中處理的有以下幾種情況:

  1. 對停頓敏感:GC 過程中產生的長時間停頓、或由於需要執行 GC 而沒有執行用戶代碼,導致需要立即執行的用戶代碼執行滯後。

  2. 對資源消耗敏感:對於頻繁分配內存的應用而言,頻繁分配內存增加 GC 的工作量,原本可以充分利用 CPU 的應用不得不頻繁地執行垃圾回收,影響用戶代碼對 CPU 的利用率,進而影響用戶代碼的執行效率。

從這兩點來看,所謂 GC 調優的核心思想也就是充分的圍繞上面的兩點來展開:優化內存的申請速度,儘可能的少申請內存,複用已申請的內存。或者簡單來說,不外乎這三個關鍵字:控制、減少、複用

我們將通過三個實際例子介紹如何定位 GC 的存在的問題,並一步一步進行性能調優。當然,在實際情況中問題遠比這些例子要複雜,這裏也只是討論調優的核心思想,更多的時候也只能具體問題具體分析。

例 1:合理化內存分配的速度、提高賦值器的 CPU 利用率

我們來看這樣一個例子。在這個例子中, concat 函數負責拼接一些長度不確定的字符串。並且爲了快速完成任務,出於某種原因,在兩個嵌套的 for 循環中一口氣創建了 800 個 goroutine。在 main 函數中,啓動了一個 goroutine 並在程序結束前不斷的觸發 GC,並嘗試輸出 GC 的平均執行時間:

package main
import (
  "fmt"
  "os"
  "runtime"
  "runtime/trace"
  "sync/atomic"
  "time"
)
var (
  stop  int32
  count int64
  sum   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.Time
    for 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.583421ms

GC 平均執行一次需要長達 2ms 的時間,我們再進一步觀察 trace 的結果:

程序的整個執行過程中僅執行了一次 GC,而且僅 Sweep STW 就耗費了超過 1 ms,非常反常。甚至查看賦值器 mutator 的 CPU 利用率,在整個 trace 尺度下連 40% 都不到:

主要原因是什麼呢?我們不妨查看 goroutine 的分析:

在這個榜單中我們不難發現,goroutine 的執行時間佔其生命週期總時間非常短的一部分,但大部分時間都花費在調度器的等待上了(藍色的部分),說明同時創建大量 goroutine 對調度器產生的壓力確實不小,我們不妨將這一產生速率減慢,一批一批地創建 goroutine:

func concat() {
  wg := sync.WaitGroup{}
  for n := 0; n < 100; n++ {
    wg.Add(8)
    for i := 0; i < 8; i++ {
      go func() {
        s := "Go GC"
        s += " " + "Hello"
        s += " " + "World"
        _ = s
        wg.Done()
      }()
    }
    wg.Wait()
  }
}

這時候我們再來看:

$ go build -o main
$ ./main
GC spend avg: 328.54µs

GC 的平均時間就降到 300 微秒了。這時的賦值器 CPU 使用率也提高到了 60%,相對來說就很可觀了:

當然,這個程序仍然有優化空間,例如我們其實沒有必要等待很多 goroutine 同時執行完畢纔去執行下一組 goroutine。而可以當一個 goroutine 執行完畢時,直接啓動一個新的 goroutine,也就是 goroutine 池的使用。有興趣的讀者可以沿着這個思路進一步優化這個程序中賦值器對 CPU 的使用率。

例 2:降低並複用已經申請的內存

我們通過一個非常簡單的 Web 程序來說明覆用內存的重要性。在這個程序中,每當產生一個 /example2的請求時,都會創建一段內存,並用於進行一些後續的工作。

package main
import (
  "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 requests
Server Software:        
Server Hostname:        127.0.0.1
Server Port:            8080
Document Path:          /example2
Document Length:        14 bytes
Concurrency 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] received
Connection 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     458
Percentage of the requests served within a certain time (ms)
  50%    171
  66%    203
  75%    222
  80%    239
  90%    281
  95%    335
  98%    365
  99%    400
 100%    458 (longest request)

GC 反覆被觸發,一個顯而易見的原因就是內存分配過多。我們可以通過 go tool pprof 來查看究竟是誰分配了大量內存(使用 web 指令來使用瀏覽器打開統計信息的可視化圖形):

$ go tool pprof http://127.0.0.1:6060/debug/pprof/heap
Fetching profile over HTTP from http://localhost:6060/debug/pprof/heap
Saved profile in /Users/changkun/pprof/pprof.alloc_objects.alloc_space.inuse_o
bjects.inuse_space.003.pb.gz
Type: inuse_space
Time: Jan 1, 2020 at 11:15pm (CET)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) web
(pprof)

可見 newBuf 產生的申請的內存過多,現在我們使用 sync.Pool 來複用 newBuf 所產生的對象:

package main
import (
  "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 requests
Server Software:        
Server Hostname:        127.0.0.1
Server Port:            8080
Document Path:          /example2
Document Length:        14 bytes
Concurrency 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] received
Connection 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     211
Percentage of the requests served within a certain time (ms)
  50%     67
  66%     89
  75%    107
  80%    122
  90%    148
  95%    167
  98%    196
  99%    204
 100%    211 (longest request)

但從 Requestsper second 每秒請求數來看,從原來的 506.63 變爲 1171.32 得到了近乎一倍的提升。從 trace 的結果來看,GC 也沒有頻繁的被觸發從而長期消耗 CPU 使用率:

sync.Pool 是內存複用的一個最爲顯著的例子,從語言層面上還有很多類似的例子,例如在例 1 中, concat 函數可以預先分配一定長度的緩存,而後再通過 append 的方式將字符串存儲到緩存中:

func concat() {
  wg := sync.WaitGroup{}
  for n := 0; n < 100; n++ {
    wg.Add(8)
    for i := 0; i < 8; i++ {
      go func() {
        s := make([]byte, 0, 20)
        s = append(s, "Go GC"...)
        s = append(s, ' ')
        s = append(s, "Hello"...)
        s = append(s, ' ')
        s = append(s, "World"...)
        _ = string(s)
        wg.Done()
      }()
    }
    wg.Wait()
  }
}

原因在於 + 運算符會隨着字符串長度的增加而申請更多的內存,並將內容從原來的內存位置拷貝到新的內存位置,造成大量不必要的內存分配,先提前分配好足夠的內存,再慢慢地填充,也是一種減少內存分配、複用內存形式的一種表現。

例 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 requests
Server Software:        
Server Hostname:        127.0.0.1
Server Port:            8080
Document Path:          /example2
Document Length:        14 bytes
Concurrency 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] received
Connection 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     860
Percentage of the requests served within a certain time (ms)
  50%     68
  66%    133
  75%    198
  80%    292
  90%    566
  95%    696
  98%    723
  99%    743
 100%    860 (longest request)

可以看到,壓測的結果得到了一定幅度的改善( Requestsper second 從原來的 506.63 提高爲了 541.61),

並且 GC 的執行頻率明顯降低:

在實際實踐中可表現爲需要緊急處理一些由 GC 帶來的瓶頸時,人爲將 GOGC 調大,加錢加內存,扛過這一段峯值流量時期。

當然,這種做法其實是治標不治本,並沒有從根本上解決內存分配過於頻繁的問題,極端情況下,反而會由於 GOGC 太大而導致回收不及時而耗費更多的時間來清理產生的垃圾,這對時間不算敏感的應用還好,但對實時性要求較高的程序來說就是致命的打擊了。

因此這時更妥當的做法仍然是,定位問題的所在,並從代碼層面上進行優化。

小結

通過上面的三個例子我們可以看到在 GC 調優過程中 go tool pprofgo tool trace 的強大作用是幫助我們快速定位 GC 導致瓶頸的具體位置,但這些例子中僅僅覆蓋了其功能的很小一部分,我們也沒有必要完整覆蓋所有的功能,因爲總是可以通過 http pprof 官方文檔、runtime pprof 官方文檔以及 trace 官方文檔來舉一反三。

現在我們來總結一下前面三個例子中的優化情況:

  1. 控制內存分配的速度,限制 goroutine 的數量,從而提高賦值器對 CPU 的利用率。

  2. 減少並複用內存,例如使用 sync.Pool 來複用需要頻繁創建臨時對象,例如提前分配足夠的內存來降低多餘的拷貝。

  3. 需要時,增大 GOGC 的值,降低 GC 的運行頻率。

這三種情況幾乎涵蓋了 GC 調優中的核心思路,雖然從語言上還有很多小技巧可說,但我們並不會在這裏事無鉅細的進行總結。實際情況也是千變萬化,我們更應該着重於培養具體問題具體分析的能力。

當然,我們還應該謹記 過早優化是萬惡之源這一警語,在沒有遇到應用的真正瓶頸時,將寶貴的時間分配在開發中其他優先級更高的任務上。

**15. Go 的垃圾回收器有哪些相關的 API?**其作用分別是什麼?

在 Go 中存在數量極少的與 GC 相關的 API,它們是

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 的語言,例如:

而不以 GC 爲目標,被直接設計爲手動管理內存、但可以自行實現 GC 的語言有:

也有一些語言可以在編譯期,依靠編譯器插入清理代碼的方式,實現精準的清理,例如:

垃圾回收使程序員無需手動處理內存釋放,從而能夠消除一些需要手動管理內存纔會出現的運行時錯誤:

  1. 在仍然有指向內存區塊的指針的情況下釋放這塊內存時,會產生懸掛指針,從而後續可能錯誤的訪問已經用於他用的內存區域。

  2. 多重釋放同一塊申請的內存區域可能導致不可知的內存損壞。

當然,垃圾回收也會伴隨一些缺陷,這也就造就了沒有 GC 的一些優勢:

  1. 沒有額外的性能開銷

  2. 精準的手動內存管理,極致的利用機器的性能

19. Go 對比 Java、V8 中 JavaScript 的 GC 性能如何?

無論是 Java 還是 JavaScript 中的 GC 均爲分代式 GC。分代式 GC 的一個核心假設就是分代假說:將對象依據存活時間分配到不同的區域,每次回收只回收其中的一個區域。

V8 的 GC

在 V8 中主要將內存分爲新生代和老生代。新生代中的對象爲存活時間較短的對象,老生代中的對象爲存活時間較長、常駐內存、佔用內存較大的對象:

  1. 新生代中的對象主要通過副垃圾回收器進行回收。該回收過程是一種採用複製的方式實現的垃圾回收算法,它將堆內存一分爲二,這兩個空間中只有一個處於使用中,另一個則處於閒置狀態。處於使用狀態的空間稱爲 From 空間,處於閒置的空間稱爲 To 空間。分配對象時,先是在 From 空間中進行分配,當開始垃圾回收時,會檢查 From 空間中的存活對象,並將這些存活對象複製到 To 空間中,而非存活對象佔用的空間被釋放。完成複製後,From 空間和 To 空間的角色互換。也就是通過將存活對象在兩個空間中進行復制。

  2. 老生代則由主垃圾回收器負責。它實現的是標記清掃過程,但略有不同之處在於它還會在清掃完成後對內存碎片進行整理,進而是一種標記整理的回收器。

Java 的 GC

Java 的 GC 稱之爲 G1,並將整個堆分爲年輕代、老年代和永久代。包括四種不同的收集操作,從上往下的這幾個階段會選擇性地執行,觸發條件是用戶的配置和實際代碼行爲的預測。

  1. 年輕代收集週期:只對年輕代對象進行收集與清理

  2. 老年代收集週期:只對老年代對象進行收集與清理

  3. 混合式收集週期:同時對年輕代和老年代進行收集與清理

  4. 完整 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 main
import (
  "fmt"
  "os"
  "runtime"
  "runtime/trace"
  "time"
)
const (
  windowSize = 200000
  msgCount   = 1000000
)
var (
  best    time.Duration = time.Second
  bestAt  time.Time
  worst   time.Duration
  worstAt time.Time
  start = 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 = 0
    best = time.Second
    runtime.GC()
  }
}
func measure() {
  var c channel
  for 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][]byte
func (c *channel) sendMsg(id int) {
  start := time.Now()
  // 模擬發送
  (*c)[id%windowSize] = newMsg(id)
  end := time.Now()
  elapsed := end.Sub(start)
  if elapsed > worst {
    worst = elapsed
    worstAt = end
  }
  if elapsed < best {
    best = elapsed
    bestAt = end
  }
}
func newMsg(n int) []byte {
  m := make([]byte, 1024)
  for i := range m {
    m[i] = byte(n)
  }
  return m
}

運行此程序我們可以得到類似下面的結果:

$ go run main.go
Best 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,進而不斷創建新的 g
      wg.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,也不僅限於技術,任何能幫助大家在職場中成長的內容都歡迎分享。

最重要的是希望大家都能得到成長!成爲更好的自己!

歐神和多位大佬已經在星球等你了,你不來嗎?

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/o2oMMh0PF5ZSoYD0XOBY2Q