今天講一個(gè)常見(jiàn)的gc compiler(也就是官方版本的go編譯器和runtime)在垃圾回收的掃描標(biāo)記階段做的優(yōu)化。
我對(duì)這個(gè)優(yōu)化的描述印象最深的是在bigcache的注釋里,大致內(nèi)容是如果map的鍵值都不包含指針,那么gc掃描的時(shí)候不管這個(gè)map多大都不會(huì)深入掃描map內(nèi)部存儲(chǔ)的數(shù)據(jù),只檢查map本身是否需要回收。
這么做的好處顯然是可以讓gc的掃描速度大大增加,從而減少gc對(duì)性能的損耗。
減少指針數(shù)量本身就是常見(jiàn)的優(yōu)化手段,但讓我感到好奇的是注釋里說(shuō)的“跳過(guò)”。跳過(guò)的依據(jù)究竟是什么,以及只有map存在這種跳過(guò)嗎?
于是我進(jìn)行了全面的搜索,結(jié)果除了復(fù)讀bigcache里那段話的,沒(méi)什么有用的發(fā)現(xiàn)。
于是這篇文章誕生了。
跳過(guò)掃描指的是什么
前置知識(shí)少不得。
簡(jiǎn)單的說(shuō),gc在檢查對(duì)象是否存活的時(shí)候,除了對(duì)象本身,還要檢查對(duì)象的子對(duì)象是否引用了其他對(duì)象,具體來(lái)說(shuō):
數(shù)組和slice的話指存儲(chǔ)在里面的每一個(gè)元素是否存活,這里被存儲(chǔ)的元素是數(shù)組/slice的子對(duì)象
map的子對(duì)象就是里面存的鍵和值了
struct的子對(duì)象是它的每一個(gè)字段
為了檢查這些子對(duì)象是否引用了其他對(duì)象(關(guān)系到這些被引用的對(duì)象是否能被回收),gc需要深入掃描這些子對(duì)象。子對(duì)象越多需要掃描的東西就越多。而且這個(gè)過(guò)程是遞歸的,因?yàn)樽訉?duì)象也會(huì)有子對(duì)象,想象一下嵌套的數(shù)組或者map。
跳過(guò)掃描自然就是指跳過(guò)這些子對(duì)象的掃描,只需要檢查對(duì)象本身即可的操作。
什么樣的對(duì)象是可以跳過(guò)掃描的
這也是我的第一個(gè)疑問(wèn)。跳過(guò)或不跳過(guò)的依據(jù)是什么,又或者是什么東西在控制這一過(guò)程。
bigcache告訴我們存有不包含指針的鍵值對(duì)的map是可以跳過(guò)的,那么具體情況是怎么樣的呢?
找不到有用的資料,那只能看代碼了,代碼以Go 1.22.1為準(zhǔn)。
首先應(yīng)該想到的應(yīng)該是從gc的代碼開(kāi)始看,于是很快就有了收獲:
// runtime/mgcmark.go | |
// 負(fù)責(zé)gc掃描的函數(shù),還有個(gè)它的兄弟gcDrainN,代碼差不多就不放了 | |
func gcDrain(gcw *gcWork, flags gcDrainFlags) { | |
... | |
// 先標(biāo)記所有root對(duì)象,檢查對(duì)象是否存活就是從這開(kāi)始的 | |
if work.markrootNext < work.markrootJobs { | |
for !(gp.preempt && (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) { | |
markroot(gcw, job, flushBgCredit) | |
// 檢查自己是否需要被中斷,需要的場(chǎng)合函數(shù)會(huì)直接跳到收尾工作然后返回 | |
} | |
} | |
// 從工作隊(duì)列里拿需要掃描的對(duì)象進(jìn)行處理 | |
for !(gp.preempt && (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) { | |
b := gcw.tryGetFast() // 從工作隊(duì)列拿對(duì)象 | |
scanobject(b, gcw) | |
... | |
} | |
... | |
} |
流程不考慮中斷、數(shù)據(jù)統(tǒng)計(jì)和校驗(yàn)的話還是很簡(jiǎn)單的,就是先標(biāo)記掃描的起點(diǎn),然后從gcw這個(gè)工作隊(duì)列里拿東西出來(lái)處理,直到工作隊(duì)列里再也沒(méi)數(shù)據(jù)了為止。
markroot也很簡(jiǎn)單,根據(jù)root對(duì)象的種類(lèi),它會(huì)調(diào)用scanblock或者markrootSpans。其中scanblock會(huì)調(diào)用greyobject來(lái)標(biāo)記待處理的對(duì)象。因此稍微看看markrootSpans即可。
markrootSpans是用來(lái)處理那些存放設(shè)置了終結(jié)器的對(duì)象的內(nèi)存的:
// runtime/mgcmark.go | |
func markrootSpans(gcw *gcWork, shard int) { | |
... | |
for i := range specialsbits { | |
... | |
for j := uint(0); j < 8; j++ { | |
// 找到要處理的span(go內(nèi)存使用的單位,你就當(dāng)是“一塊內(nèi)存空間”就行) | |
s := ha.spans[arenaPage+uint(i)*8+j] | |
... | |
lock(&s.speciallock) | |
for sp := s.specials; sp != nil; sp = sp.next { | |
if sp.kind != _KindSpecialFinalizer { | |
continue | |
} | |
// don't mark finalized object, but scan it so we | |
// retain everything it points to. | |
// spf是終結(jié)器本身 | |
spf := (*specialfinalizer)(unsafe.Pointer(sp)) | |
// A finalizer can be set for an inner byte of an object, find object beginning. | |
p := s.base() + uintptr(spf.special.offset)/s.elemsize*s.elemsize | |
// p是設(shè)置了終結(jié)器的對(duì)象 | |
// 這里檢查這個(gè)對(duì)象占用的內(nèi)存上是否設(shè)置了跳過(guò)掃描的標(biāo)記 | |
// 設(shè)置了的話就不要繼續(xù)掃描對(duì)象自己的子對(duì)象了 | |
if !s.spanclass.noscan() { | |
scanobject(p, gcw) | |
} | |
// 這個(gè)span本身就是root對(duì)象,所以剩下的直接用scanblock處理 | |
scanblock(uintptr(unsafe.Pointer(&spf.fn)), goarch.PtrSize, &oneptrmask[0], gcw, nil) | |
} | |
unlock(&s.speciallock) | |
} | |
} | |
} |
其實(shí)很簡(jiǎn)單,依舊是找到所有的對(duì)象,然后進(jìn)行處理。然而我們看到了有意思的東西:s.spanclass.noscan()。
看起來(lái)這和是否跳過(guò)掃描有關(guān)。
但我們先不深入這個(gè)方法,為什么?因?yàn)榻K結(jié)器是被特殊處理的,沒(méi)看完scanobject和greyobject之前我們不能斷言這個(gè)方法是否控制著對(duì)對(duì)象的掃描。(其實(shí)注釋上我已經(jīng)告訴你就是這個(gè)東西控制的了,但如果你自己跟蹤代碼的話頭一次看到這段代碼的時(shí)候是不知道的)
所以我們接著看scanobject,這個(gè)函數(shù)是掃描對(duì)象的子對(duì)象的:
// runtime/mgcmark.go | |
func scanobject(b uintptr, gcw *gcWork) { | |
// 先拿到還沒(méi)掃描過(guò)的內(nèi)存 | |
s := spanOfUnchecked(b) | |
n := s.elemsize | |
// n 表示mspan里有幾個(gè)對(duì)象,在被這個(gè)函數(shù)檢查的時(shí)候肯定不能是0 | |
if n == 0 { | |
throw("scanobject n == 0") | |
} | |
if s.spanclass.noscan() { | |
// 如果內(nèi)存設(shè)置了noscan標(biāo)志,就報(bào)錯(cuò) | |
throw("scanobject of a noscan object") | |
} | |
var tp typePointers | |
if n > maxObletBytes { | |
// 大內(nèi)存分割成不同的塊放進(jìn)工作隊(duì)列,這樣能被并行處理 | |
if b == s.base() { | |
// 分割后入隊(duì) | |
for oblet := b + maxObletBytes; oblet < s.base()+s.elemsize; oblet += maxObletBytes { | |
if !gcw.putFast(oblet) { | |
gcw.put(oblet) | |
} | |
} | |
} | |
// 獲取類(lèi)型信息 | |
} else { | |
// 這里不重要 | |
} | |
var scanSize uintptr | |
for { | |
var addr uintptr | |
// 獲取子對(duì)象 | |
// 整個(gè)循環(huán)的退出條件就是next不再返回子對(duì)象的時(shí)候(沒(méi)東西可繼續(xù)掃描了) | |
if tp, addr = tp.nextFast(); addr == 0 { | |
if tp, addr = tp.next(); addr == 0 { | |
break | |
} | |
} | |
// 拿到要處理的對(duì)象 | |
scanSize = addr - b + goarch.PtrSize | |
obj := *(*uintptr)(unsafe.Pointer(addr)) | |
// 排除nil和指向當(dāng)前對(duì)象自身的指針 | |
// 后者屬于可以被回收的循環(huán)引用,當(dāng)前對(duì)象能不能回收不受這個(gè)指針影響 | |
// 因?yàn)槿绻?dāng)前對(duì)象不可訪問(wèn)了,那么它的字段自然也是不可能被訪問(wèn)到的,兩者均從root不可達(dá) | |
// 而如果這個(gè)指針是可達(dá)的,那么當(dāng)前對(duì)象的字段被引用,當(dāng)前對(duì)象也是不需要回收的 | |
// 所以指向當(dāng)前對(duì)象本身的指針字段不需要處理 | |
if obj != 0 && obj-b >= n { | |
if obj, span, objIndex := findObject(obj, b, addr-b); obj != 0 { | |
greyobject(obj, b, addr-b, span, gcw, objIndex) | |
} | |
} | |
} | |
... | |
} |
這個(gè)函數(shù)長(zhǎng)歸長(zhǎng),條理還是清晰的:
首先看看對(duì)象是否太大要把對(duì)象的內(nèi)存分割成小塊交給工作隊(duì)列里的其他協(xié)程并行處理
接著掃描所有子對(duì)象,用greyobject標(biāo)記這些對(duì)象
因?yàn)檫@個(gè)函數(shù)本身已經(jīng)是在掃描了,所以不太會(huì)有“跳過(guò)”的相關(guān)的邏輯,而且你也看到了把這個(gè)函數(shù)放在不需要掃描子對(duì)象的對(duì)象上調(diào)用時(shí)會(huì)觸發(fā)throw,throw會(huì)導(dǎo)致程序報(bào)錯(cuò)并退出執(zhí)行。
所以秘密就在greyobject里了。看看代碼:
// runtime/mgcmark.go | |
func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) { | |
... | |
if useCheckmark { | |
if setCheckmark(obj, base, off, mbits) { | |
// Already marked. | |
return | |
} | |
} else { | |
... | |
// If marked we have nothing to do. | |
if mbits.isMarked() { | |
return | |
} | |
mbits.setMarked() | |
... | |
// 如果內(nèi)存被標(biāo)記為不需要進(jìn)一步掃描,則會(huì)跳過(guò)后續(xù)的流程(內(nèi)存會(huì)被放進(jìn)gc掃描的工作隊(duì)列里等著被取出來(lái)掃描) | |
if span.spanclass.noscan() { | |
... | |
return | |
} | |
} | |
// 對(duì)象被放進(jìn)工作隊(duì)列等待掃描 | |
} |
這個(gè)函數(shù)會(huì)先檢查對(duì)象是否已經(jīng)被處理過(guò),然后標(biāo)記對(duì)象,接著檢查span上的noscan標(biāo)志,設(shè)置了的話就返回調(diào)用,沒(méi)有設(shè)置說(shuō)明需要被進(jìn)一步掃描,于是被放進(jìn)工作隊(duì)列,等著gcDrain或者它兄弟來(lái)處理。
現(xiàn)在我們可以得出結(jié)論了,會(huì)不會(huì)跳過(guò)掃描,全部由內(nèi)存上是否設(shè)置noscan標(biāo)志來(lái)控制,設(shè)置了就可以跳過(guò)。
至于在這塊內(nèi)存上的是map還是slice還是struct,沒(méi)關(guān)系。
跳過(guò)掃描的具體流程
看了上面的代碼,我想信你一定是懵的,跳過(guò)具體發(fā)生的流程是什么樣的呢?
沒(méi)關(guān)系,我們看兩個(gè)例子就知道了。
第一個(gè)例子是一個(gè)頂層的全局的可跳過(guò)掃描的對(duì)象A,介于我們還沒(méi)說(shuō)noscan會(huì)在什么情況下被設(shè)置,所以我們先忽略A的具體類(lèi)型,只要知道它可以跳過(guò)掃描即可。
A的掃描流程是這樣的:
gc開(kāi)始運(yùn)行,先標(biāo)記root對(duì)象
A就是root之一,所以它要么被scanblock處理要么被markrootSpan處理
假設(shè)A設(shè)置了終結(jié)器,又因?yàn)锳是可跳過(guò)掃描子對(duì)象的,因此markrootSpan會(huì)直接調(diào)用scanblock
scanblock會(huì)調(diào)用greyobject處理內(nèi)存里的對(duì)象
因?yàn)锳可跳過(guò)掃描,所以greyobject做完標(biāo)記就返回了,A不會(huì)進(jìn)入工作隊(duì)列
A的掃描結(jié)束,整個(gè)流程上不會(huì)有scanobject的調(diào)用
A的例子相對(duì)簡(jiǎn)單,現(xiàn)在我們假設(shè)有個(gè)不是root對(duì)象的對(duì)象B,B本身不可跳過(guò)掃描,B有一個(gè)子對(duì)象C可以跳過(guò)掃描。我們來(lái)看看C的掃描流程:
因?yàn)锽并不是root對(duì)象,且不可跳過(guò)掃描,所以它作為某個(gè)root對(duì)象的子對(duì)象,現(xiàn)在肯定在gc工作隊(duì)列里
gcDrain從隊(duì)列里拿到了B,于是交給了scanobject處理
我們假設(shè)B不是很大因此不會(huì)被分割(反正分割了也一樣)
scanobject把每個(gè)B的子對(duì)象都用greyobject處理,C也不例外
因?yàn)镃可跳過(guò)掃描,所以greyobject做完標(biāo)記就返回了,C不會(huì)進(jìn)入工作隊(duì)列
C的掃描結(jié)束,整個(gè)流程上不會(huì)有對(duì)C的scanobject的調(diào)用
這樣基本涵蓋了所有的情況,一些我沒(méi)單獨(dú)說(shuō)的比如“可跳過(guò)對(duì)象E是不可跳過(guò)root對(duì)象D的子對(duì)象”這樣的情況,實(shí)際上和情況2沒(méi)什么區(qū)別。
現(xiàn)在對(duì)象的子對(duì)象掃描是這么跳過(guò)的我們也知道了,只剩一個(gè)疑問(wèn)了:noscan標(biāo)志是怎么設(shè)置的?
noscan標(biāo)志是怎么設(shè)置的
在深入之前,我們先來(lái)簡(jiǎn)單看下go的怎么分配內(nèi)存的。完整講解恐怕5篇長(zhǎng)文也兜不住,所以我做些概念上的精簡(jiǎn)。
在go里,mspan是內(nèi)存分配的基礎(chǔ)單位,一個(gè)mspan上可以分配多個(gè)大小類(lèi)似可以被歸為一類(lèi)的對(duì)象(比如13字節(jié)和14字節(jié)的對(duì)象都是一類(lèi),可以被分配到允許最大存儲(chǔ)16字節(jié)對(duì)象的mspan上)。這個(gè)“類(lèi)型”就叫mpan的sizeclass。一個(gè)簡(jiǎn)單的心智模型是把mspan當(dāng)成一個(gè)能存大小相近的對(duì)象的列表。
為了加快內(nèi)存分配,go會(huì)給每個(gè)線程預(yù)分配一塊內(nèi)存,然后按sizeclass分成多份,每份對(duì)應(yīng)一個(gè)sizeclass的mspan。這個(gè)結(jié)構(gòu)叫mcache。
當(dāng)然了,總有對(duì)象的大小會(huì)超過(guò)所有mcache的sizeclass規(guī)定的范圍,這個(gè)時(shí)候go就會(huì)像系統(tǒng)申請(qǐng)一大塊內(nèi)存,然后把內(nèi)存交給mspan。
存儲(chǔ)了span信息的比如sizeclass和noscan的結(jié)構(gòu)叫spanClass。這個(gè)結(jié)構(gòu)會(huì)作為字段存儲(chǔ)在mspan的控制結(jié)構(gòu)里。
知道了這些之后,我們就能看懂s.spanclass.noscan()了,它的意思就是檢查mspan的spanclass信息是否設(shè)置了不需要掃描子對(duì)象的標(biāo)志。
而創(chuàng)建spanclass只能用makeSpanClass這個(gè)函數(shù):
// runtime/mheap.go | |
type spanClass uint8 | |
func makeSpanClass(sizeclass uint8, noscan bool) spanClass { | |
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan)) | |
} |
現(xiàn)在問(wèn)題簡(jiǎn)單了,我們只要追蹤誰(shuí)調(diào)用了這個(gè)函數(shù)就行,以及我們還知道額外的信息:這些調(diào)用者還需要從mcache或者系統(tǒng)申請(qǐng)內(nèi)存獲得mspan結(jié)構(gòu)。這樣一下范圍就收縮了。
按上面的思路,我們很快就找到了go分配內(nèi)存給對(duì)象的入口之一mallocgc:
// runtime/malloc.go | |
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { | |
... | |
// size是指類(lèi)型的大小 | |
// typ是需要?jiǎng)?chuàng)建的對(duì)象的類(lèi)型信息,如果只是分配內(nèi)存,typ要傳nil | |
// typ是否是空的或者typ是否包含有指針 | |
noscan := typ == nil || !typ.Pointers() | |
if 如果size足夠小可以從mspan上分配 { | |
if size滿(mǎn)足要求可以用tinyallocator分配 { | |
} else { | |
// 計(jì)算sizeclass(size對(duì)應(yīng)到哪一類(lèi)span) | |
spc := makeSpanClass(sizeclass, noscan) // noscan是這里傳進(jìn)去的 | |
span = c.alloc[spc] // 從mcache拿mspan | |
v := nextFreeFast(span) // 從mspan真正拿到可用的內(nèi)存 | |
// 后面是把內(nèi)存內(nèi)容清零和維護(hù)gc信息等代碼 | |
} | |
} else { | |
// 大對(duì)象分配 | |
// mcache.allocLarge也調(diào)用makeSpanClass(0, noscan),然后用mheap.alloc根據(jù)span的信息從系統(tǒng)申請(qǐng)內(nèi)存 | |
span = c.allocLarge(size, noscan) // noscan是這里傳進(jìn)去的 | |
// 后面是把內(nèi)存內(nèi)容清零和維護(hù)gc信息等代碼 | |
} | |
} |
即使sizeclass是一樣的,因?yàn)閚oscan的值不一樣,兩個(gè)spanClass的值也是不一樣的。對(duì)于可跳過(guò)掃描的大對(duì)象來(lái)說(shuō),會(huì)把為這個(gè)對(duì)象分配的內(nèi)存標(biāo)記為noscan;對(duì)于可跳過(guò)的小對(duì)象來(lái)說(shuō),會(huì)直接把這個(gè)小對(duì)象放在mcache提前分配的不需要深入掃描的內(nèi)存區(qū)域上。
那么這個(gè)mallocgc又是誰(shuí)調(diào)用的?答案太多了,因?yàn)閚ew,make都會(huì)用到它。我們用slice和map做例子看看。
首先是slice。這個(gè)非常簡(jiǎn)單,創(chuàng)建slice的入口是makeslice:
// runtime/slice.go | |
func makeslice(et *_type, len, cap int) unsafe.Pointer { | |
mem, overflow := math.MulUintptr(et.Size_, uintptr(cap)) | |
if overflow || mem > maxAlloc || len < 0 || len > cap { | |
// NOTE: Produce a 'len out of range' error instead of a | |
// 'cap out of range' error when someone does make([]T, bignumber). | |
// 'cap out of range' is true too, but since the cap is only being | |
// supplied implicitly, saying len is clearer. | |
// See golang.org/issue/4085. | |
mem, overflow := math.MulUintptr(et.Size_, uintptr(len)) | |
if overflow || mem > maxAlloc || len < 0 { | |
panicmakeslicelen() | |
} | |
panicmakeslicecap() | |
} | |
return mallocgc(mem, et, true) | |
} |
slice中的元素的類(lèi)型信息被傳給了mallocgc。如果slice的元素不包含指針,那么slice是可以跳過(guò)掃描的。
map比較特殊,跳過(guò)掃描的是它的bucket,而bucket外界是看不到的:
// runtime/map.go | |
// 調(diào)用鏈:makemap -> makeBucketArray -> newarray -> mallocgc | |
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) { | |
base := bucketShift(b) | |
nbuckets := base | |
// For small b, overflow buckets are unlikely. | |
// Avoid the overhead of the calculation. | |
if b >= 4 { | |
// Add on the estimated number of overflow buckets | |
// required to insert the median number of elements | |
// used with this value of b. | |
nbuckets += bucketShift(b - 4) | |
sz := t.Bucket.Size_ * nbuckets | |
up := roundupsize(sz, !t.Bucket.Pointers()) | |
if up != sz { | |
nbuckets = up / t.Bucket.Size_ | |
} | |
} | |
if dirtyalloc == nil { | |
// t.Bucket.Pointers() 返回鍵值對(duì)中是否包含指針 | |
buckets = newarray(t.Bucket, int(nbuckets)) | |
} else { | |
// dirtyalloc was previously generated by | |
// the above newarray(t.Bucket, int(nbuckets)) | |
// but may not be empty. | |
buckets = dirtyalloc | |
size := t.Bucket.Size_ * nbuckets | |
if t.Bucket.Pointers() { | |
memclrHasPointers(buckets, size) | |
} else { | |
memclrNoHeapPointers(buckets, size) | |
} | |
} | |
if base != nbuckets { | |
// We preallocated some overflow buckets. | |
// To keep the overhead of tracking these overflow buckets to a minimum, | |
// we use the convention that if a preallocated overflow bucket's overflow | |
// pointer is nil, then there are more available by bumping the pointer. | |
// We need a safe non-nil pointer for the last overflow bucket; just use buckets. | |
nextOverflow = (*bmap)(add(buckets, base*uintptr(t.BucketSize))) | |
last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.BucketSize))) | |
last.setoverflow(t, (*bmap)(buckets)) | |
} | |
return buckets, nextOverflow | |
} | |
func newarray(typ *_type, n int) unsafe.Pointer { | |
if n == 1 { | |
return mallocgc(typ.Size_, typ, true) | |
} | |
mem, overflow := math.MulUintptr(typ.Size_, uintptr(n)) | |
if overflow || mem > maxAlloc || n < 0 { | |
panic(plainError("runtime: allocation size out of range")) | |
} | |
return mallocgc(mem, typ, true) | |
} |
可以看到要是鍵值對(duì)里都不包含指針的話,map就可以被跳過(guò)。
所以總結(jié)下,只要?jiǎng)?chuàng)建的對(duì)象不包含指針(例如數(shù)組/切片成員都是不包含指針的類(lèi)型,map的鍵值對(duì)都不包含指針,結(jié)構(gòu)體所有字段不包含指針)或者只是單純分配塊內(nèi)存(makeslicecopy里分配一塊內(nèi)存然后再把數(shù)據(jù)copy進(jìn)去的時(shí)候會(huì)判斷element里包不包含指針,不包含的時(shí)候會(huì)傳nil給mallocgc),noscan就會(huì)被設(shè)置。
現(xiàn)在所有的疑問(wèn)都解決了:noscan是內(nèi)存分配時(shí)根據(jù)類(lèi)型信息來(lái)設(shè)置的;能跳過(guò)掃描的不只是map,符合條件的類(lèi)型不管是slice、map還是struct都可以。
優(yōu)化帶來(lái)的提升
說(shuō)了這么多,這個(gè)優(yōu)化帶來(lái)的提升有多少呢?
看個(gè)例子:
var a int64 = 1000 | |
func generateIntSlice(n int64) []int64 { | |
ret := make([]int64, 0, n) | |
for i := int64(0); i < n; i++ { | |
ret = append(ret, a) | |
} | |
return ret | |
} | |
func generatePtrSlice(n int64) []*int64 { | |
ret := make([]*int64, 0, n) | |
for i := int64(0); i < n; i++ { | |
ret = append(ret, &a) | |
} | |
return ret | |
} | |
func BenchmarkGCScan1(b *testing.B) { | |
defer debug.SetGCPercent(debug.SetGCPercent(-1)) // 測(cè)試期間禁止自動(dòng)gc | |
for i := 0; i < b.N; i++ { | |
for j := 0; j < 20; j++ { | |
generatePtrSlice(10000) | |
} | |
runtime.GC() | |
} | |
} | |
func BenchmarkGCScan2(b *testing.B) { | |
defer debug.SetGCPercent(debug.SetGCPercent(-1)) | |
for i := 0; i < b.N; i++ { | |
for j := 0; j < 20; j++ { | |
generateIntSlice(10000) | |
} | |
runtime.GC() | |
} | |
} |
我們分別創(chuàng)建20個(gè)包含10000個(gè)int64或者*int64的slice(兩個(gè)類(lèi)型在x64系統(tǒng)上都是8字節(jié)大小),然后手動(dòng)觸發(fā)一次GC。為了讓結(jié)果更準(zhǔn)確,我們還在測(cè)試開(kāi)始前禁用了自動(dòng)觸發(fā)的gc,而且我們創(chuàng)建的slice的長(zhǎng)度和slice里元素的大小都是一樣的,所以總體來(lái)說(shuō)結(jié)果應(yīng)該比較接近真實(shí)的gc回收內(nèi)存時(shí)的性能。
這是結(jié)果:
goos: windows | |
goarch: amd64 | |
cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz | |
│ old.txt │ new.txt │ | |
│ sec/op │ sec/op vs base │ | |
GCScan-8 379.0μ ± 2% 298.0μ ± 2% -21.51% (p=0.000 n=10) | |
│ old.txt │ new.txt │ | |
│ B/op │ B/op vs base │ | |
GCScan-8 1.563Mi ± 0% 1.563Mi ± 0% ~ (p=0.438 n=10) | |
│ old.txt │ new.txt │ | |
│ allocs/op │ allocs/op vs base │ | |
GCScan-8 20.00 ± 0% 20.00 ± 0% ~ (p=1.000 n=10) 1 | |
1 all samples are equal |
內(nèi)存用量大家都一樣,但存指針的時(shí)候速度慢了五分之一。slice越大差距也會(huì)越大。可見(jiàn)跳過(guò)掃描帶來(lái)的提升還是很大的。
另外少用指針還有助于增加數(shù)據(jù)的局部性,不僅僅是惠及gc掃描。
如何利用這一優(yōu)化
最后我們看看如何利用這一優(yōu)化。
少用指針可以減輕gc壓力大家都知道,但有一些“不得不用”指針的時(shí)候。
以一個(gè)本地cache為例:
type Cache[K comparable, V any] struct { | |
m map[K]*V | |
} | |
func (c *Cache[K, V]) Get(key K) *V { | |
return c.m[key] | |
} | |
func (c *Cache[K, V]) Set(key Key, value *V) { | |
c.m[key] = value | |
} |
值需要用指針是有兩個(gè)原因,一是map的元素不能取地址,如果我們想要cache里的數(shù)據(jù)可以自由使用的話那就不得不用臨時(shí)變量加復(fù)制,這樣如果我們想更新值的時(shí)候就會(huì)很麻煩;二是如果值很大的話復(fù)制帶來(lái)的開(kāi)銷(xiāo)會(huì)很大,用cache就是想提升性能呢反過(guò)來(lái)下降了怎么行。
但這么做就會(huì)導(dǎo)致Cache.m里的每一個(gè)鍵值對(duì)要被掃描,如果鍵值對(duì)很多的話性能會(huì)十分感人。
這樣看起來(lái)是“不得不用指針”的場(chǎng)景。真的是這樣嗎?考慮到cache本身就是空間換時(shí)間的做法,我們不妨再多用點(diǎn)空間:
type index = int | |
type Cache[K comparable, V any] struct { | |
buf []V | |
m map[K]index | |
} | |
func (c *Cache[K, V]) Get(key K) *V { | |
idx, ok := c.m[key] | |
if !ok { | |
return nil | |
} | |
return &c.buf[idx] // 可以對(duì)slice里存的數(shù)據(jù)取地址 | |
} | |
func (c *Cache[K, V]) Set(key Key, value V) { | |
idx, ok := c.m[key] | |
if !ok { | |
// 新建 | |
c.m[key] = len(c.buf) | |
c.buf = append(c.buf, value) | |
return | |
} | |
// 覆蓋已添加的 | |
c.buf[idx] = value | |
} |
我們用一個(gè)slice來(lái)存所有的值,然后再把key映射到值在slice中的索引上。對(duì)于slice的元素,我們是可以取地址的,因此可以簡(jiǎn)單拿到值的指針,對(duì)于值的更新也可以基于這個(gè)Get拿到的指針,時(shí)間復(fù)雜度不變,簡(jiǎn)單又方便。
然后我們?cè)賮?lái)看,現(xiàn)在buf和m都沒(méi)有指針了,只要K和V不包含指針,那么不管我們的cache里存了多少東西對(duì)gc來(lái)說(shuō)都只要看看外層的Cache對(duì)象是否存活就夠了。
但是這么做會(huì)有代價(jià):
Get會(huì)稍微慢一點(diǎn),因?yàn)椴粌H要做額外的檢查,還需要從兩個(gè)不同的數(shù)據(jù)結(jié)構(gòu)里拿數(shù)據(jù),對(duì)緩存不友好
存數(shù)據(jù)進(jìn)Cache的時(shí)候不可避免地需要一次復(fù)制
Get返回的指針沒(méi)有穩(wěn)定性,在底層的buf擴(kuò)容后就會(huì)失效
刪除元素會(huì)很慢,這怪我們用了slice而且需要維護(hù)map里的映射關(guān)系,解決方法倒是不少,比如你可以把待刪除元素和slice結(jié)尾的元素交換這樣slice里的其他元素不用移動(dòng)map也只要遍歷一次,又比如你可以再多浪費(fèi)點(diǎn)內(nèi)存用墓碑標(biāo)志來(lái)模擬刪除或者干脆不提供刪除功能(不好做就干脆不做,這是非常golang的做法)
順帶一提,前面說(shuō)的bigcache就利用了類(lèi)似的做法減輕了gc掃描壓力。
所以我建議先用benchmark和trace確定gc是性能瓶頸之后再進(jìn)行上面這樣的優(yōu)化,否則性能優(yōu)化不了還會(huì)帶來(lái)很多額外的煩惱。
總結(jié)
現(xiàn)在我們知道了對(duì)于不包含指針的對(duì)象,gc會(huì)跳過(guò)對(duì)它內(nèi)部子對(duì)象的掃描,這個(gè)優(yōu)化不止于map。
接口雖然看起來(lái)不像指針,但其實(shí)它內(nèi)部也有指針,因此接口是要被深入掃描的。
另外還要強(qiáng)調(diào)一點(diǎn),這個(gè)只是官方版本的go做的優(yōu)化,不保證其他的編譯器實(shí)現(xiàn)比如gccgo、tinygo會(huì)有類(lèi)似的優(yōu)化。但少用指針減輕gc壓力是大多數(shù)語(yǔ)言的共識(shí),這點(diǎn)不會(huì)錯(cuò)。
最后的最后,還是老話,過(guò)早的優(yōu)化是萬(wàn)惡之源,但用這句話給自己低性能的設(shè)計(jì)找借口更是錯(cuò)上加錯(cuò),性能問(wèn)題靠數(shù)據(jù)說(shuō)話,多做benchmark少做白日夢(mèng)。
審核編輯:黃飛
-
函數(shù)
+關(guān)注
關(guān)注
3文章
4308瀏覽量
62445 -
指針
+關(guān)注
關(guān)注
1文章
480瀏覽量
70512 -
編譯器
+關(guān)注
關(guān)注
1文章
1618瀏覽量
49057 -
數(shù)據(jù)統(tǒng)計(jì)
+關(guān)注
關(guān)注
0文章
17瀏覽量
7930
原文標(biāo)題:golang gc的內(nèi)部?jī)?yōu)化
文章出處:【微信號(hào):magedu-Linux,微信公眾號(hào):馬哥Linux運(yùn)維】歡迎添加關(guān)注!文章轉(zhuǎn)載請(qǐng)注明出處。
發(fā)布評(píng)論請(qǐng)先 登錄
相關(guān)推薦
評(píng)論