當(dāng)分配的內(nèi)存量相對較小時(shí),Go垃圾收集器(GC)工作得非常好,但是如果堆大小較大,GC最終可能會使用大量的CPU。在極端情況下,它可能無法跟上。
有什么問題?
GC的工作是確定哪些內(nèi)存塊可以釋放,它通過掃描指向分配的內(nèi)存的指針來實(shí)現(xiàn)這一點(diǎn)。簡單地說,如果沒有指向分配內(nèi)存的指針,那么可以釋放這個內(nèi)存。這很有效,但是掃描內(nèi)存越多,掃描時(shí)間就越長。
假設(shè)您已經(jīng)編寫了一個內(nèi)存中的數(shù)據(jù)庫,或者您正在構(gòu)建一個需要一個巨大的查找表的pipeline。在這些場景中,您可能分配了千兆字節(jié)的內(nèi)存。在這種情況下,GC可能會損失相當(dāng)多的潛在性能。
這個是個大問題嗎?
有多少問題?讓我們看看!這里有一個小程序要演示。我們分配了10億(1E9)個8字節(jié)指針,因此大約有8GB的內(nèi)存。然后我們強(qiáng)制一個GC并計(jì)算它需要多長時(shí)間。我們這樣做幾次,得到一個穩(wěn)定的值。我們還調(diào)用runtime.keepalive()以確保GC/編譯器不會同時(shí)丟棄我們的分配。
func main() {
a := make([]*int, 1e9)
for i := 0; i < 10; i++ {
start := time.Now()
runtime.GC()
fmt.Printf("GC took %s\n", time.Since(start))
}
runtime.KeepAlive(a)
}
我得到如下輸出:
GC took 4.275752421s
GC took 1.465274593s
GC took 652.591348ms
GC took 648.295749ms
GC took 574.027934ms
GC took 560.615987ms
GC took 555.199337ms
GC took 1.071215002s
GC took 544.226187ms
GC took 545.682881ms
GC需要半秒鐘。為什么這會令人驚訝呢?我已經(jīng)分配了10億個指針。實(shí)際上,檢查每個指針不到一納秒,這是一個很好的速度。
那么接下來呢
這似乎是一個根本問題。如果我們的應(yīng)用程序需要一個大的內(nèi)存查找表,或者如果我們的應(yīng)用程序從根本上是一個大的內(nèi)存查找表,那么我們就遇到了一個問題。如果GC堅(jiān)持定期掃描我們分配的所有內(nèi)存,我們將失去GC大量可用的處理能力。我們該怎么辦?
讓GC變得遲鈍
GC怎么才能變遲鈍?嗯,GC正在尋找指針。如果我們分配的對象的類型不包含指針怎么辦?GC還會掃描它嗎?
我們可以試試。在下面的示例中,我們分配的內(nèi)存量與以前完全相同,但現(xiàn)在我們的分配中沒有指針類型。我們分配了一個10億個8字節(jié)的內(nèi)存片,這也是大約8GB的內(nèi)存。
func main() {
a := make([]int, 1e9)
for i := 0; i < 10; i++ {
start := time.Now()
runtime.GC()
fmt.Printf("GC took %s\n", time.Since(start))
}
runtime.KeepAlive(a)
}
再次運(yùn)行:
GC took 350.941μs
GC took 179.517μs
GC took 169.442μs
GC took 191.353μs
GC took 126.585μs
GC took 127.504μs
GC took 111.425μs
GC took 163.378μs
GC took 145.257μs
GC took 144.757μs
由于分配的內(nèi)存量完全相同,GC的速度要快1000倍多。結(jié)果表明,Go內(nèi)存管理器知道每個分配的類型,并將標(biāo)記不包含指針的分配,這樣GC就不必掃描它們。如果我們可以安排內(nèi)存表沒有指針,那么我們就是贏家。
隱藏內(nèi)存
我們可以做的另一件事是對GC隱藏分配的內(nèi)存。如果我們直接向操作系統(tǒng)請求內(nèi)存,GC就永遠(yuǎn)不會發(fā)現(xiàn)它,因此不會掃描它。這比我們前面的例子要復(fù)雜一點(diǎn)!
這類似于我們的第一個程序,我們分配了一個帶有10億(1E9)元素的[]*int。這次,我們使用mmap系統(tǒng)調(diào)用直接從操作系統(tǒng)內(nèi)核請求內(nèi)存。注意:這是在類似Unix的操作系統(tǒng)上工作,但是在Windows上也可以做類似的事情。
package main
import (
"fmt"
"reflect"
"runtime"
"syscall"
"time"
"unsafe"
)
func main() {
var example *int
slice := makeSlice(1e9, unsafe.Sizeof(example))
a := *(*[]*int)(unsafe.Pointer(&slice))
for i := 0; i < 10; i++ {
start := time.Now()
runtime.GC()
fmt.Printf("GC took %s\n", time.Since(start))
}
runtime.KeepAlive(a)
}
func makeSlice(len int, eltsize uintptr) reflect.SliceHeader {
fd := -1
data, _, errno := syscall.Syscall6(
syscall.SYS_MMAP,
0, // address
uintptr(len)*eltsize,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_ANON|syscall.MAP_PRIVATE,
uintptr(fd), // No file descriptor
0, // offset
)
if errno != 0 {
panic(errno)
}
return reflect.SliceHeader{
Data: data,
Len: len,
Cap: len,
}
}
輸出:
GC took 460.777μs
GC took 206.805μs
GC took 174.58μs
GC took 193.697μs
GC took 184.325μs
GC took 142.556μs
GC took 132.48μs
GC took 155.853μs
GC took 138.54μs
GC took 159.04μs
要了解a := *(*[]*int)(unsafe.Pointer(&slice))查看連接:https://blog.gopheracademy.com/advent-2017/unsafe-pointer-and-system-calls/
現(xiàn)在,這些內(nèi)存對GC不可見。這就產(chǎn)生了一個有趣的結(jié)果,即存儲在此內(nèi)存中的指針不會停止GC收集它們指向的“正?!狈峙涞膬?nèi)存。這會帶來很壞的后果,很容易證明這一點(diǎn)。
在這里,我們嘗試將數(shù)字0、1和2存儲在分配給堆的int中,并將指向它們的指針存儲在分配給堆外mmap的切片中。我們在使指針指向分配的每個int之后強(qiáng)制GC。
func main() {
var example *int
slice := makeSlice(3, unsafe.Sizeof(example))
a := *(*[]*int)(unsafe.Pointer(&slice))
for j := range a {
a[j] = getMeAnInt(j)
fmt.Printf("a[%d] is %X\n", j, a[j])
fmt.Printf("*a[%d] is %d\n", j, *a[j])
runtime.GC()
}
fmt.Println()
for j := range a {
fmt.Printf("*a[%d] is %d\n", j, *a[j])
}
}
func getMeAnInt(i int) *int {
b := i
return &b
}
這是我們的輸出。支持ints的內(nèi)存被釋放,并可能在每個gc之后重新使用。但是我們的數(shù)據(jù)并不像我們預(yù)期的那樣,雖然還沒有崩潰。
a[0] is C000016090
*a[0] is 0
a[1] is C00008C030
*a[1] is 1
a[2] is C00008C030
*a[2] is 2
*a[0] is 0
*a[1] is 811295018
*a[2] is 811295018
這樣并不好。如果我們將其更改為使用一個正常分配的[]*int,如下所示,我們將得到預(yù)期的結(jié)果。
func main() {
a := make([]*int, 3)
for j := range a {
a[j] = getMeAnInt(j)
fmt.Printf("a[%d] is %X\n", j, a[j])
fmt.Printf("*a[%d] is %d\n", j, *a[j])
runtime.GC()
}
fmt.Println()
for j := range a {
fmt.Printf("*a[%d] is %d\n", j, *a[j])
}
}
a[0] is C00009A000
*a[0] is 0
a[1] is C00009A040
*a[1] is 1
a[2] is C00009A050
*a[2] is 2
*a[0] is 0
*a[1] is 1
*a[2] is 2
問題的核心
所以,結(jié)果表明指針是敵人,無論是在堆上分配了大量內(nèi)存時(shí),還是在我們試圖通過將數(shù)據(jù)移動到自己的堆外分配來解決這一問題時(shí)。如果我們可以避免分配的類型中的任何指針,它們不會導(dǎo)致GC開銷,因此我們不需要使用任何堆外技巧。如果我們確實(shí)使用堆外分配,那么我們需要避免存儲指向堆的指針,除非這些指針也被GC可見的內(nèi)存引用。
我們怎樣才能避免指針?
在大堆棧中,指針是邪惡的,必須避免。但是你需要能夠發(fā)現(xiàn)它們以避免它們,而且它們并不總是顯而易見的。字符串、切片和時(shí)間。時(shí)間都包含指針。如果你在內(nèi)存中儲存了大量的這些信息,可能需要采取一些步驟。
當(dāng)我遇到大堆的問題時(shí),主要原因如下:
- 大量的string
- 對象中的時(shí)間是time.Time類型
- map中含有slice的值
- map中含有slice的key
關(guān)于處理每一個問題的不同策略,有很多話要說。在這篇文章中,我將討論處理字符串的一個想法。
strings
什么是string?嗯,有兩部分。有一個字符串頭,它告訴你它有多長,以及基礎(chǔ)數(shù)據(jù)在哪里。然后是底層數(shù)據(jù),它只是一個字節(jié)序列。
字符串頭由reflect.string header描述,如下所示。
type StringHeader struct {
Data uintptr
Len int
}
字符串頭包含指針,因此我們希望避免存儲字符串!
- 如果字符串只接受幾個固定值,則考慮改用整型常量。
- 如果您將日期和時(shí)間存儲為字符串,那么可以解析它們并將日期或時(shí)間存儲為整數(shù)。
- 如果你基本上需要存儲大量的字符串,那么繼續(xù)讀下去…
假設(shè)我們存儲了一億個字符串。為了簡單起見,假設(shè)這是一個巨大的全局var mystrings[]字符串。
我們這里有什么?myStrings的底層是一個reflect.sliceHeader,它看起來類似于我們剛才看到的reflect.stringHeader。
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
對于myStrings,len和cap分別為100000000,數(shù)據(jù)將指向一個足夠大的連續(xù)內(nèi)存塊,以包含100000000個stringHeader。這段內(nèi)存包含指針,因此將由GC掃描
strings 本身由兩部分組成。包含在此切片中的StringHeaders,以及每個字符串的數(shù)據(jù),這些字符串是單獨(dú)的分配,它們都不能包含指針。從GC的角度來看,字符串頭是一個問題,而不是字符串?dāng)?shù)據(jù)本身。字符串?dāng)?shù)據(jù)不包含指針,因此不進(jìn)行掃描。巨大的字符串頭數(shù)組確實(shí)包含指針,因此必須在每個GC循環(huán)中進(jìn)行掃描。

我們該怎么辦?好吧,如果所有的字符串字節(jié)都在一塊內(nèi)存中,那么我們可以通過偏移量跟蹤字符串到內(nèi)存中每個字符串的開始和結(jié)束。通過跟蹤偏移量,我們的大塊中不再有指針,GC也不再有問題。

我們通過這樣做放棄的是為單個字符串釋放內(nèi)存的能力,并且我們增加了一些將字符串體復(fù)制到大字節(jié)片中的開銷。
下面是一個演示這個想法的小程序。我們將創(chuàng)建100000000個字符串,將字符串中的字節(jié)復(fù)制到一個大字節(jié)片中,并存儲偏移量。然后我們將顯示gc時(shí)間仍然很短,并演示通過顯示前10個字符串來檢索字符串。
package main
import (
"fmt"
"runtime"
"strconv"
"time"
"unsafe"
)
func main() {
var stringBytes []byte
var stringOffsets []int
for i := 0; i < 1e8; i++ {
val := strconv.Itoa(i)
stringBytes = append(stringBytes, val...)
stringOffsets = append(stringOffsets, len(stringBytes))
}
runtime.GC()
start := time.Now()
runtime.GC()
fmt.Printf("GC took %s\n", time.Since(start))
sStart := 0
for i := 0; i < 10; i++ {
sEnd := stringOffsets[i]
bytes := stringBytes[sStart:sEnd]
stringVal := *(*string)(unsafe.Pointer(&bytes))
fmt.Println(stringVal)
sStart = sEnd
}
}
GC took 187.082μs
0
1
2
3
4
5
6
7
8
9
這里的原則是,如果不需要釋放字符串,可以將其轉(zhuǎn)換為索引,轉(zhuǎn)換為更大的數(shù)據(jù)塊,并避免有大量指針。如果你感興趣的話,我已經(jīng)建立了一個稍微復(fù)雜一點(diǎn)的東西,遵循這個原則。
我以前在 博客中提到過遇到由大型堆引起的垃圾收集器(GC)問題。 好幾次。事實(shí)上,每次我碰到這個問題,我都會感到驚訝,我 震驚的是,我又寫了一篇關(guān)于它的博客。希望通過閱讀到目前為止,如果它發(fā)生在您的項(xiàng)目中,您不會感到驚訝,或者您甚至可以預(yù)見到問題!
以下是一些處理這些問題的有用資源。
- 我上面提到的字符串存儲
- 一個字符串interning 庫,用來存儲字符串到字符串銀行并保證唯一性
- 一個變量,用于轉(zhuǎn)換字符串interning 庫中的唯一字符串和可用于索引到數(shù)組中的序列號。
原文:https://blog.gopheracademy.com/advent-2018/avoid-gc-overhead-large-heaps/