高效能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 sliceb
的底層陣列都逃逸,因此在堆上進行分配。
以我們的經驗來講,這四種情況是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.Time
的Format
這裡,它返回了一個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,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",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!
總結
我們來總結一下關鍵點:
- 不要過早優化!使用資料來驅動優化工作
- 棧分配廉價,堆分配昂貴
- 瞭解逃逸分析的規則能夠讓我們寫出更高效的程式碼
- 使用指標幾乎不會在棧上分配
- 效能關鍵的程式碼段中尋找提供分配控制的API
- 在熱程式碼路徑裡謹慎地使用介面型別