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

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

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

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

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

使用工具

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

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

逃逸分析

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

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

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

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

編譯器使用逃逸分析技術去選擇堆或者棧。基本思想是在編譯時期進行垃圾收集工做。編譯器追蹤代碼域變量的做用範圍。它使用追蹤數據來檢查哪些變量的生命週期是徹底可知的。若是變量經過這些檢查,則能夠在棧上進行分配。若是沒經過,也就是所說的逃逸,則必須在堆上分配。

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. 在熱代碼路徑裏謹慎地使用接口類型
原文連接:segment.com/blog/alloca…
相關文章
相關標籤/搜索