高性能go服務之高效內存分配


高性能go服務之高效內存分配

手動內存管理真的很坑爹(如C C++),好在我們有強大的自動化系統能夠管理內存分配和生命周期,從而解放我們的雙手。

但是呢,如果你想通過調整JVM垃圾回收器參數或者是優化go代碼的內存分配模式話來解決問題的話,這是遠遠不夠的。自動化的內存管理幫我們規避了大部分的錯誤,但這只是故事的一半。我們必須要合理有效構建我們的軟件,這樣垃圾回收系統可以有效工作。

在構建高性能go服務Centrifuge時我們學習到的內存相關的東西,在這里進行分享。Centrifuge每秒鍾可以處理成百上千的事件。Centrifuge是Segment公司基礎設施的關鍵部分。一致性、行為可預測是必須的。整潔、高效和精確的使用內存是實現一致性的重要部分。

這篇文章,我們將介紹導致低效率和與內存分配相關的生產意外的常見模式,以及消除這些問題的實用方法。我們會專注於分配器的核心機制,為廣大開發人員提供一種處理內存使用的方法。

使用工具

首先我們建議的是避免過早進行優化。Go提供了出色的分析工具,能夠直接指向內存分配密集的代碼部分。沒有必要重新造輪子,我們直接參考Go官方這篇文章即可。它為使用pprof進行CPU和分配分析提供了可靠的demo。我們在Segment中用於查找生產Go代碼中的瓶頸的工具就是它,學會使用pprof是基本要求。

另外,使用數據去推動你的優化。

逃逸分析

Go能夠自動管理內存分配。這可以防止一大類潛在錯誤,但是不能說完全不去了解分配的機制。

首先要記住一點:棧分配是很廉價的而堆分配代價是昂貴的。我們來看一下具體含義。

Go在兩個地方分配內存:用於動態分配的全局堆,以及用於每個goroutine的局部棧。Go偏向於在棧中分配----大多數go程序的分配都是在棧上面的。棧分配很廉價,因為它只需要兩個CPU指令:一個是分配入棧,另一個是棧內釋放。

但是不幸的是,不是所有數據都能使用棧上分配的內存。棧分配要求可以在編譯時確定變量的生存期和內存占用量。然而堆上的動態分配發生在運行時。malloc必須去找一塊兒足夠大的空閑內存來保存新值。然后垃圾收集器掃描堆以查找不再引用的對象。毫無疑問,它比堆棧分配使用的兩條指令要貴得多。

編譯器使用逃逸分析技術去選擇堆或者棧。基本思想是在編譯時期進行垃圾收集工作。編譯器追蹤代碼域變量的作用范圍。它使用追蹤數據來檢查哪些變量的生命周期是完全可知的。如果變量通過這些檢查,則可以在棧上進行分配。如果沒通過,也就是所說的逃逸,則必須在堆上分配。

go語言里沒有明確說明逃逸分析規則。對於Go程序員來說,最直接去了解規則的方式就是去實驗。通過構建時候加上go build -gcflags '-m',可以看到逃逸分析結果。我們看一個例子。

package main

import "fmt" func main() { x := 42 fmt.Println(x) } 
$ go build -gcflags '-m' ./main.go # command-line-arguments ./main.go:7: x escapes to heap ./main.go:7: main ... argument does not escape 

我們這里看到變量x“逃逸到堆上”,因為它是在運行時期動態在堆上分配的。這個例子可能有點困惑。我們肉眼看上去,顯然x變量在main()方法上不會逃逸。編譯器輸出並沒有解釋為什么它會認為變量逃逸了。為了看到更多細節,再加上一個-m參數,可以看到更多輸出

$ go build -gcflags '-m -m' ./main.go # command-line-arguments ./main.go:5: cannot inline main: non-leaf function ./main.go:7: x escapes to heap ./main.go:7: from ... argument (arg to ...) at ./main.go:7 ./main.go:7: from *(... argument) (indirection) at ./main.go:7 ./main.go:7: from ... argument (passed to call[argument content escapes]) at ./main.go:7 ./main.go:7: main ... argument does not escape 

這說明,x逃逸是因為它被傳入一個方法參數里,這個方法參數自己逃逸了。后面可以看到更多這種情況。

規則可能看上去是隨意的,經過工具的嘗試,一些規律顯現出來。這里列出了一些典型的導致逃逸的情況:

  • 發送指針或者是帶有指針的值到channel里。編譯時期沒有辦法知道哪個goroutine會受到channel中的數據。因此編譯器無法確定這個數據什么時候不再被引用到。
  • 在slice中存儲指針或者是帶有指針的值。這種情況的一個例子是[]*string。它總會導致slice中的內容逃逸。盡管切片底層的數組還是在堆上,但是引用的數據逃逸到堆上了。
  • slice底層數組由於append操作超過了它的容量,它會重新分片內存。如果在編譯時期知道切片的初始大小,則它會在棧上分配。如果切片的底層存儲必須被擴展,數據在運行時才獲取到。則它將在堆上分配。
  • 在接口類型上調用方法。對接口類型的方法調用是動態調用--接口的具體實現只有在運行時期才能確定。考慮一個接口類型為io.Reader的變量r。對r.Read(b)的調用將導致r的值和byte slice b的底層數組都逃逸,因此在堆上進行分配。

以我們的經驗來講,這四種情況是Go程序中最常見的動態分配情況。對於這些情況還是有一些解決方案的。接下來,我們將深入探討如何解決生產軟件中內存低效問題的一些具體示例。

指針相關

經驗法則是:指針指向堆上分配的數據。 因此,減少程序中指針的數量會減少堆分配的數量。 這不是公理,但我們發現它是現實世界Go程序中的常見情況。

我們直覺上得出的一個常見的假設是這樣的:“復制值代價是昂貴的,所以我會使用指針。”然而在許多情況下,復制值比使用指針的開銷要便宜的多。你可能會問這是為什么。

  • 在解引用一個指針的時候,編譯器會生成檢查。它的目的是,如果指針是nil的話,通過運行panic()來避免內存損壞。這部分額外代碼必須在運行時去運行。如果數據按值傳遞,它不會是nil。
  • 指針通常具有較差的引用局部性。函數中使用的所有值都在並置在堆棧內存中。引用局部性是代碼高效的一個重要方面。它極大增加了變量在CPU caches中變熱的可能性,並降低了預取時候未命中風險。
  • 復制緩存行中的對象大致相當於復制單個指針。 CPU在緩存層和主存在常量大小的緩存行上之間移動內存。 在x86上,cache行是64個字節。 此外,Go使用一種名為Duff`s devices的技術,使拷貝等常見內存操作非常高效。

指針應主要用於反映成員所有關系以及可變性。實際中,使用指針避免復制應該是不常見的。不要陷入過早優化陷阱。按值傳遞數據習慣是好的,只有在必要的時候才去使用指針傳遞數據。另外,值傳遞消除了nil從而增加了安全性。

減少程序中指針的數量可以產生另一個有用的結果,因為垃圾收集器將跳過不包含指針的內存區域。例如,根本不掃描返回類型為[]byte 的切片的堆區域。對於不包含任何具有指針類型字段的結構類型數組,也同樣適用。

減少指針不僅減少垃圾回收的工作量,還會生存出”cache友好“的代碼。讀取內存會將數據從主存移到CPU cache中。Caches是優先的,因此必須清掉一些數據來騰出空間。cache清掉的數據可能會和程序的其它部分相關。由此產生的cache抖動可能會導致不可預期行為和突然改變生產服務的行為。

指針深入

減少指針使用通常意需要味着深入研究用於構建程序的類型的源代碼。我們的服務Centrifuge保留了一個失敗操作隊列,來作為循環緩沖區重試去進行重試,其中包含一組如下所示的數據結構:

type retryQueue struct { buckets [][]retryItem // each bucket represents a 1 second interval currentTime time.Time currentOffset int } type retryItem struct { id ksuid.KSUID // ID of the item to retry time time.Time // exact time at which the item has to be retried } 

數組buckets的外部大小是一個常量值,但是[]retryItem所包含的items會在運行時期改變。重試次數越多,這些slices就變越大。

深入來看一下retryItem細節,我們了解到KSUID是一個[20]byte的同名類型,不包含指針,因此被逃逸規則排除在外。currentOffset是一個int值,是一個固定大小的原始值,也可以排除。下面看一下,time.Time的實現:

type Time struct { sec int64 nsec int32 loc *Location // pointer to the time zone structure } 

time.Time結構內部包含一個loc的指針。在retryItem內部使用它導致了在每次變量通過堆區域時候,GC都會去標記struct上的指針。

我們發現這是在不可預期情況下級聯效應的典型情況。通常情況下操作失敗是很少見的。只有小量的內存去存這個retries的變量。當失敗操作激增,retry隊列會每秒增加到上千個,這會大大增加垃圾回收器的工作量。

對於這種特殊使用場景,time.Time的time信息其實是不必要的。這些時間戳存在內存中,永遠不會被序列化。可以重構這些數據結構以完全避免time類型出現。

type retryItem struct { id ksuid.KSUID nsec uint32 sec int64 } func (item *retryItem) time() time.Time { return time.Unix(item.sec, int64(item.nsec)) } func makeRetryItem(id ksuid.KSUID, time time.Time) retryItem { return retryItem{ id: id, nsec: uint32(time.Nanosecond()), sec: time.Unix(), } 

現在retryItem不包含任何指針。這樣極大的減少了垃圾回收器的工作負載,編譯器知道retryItem的整個足跡。

請給我傳切片(Slice)

slice使用很容易會產生低效分配代碼。除非編譯器知道slice的大小,否則slice(和maps)的底層數組會分配到堆上。我們來看一下一些方法,讓slice在棧上分配而不是在堆上。

Centrifuge集中使用了Mysql。整個程序的效率嚴重依賴了Mysql driver的效率。在使用pprof去分析了分配行為之后,我們發現Go MySQL driver代碼序列化time.Time值的代價十分昂貴。

分析器顯示大部分堆分配都在序列化time.Time的代碼中。

 

 

相關代碼在調用time.TimeFormat這里,它返回了一個string。等會兒,我們不是在說slices么?好吧,根據Go官方文檔,一個string其實就是個只讀的bytes類型slices,加上一點額外的語言層面的支持。大多數分配規則都適用!

分析數據告訴我們大量分配,即12.38%都產生在運行的這個Format方法里。這個Format做了些什么?

 

 

事實證明,有一種更加有效的方式來做同樣的事情。雖然Format()方法方便容易,但是我們使用AppendFormat()在分配器上會更輕松。觀察源碼庫,我們注意到所有內部的使用都是AppendFormat()而非Format(),這是一個重要提示,AppendFormat()的性能更高。

 

 

實際上,Format方法僅僅是包裝了一下AppendFormat方法:

func (t Time) Format(layout string) string {
          const bufSize = 64
          var b []byte
          max := len(layout) + 10
          if max < bufSize { var buf [bufSize]byte b = buf[:0] } else { b = make([]byte, 0, max) } b = t.AppendFormat(b, layout) return string(b) } 

更重要的是,AppendFormat()給程序員提供更多分配控制。傳遞slice而不是像Format()自己在內部分配。相比Format,直接使用AppendFormat()可以使用固定大小的slice分配,因此內存分配會在棧空間上面。

可以看一下我們給Go MySQL driver提的這個PR

 

 

首先注意到var a [64]byte是一個大小固定的數組。編譯期間我們知道它的大小,以及它的作用域僅在這個方法里,所以我們知道它會被分配在棧空間里。

但是這個類型不能傳給AppendFormat(),該方法只接受[]byte類型。使用a[:0]的表示法將固定大小的數組轉換為由此數組所支持的b表示的切片類型。這樣可以通過編譯器檢查,並且會在棧上面分配內存。

更關鍵的是,AppendFormat(),這個方法本身通過編譯器棧分配檢查。而之前版本Format(),編譯器不能確定需要分配的內存大小,所以不滿足棧上分配規則。

這個小的改動大大減少了這部分代碼的堆上分配!類似於我們在MySQL驅動里使用的“附加模式”。在這個PR里,KSUID類型使用了Append()方法。在熱路徑代碼中,KSUID使用Append()模式處理大小固定的buffer而不是String()方法,節省了類似的大量動態堆分配。 另外值得注意的是,strconv包使用了相同的append模式,用於將包含數字的字符串轉換為數字類型。

接口類型

眾所周知,接口類型上進行方法調用比struct類型上進行方法調用要昂貴的多。接口類型的方法調用通過動態調度執行。這嚴重限制了編譯器確定代碼在運行時執行方式的能力。到目前為止,我們已經在很大程度上討論了類型固定的代碼,以便編譯器能夠在編譯時最好地理解它的行為。 接口類型拋棄了所有這些規則!

不幸的是接口類型在抽象層面非常有用 --- 它可以讓我們寫出更加靈活的代碼。程序里常用的熱路徑代碼的相關實例就是標准庫提供的hash包。hash包定義了一系列常規接口並提供了幾個具體實現。我們看一個例子。

package main

import (
        "fmt" "hash/fnv" ) func hashIt(in string) uint64 { h := fnv.New64a() h.Write([]byte(in)) out := h.Sum64() return out } func main() { s := "hello" fmt.Printf("The FNV64a hash of '%v' is '%v'\n", s, hashIt(s)) } 

構建檢查逃逸分析結果:

./foo1.go:9:17: inlining call to fnv.New64a
./foo1.go:10:16: ([]byte)(in) escapes to heap ./foo1.go:9:17: hash.Hash64(&fnv.s·2) escapes to heap ./foo1.go:9:17: &fnv.s·2 escapes to heap ./foo1.go:9:17: moved to heap: fnv.s·2 ./foo1.go:8:24: hashIt in does not escape ./foo1.go:17:13: s escapes to heap ./foo1.go:17:59: hashIt(s) escapes to heap ./foo1.go:17:12: main ... argument does not escape 

也就是說,hash對象,輸入字符串,以及代表輸入的[]byte全都會逃逸到堆上。我們肉眼看上去顯然不會逃逸,但是接口類型限制了編譯器。不通過hash包的接口就沒有辦法安全地使用具體的實現。 那么效率相關的開發人員應該做些什么呢?

我們在構建Centrifuge的時候遇到了這個問題,Centrifuge在熱代碼路徑對小字符串進行非加密hash。因此我們建立了fasthash庫。構建它很直接,困難工作依舊在標准庫里做。fasthash只是在沒有使用堆分配的情況下重新打包了標准庫。

直接來看一下fasthash版本的代碼

package main

import (
        "fmt" "github.com/segmentio/fasthash/fnv1a" ) func hashIt(in string) uint64 { out := fnv1a.HashString64(in) return out } func main() { s := "hello" fmt.Printf("The FNV64a hash of '%v' is '%v'\n", s, hashIt(s)) } 

看一下逃逸分析輸出

./foo2.go:9:24: hashIt in does not escape ./foo2.go:16:13: s escapes to heap ./foo2.go:16:59: hashIt(s) escapes to heap ./foo2.go:16:12: main ... argument does not escape 

唯一產生的逃逸就是因為fmt.Printf()方法的動態特性。盡管通常我們更喜歡是用標准庫,但是在一些情況下需要進行權衡是否要提高分配效率。

一個小竅門

我們最后這個事情,不夠實際但是很有趣。它有助我們理解編譯器的逃逸分析機制。 在查看所涵蓋優化的標准庫時,我們遇到了一段相當奇怪的代碼。

// noescape hides a pointer from escape analysis.  noescape is
// the identity function but escape analysis doesn't think the // output depends on the input. noescape is inlined and currently // compiles down to zero instructions. // USE CAREFULLY! //go:nosplit func noescape(p unsafe.Pointer) unsafe.Pointer { x := uintptr(p) return unsafe.Pointer(x ^ 0) } 

這個方法會讓傳遞的指針逃過編譯器的逃逸分析檢查。那么這意味着什么呢?我們來設置個實驗看一下。

package main

import (
        "unsafe" ) type Foo struct { S *string } func (f *Foo) String() string { return *f.S } type FooTrick struct { S unsafe.Pointer } func (f *FooTrick) String() string { return *(*string)(f.S) } func NewFoo(s string) Foo { return Foo{S: &s} } func NewFooTrick(s string) FooTrick { return FooTrick{S: noescape(unsafe.Pointer(&s))} } func noescape(p unsafe.Pointer) unsafe.Pointer { x := uintptr(p) return unsafe.Pointer(x ^ 0) } func main() { s := "hello" f1 := NewFoo(s) f2 := NewFooTrick(s) s1 := f1.String() s2 := f2.String() } 

這個代碼包含兩個相同任務的實現:它們包含一個字符串,並使用String()方法返回所持有的字符串。但是,編譯器的逃逸分析說明FooTrick版本根本沒有逃逸。

./foo3.go:24:16: &s escapes to heap
./foo3.go:23:23: moved to heap: s
./foo3.go:27:28: NewFooTrick s does not escape
./foo3.go:28:45: NewFooTrick &s does not escape
./foo3.go:31:33: noescape p does not escape
./foo3.go:38:14: main &s does not escape
./foo3.go:39:19: main &s does not escape
./foo3.go:40:17: main f1 does not escape
./foo3.go:41:17: main f2 does not escape

這兩行是最相關的

./foo3.go:24:16: &s escapes to heap
./foo3.go:23:23: moved to heap: s

這是編譯器認為NewFoo()``方法把拿了一個string類型的引用並把它存到了結構體里,導致了逃逸。但是NewFooTrick()方法並沒有這樣的輸出。如果去掉noescape(),逃逸分析會把FooTrick結構體引用的數據移動到堆上。這里發生了什么?

func noescape(p unsafe.Pointer) unsafe.Pointer {
    x := uintptr(p)
    return unsafe.Pointer(x ^ 0) } 

noescape()方法掩蓋了輸入參數和返回值直接的依賴關系。編譯器不認為p會通過x逃逸,因為uintptr()會產生一個對編譯器不透明的引用。內置的uintptr類型的名稱會讓人相信它是一個真正的指針類型,但是從編譯器的視角來看,它只是一個恰好大到足以存儲指針的整數。最后一行代碼構造並返回了一個看似任意整數的unsafe.Pointer值。

一定要清楚,我們並不推薦使用這種技術。這也是為什么它引用的包叫做unsafe,並且注釋里寫着USE CAREFULLY!

總結

我們來總結一下關鍵點:

  1. 不要過早優化!使用數據來驅動優化工作
  2. 棧分配廉價,堆分配昂貴
  3. 了解逃逸分析的規則能夠讓我們寫出更高效的代碼
  4. 使用指針幾乎不會在棧上分配
  5. 性能關鍵的代碼段中尋找提供分配控制的API
  6. 在熱代碼路徑里謹慎地使用接口類型


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM