Go 高效能系列教程之五:記憶體和垃圾回收

yudotyang發表於2021-06-11

原文連結 https://dave.cheney.net/high-performance-go-workshop/gophercon-2019.html#memory-and-gc

Go 是一門自動垃圾收回的語言。這是設計原則,不能改變。

作為自動垃圾回收的語言,Go 程式的效能通常取決於他們與垃圾收集器的互動。

除了演算法的選擇以外,記憶體的消耗是決定應用程式效能和可伸縮性的最重要的因素。

本節將討論垃圾回收器的操作,如何評估程式的記憶體使用情況以及在垃圾回收器的效能成為瓶頸時如何降低記憶體使用量的策略。

5.1 垃圾回收器的目的

任何垃圾回收器的目的都是為了讓程式有足夠的可用記憶體

你可能不太同意這個觀點,但是這是垃圾回收器設計者工作時最基本的假設。

就總執行時間而言,STW、標記清除 GC 是最有效的垃圾回收演算法。適用於批處理、模擬等。但是,隨著時間的流式,Go GC 從純粹的 STW(stop the world)轉變成併發、非壓縮的方式。這是因為 Go GC 被設計成了低延遲服務和互動程式。

Go GC 的設計偏向於低延遲,而不再是高吞吐;它將一些記憶體分配成本轉移到了 mutator 上,以減少後續的清理成本。

5.2 垃圾回收器的設計

在過去的幾年裡,Go GC 的設計發生了很多改變:

  • Go 1.0,高度依賴於tcmalloc的 STW、標記清除收集器
  • Go 1.3,非常精確的收集器(fully precise collector),不會將堆記憶體上的大數字誤認為是指標了,因此降低了記憶體浪費
  • Go 1.5,一個新的 GC 設計,主要關注在低延遲而非高吞吐量上。
  • Go 1.6,改進 GC,用低延遲處理大的堆記憶體。
  • Go 1.7,一些小的改進,主要是重構。
  • Go 1.8,更進一步降低 STW 的時間,目前降低到了 100 微秒以內。
  • Go 1.10+, 擺脫了純粹的 goroutine 協同排程以降低觸發整個 GC 週期的延遲
  • Go 1.13,重寫 Scavenger

5.2.1 垃圾回收調整

Go 執行時提供了一個環境變數來調整 GC,GOGC GOGC 的公式是:

goal = reachable * (1 + GOGC/100)

該公式中,reachable 是當前的記憶體量,goal 是 GC 執行的目標堆記憶體量,即當堆記憶體量達到該值時就需要執行 GC 了。

GOGC 是 Go 執行時很早就支援的一個環境變數。可能比 GOROOT 的支援還早。GOGC 的值能夠影響 GC 執行的頻率。預設值是 100.即當分配的堆記憶體是目前的一倍的時候,則會執行 GC。

例如,如果我們現在有一個 256M 大小的堆記憶體,同時,GOGC=100(預設),當堆記憶體增加到以下值時,GC 將會執行:

512MB = 256MB * (1 + 100 / 100)
  • GOGC 變數值大於 100 時會導致堆記憶體增長過快,這樣會減少 GC 的壓力。
  • GOGC 小於 100 時,導致堆堆記憶體較慢的增長,會增大 GC 的壓力。

GOGC 的預設值 100 只是一個指導值。你可以根據你線上應用的實際負載情況選擇合適的值。

5.2.2 VSS 和 scavenger

很多應用程式都會有不同的階段。啟動階段、穩定執行階段和(可選)結束階段。每個階段都有不同的記憶體分析資料。啟動階段可能會處理或彙總大量的資料。穩定執行階段可能會消耗和客戶端連線數或請求數成比例的記憶體。關閉階段可能會消耗和穩定執行階段處理的資料量成正比的記憶體,以將資料彙總或寫入到磁碟上。

實際上,您的應用程式在啟動時可能會使用比其餘階段更多的記憶體,然後它的堆將超過必需的記憶體,但大部分未使用。 如果 Go 執行時可以告訴作業系統哪部分堆記憶體是沒有被用到的,這將很有用。

Go 1.13 中的新特性 自從 Go 1.1 中實現了 scavenger 後,基本沒有變更過。在 Go 1.13 中,scavenging 從後臺週期性的操作遷移到了某些命令驅動,因此,沒有從 scavenging 受益的程式不會為此付出代價,因為記憶體分配變化很大且長時間執行的程式應該會更有效的將記憶體歸還給作業系統。 但是,一些與清除相關的 CL 尚未提交。 這項工作可能要到 Go 1.14 才能完成。

5.2.3 GC 監控

一種監控垃圾收集器最簡單的方法是啟用 GC 日誌記錄輸出。

這些統計資訊始終會收集,但通常會被禁止顯示,您可以通過設定GODEBUG環境變數來啟用它們的顯示。

% env GODEBUG=gctrace=1 godoc -http=:8080
gc 1 @0.012s 2%: 0.026+0.39+0.10 ms clock, 0.21+0.88/0.52/0+0.84 ms cpu, 4->4->0 MB, 5 MB goal, 8 P
gc 2 @0.016s 3%: 0.038+0.41+0.042 ms clock, 0.30+1.2/0.59/0+0.33 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 3 @0.020s 4%: 0.054+0.56+0.054 ms clock, 0.43+1.0/0.59/0+0.43 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 4 @0.025s 4%: 0.043+0.52+0.058 ms clock, 0.34+1.3/0.64/0+0.46 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 5 @0.029s 5%: 0.058+0.64+0.053 ms clock, 0.46+1.3/0.89/0+0.42 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 6 @0.034s 5%: 0.062+0.42+0.050 ms clock, 0.50+1.2/0.63/0+0.40 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 7 @0.038s 6%: 0.057+0.47+0.046 ms clock, 0.46+1.2/0.67/0+0.37 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 8 @0.041s 6%: 0.049+0.42+0.057 ms clock, 0.39+1.1/0.57/0+0.46 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 9 @0.045s 6%: 0.047+0.38+0.042 ms clock, 0.37+0.94/0.61/0+0.33 ms cpu, 4->4->1 MB, 5 MB goal, 8 P

跟蹤結果顯示了 GC 活動的通用測量結果。gctrace=1 的輸出格式是在runtime 包文件中描述的。

DEMO: Show godoc with GODEBUG=gctrace=1 enabled 示例:開啟 GODEBUG=gctrace=1 以展示 godoc 結果

說明:在生產環境中使用該選項,對效能沒有任何影響,

當你知道有問題的時候使用 GODEBUG=gctrace=1 是非常好的選擇,但對於應用程式的常規檢測,我推薦使用 net/http/pprof 介面。

import _ "net/http/pprof"

匯入 net/http/pprof 包的時候,將會在/debug/pprof 中註冊一個使用各種執行時指標的的處理器。包括:

  • 執行時協程列表,/debug/pprof/goroutine?debug=1
  • 靜態記憶體分配報告,/debug/pprof/heap?debug=1

注意 net/http/pprof 將使用預設的 http.ServerMux 註冊自己。如果你使用 http.ListenAndServe(address, nil) 時,要當心會暴露自己。

示例:godoc -http=:8080, 展示/debug/pprof

5.3 最小化記憶體分配

事實上,記憶體分配都是有代價開銷的,無論你的語言是否是自動垃圾回收的還是手動回收的。

記憶體分配可能是整個程式碼庫的開銷。每一個都只佔執行時的一小部分時間,但總的來說,他們代表了相當大的成本。因為這個成本貫穿在很多地方,確定最大的開銷者可能很複雜,並且通常需要重新設計 API 介面。

每次分配都應按需分配 。 打個比方:如果您因為打算建立家庭而搬到更大的房子,那將是對您的資本的充分利用。 如果您因為某人要您照顧孩子一個下午而搬到更大的房子,那將浪費您的資金。

5.3.1 strings vs [] bytes

In Go string values are immutable, [] byte are mutable. 在 Go 語言中,字串值是不可變的,[] byte 是可變的。

大多數應用程式都喜歡使用 string,但是大多數 IO 操作都是用 [] byte 完成的。

儘可能的避免將 [] byte 轉換為字串,這通常意味著選擇一種表示形式,是選擇字串,還是選擇 [] byte 來儲存值。 如果您從網路或磁碟讀取資料,則通常為 [] byte。

Go 中的 bytes package 包含許多與 string package 相同的操作 -- Split,Compare,HasPrefix,Trim 等。

在底層實現中,字串和 bytes 使用相同的彙編原語。

5.3.2 用 [] byte 作為 map 的 key

使用 string 作為 map 的 key 是非常常見的,但有時候你會使用位元組陣列([] byte)作為 map 的 key。換句話說,你可能具有 [] byte 形式的鍵,但是切片又沒有定義等價的運算子,因此 [] bytes 不能用作 map 的 key。

編譯器針對這種情況實現了特定的優化:

var m map[string]string
v, ok := m[string(bytes)]

在 map 的 key 查詢中,這將避免從 byte slice 到字串的轉換。這是非常特殊的,如果你做如下操作,那麼將不會工作:

key := string(bytes)
val, ok := m[key]

5.3.3 [] byte 到 string 的轉換

這是 Go 1.13 版本中的新特性

就像在 map 的 key 中會自動將 [] byte 轉換成字串一樣,在比較兩個 [] byte 切片是否相等時也需要將位元組切片 [] byte 轉換成字串後再進行比較 - 本質上是對位元組切片的內容做了一個拷貝,或者是使用位元組切片型別的比較函式:bytes.Equal

好訊息是,在 Go 1.13 中編譯器已經對位元組切片轉換成字串進行了改進,下面的是對位元組切片進行比較時避免了記憶體分配的測試:

func BenchmarkBytesEqualInline(b *testing.B) {
    x := bytes.Repeat([]byte{'a'}, 1<<20)
    y := bytes.Repeat([]byte{'a'}, 1<<20)
    b.ReportAllocs()
    b.ResetTimer()
    for n := 0; n < b.N; n++ {
        if string(x) != string(y) {
            b.Fatal("x != y")
        }
    }
}

func BenchmarkBytesEqualExplicit(b *testing.B) {
    x := bytes.Repeat([]byte{'a'}, 1<<20)
    y := bytes.Repeat([]byte{'a'}, 1<<20)
    b.ReportAllocs()
    b.ResetTimer()
    for n := 0; n < b.N; n++ {
        q := string(x)
        r := string(y)
        if q != r {
            b.Fatal("x != y")
        }
    }
}

更進一步閱讀 [https://go-review.googlesource.com/c/go/+/173323]

5.3.4 避免字串連線

Go 語言中的字串是不可變的,連線兩個字串將會產生第三個字串。下面的程式哪個會更快?

s := request.ID
s += " " + client.Addr().String()
s += " " + time.Now().String()
r = s
var b bytes.Buffer
fmt.Fprintf(&b, "%s %v %v", request.ID, client.Addr(), time.Now())
r = b.String()
r = fmt.Sprintf("%s %v %v", request.ID, client.Addr(), time.Now())
b := make([]byte, 0, 40)
b = append(b, request.ID...)
b = append(b, ' ')
b = append(b, client.Addr().String()...)
b = append(b, ' ')
b = time.Now().AppendFormat(b, "2006-01-02 15:04:05.999999999 -0700 MST")
r = string(b)
var b strings.Builder
b.WriteString(request.ID)
b.WriteString(" ")
b.WriteString(client.Addr().String())
b.WriteString(" ")
b.WriteString(time.Now().String())
r = b.String()

DEMO: go test -bench=. ./examples/concat

5.3.5 不要在你的 API 呼叫方上強制分配記憶體

確保你的 API 呼叫方減少記憶體垃圾生成的數量

考慮以下兩種 Read 方法:

func (r *Reader) Read() ([]byte, error)
func (r *Reader) Read(buf []byte) (int, error)

第一個方法是不帶任何引數的,並且返回一個 [] byte。第二個函式時帶一個 [] byte 的 buf 引數,並且返回的是讀取到的位元組數量。

第一個 Read 方法呼叫方總是會分配一個位元組切片來接收返回的值,這在 GC 上帶來壓力。第二個方法是將讀到的資料填充到已經給的 buffer 中。 譯者注:第一個方法是因為在 Read 函式中分配的位元組切片指向的記憶體逃逸到了堆上,而堆記憶體是需要進行 GC 回收的,所以會增加 GC 的壓力。但第二個方法是隻有呼叫者分配了一次記憶體,被呼叫者共享了傳入進來的記憶體

5.3.6 如果切片長度可知則可預先分配

Append 函式非常方便,但也比較浪費資源,

Slice 的空間擴容首先會按成倍的方式直至擴容到 1024 個元素,然後每次擴容會按原容量 25% 的增速擴容。那麼,以下程式碼中,我們使用 append 往 b 中新增 1 個或多個元素後其容量是多少?

func main() {
   b := make([]int, 1024)
   b = append(b, 99)
   fmt.Println("len:", len(b), "cap:", cap(b))
}

如果你使用 append 模式,那麼你可能會拷貝大量的資料,同時也會產生大量的垃圾。

如果能提前知道切片的長度,然後預分配目標大小的切片容量,就可以避免資料的拷貝,並能依然能夠確保容量的正確性

之前:

var s []string
for _, v := range fn() {
        s = append(s, v)
}
return s

之後:

vals := fn()
s := make([]string, len(vals))
for i, v := range vals {
        s[i] = v
}
return s

5.4 使用 sync.Pool

注意:sync.Pool 不是快取。它在任何時刻都可能被清空。不要把任何重要的資料放在 sync.Pool 中,他們有可能會被刪除。

sync 包中的 sync.Pool 被用於物件的重用。 sync.Pool 沒有固定的大小或最大容量。你可以增加一個物件到 sync.Pool 中,也可以從 sync.Pool 中獲取一個物件,直到有 GC 發生,sync.Pool 將會被無條件的被清空。這是就這樣設計的。

sync.Pool 實踐:

var pool = sync.Pool{New: func() interface{} { return make([]byte, 4096) }}

func fn() {
    buf := pool.Get().([]byte) // takes from pool or calls New
    // do work
    pool.Put(buf) // returns buf to the pool
}

5.5 重排結構體的欄位順序以便更好的壓縮

考慮以下結構體的定義

type S struct {
    a bool
    b float64
    c int32
}

那麼,一個該型別的變數值將佔用多少記憶體呢?

var s S
fmt.Println(unsafe.Sizeof(s)) 

結果是在 64 位系統上是 24 位元組,在 32 位系統上是 16 位元組。

這是為什麼呢?這不得不從記憶體對齊說起(padding and alignment)

float64 型別的值在所有的平臺上都是 8 位元組的,因此它們必須始終位於 8 的倍數的地址處。這叫做自然對齊。因為 CPU 自然希望長度為 4 位元組的欄位在 4 位元組邊界上對齊,8 位元組的欄位在 8 位元組邊界上對齊,依此類推。這是因為某些平臺(尤其不是 Intel)不允許您對未正確對齊的值進行操作。 即使在確實支援所謂的不對齊訪問的平臺上,訪問這些欄位通常也會產生成本。

瞭解了記憶體對齊之後,我們就能知道編譯器是如何將這些欄位排列在記憶體上的了:

type S struct {
    a bool
    _ [7]byte // padding 註釋1
    b float64
    c int32
    _ [4]byte // padding 
}
  • 在 a bool 欄位上,bool 值原本佔 1 個位元組,所以需要額外填充 7 個位元組,以確保 b float64 是從 8 位元組的邊界上開始的。
  • 在 c int32 欄位刪個,int32 值原本佔 4 個位元組,所以需要額外填充 4 個位元組以確保 S 陣列或切片在記憶體上整體排列的正確性。

深入閱讀:Padding is hard

更多原創文章乾貨分享,請關注公眾號
  • Go 高效能系列教程之五:記憶體和垃圾回收
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章