Go語(yǔ)言使用 map 時(shí)盡量不要在 big map 中保存指針

不知道你有沒有聽過這么一句:在使用 map 時(shí)盡量不要在 big map 中保存指針。好吧,你現(xiàn)在已經(jīng)聽過了:)為什么呢?原因在于 Go 語(yǔ)言的垃圾回收器會(huì)掃描標(biāo)記 map 中的所有元素,GC 開銷相當(dāng)大,直接GG。

這兩天在《Mastering Go》中看到 GC 這一章節(jié)里面對(duì)比 map 和 slice 在垃圾回收中的效率對(duì)比,書中只給出結(jié)論沒有說明理由,這我是不能忍的,于是有了這篇學(xué)習(xí)筆記。扯那么多,Show Your Code

func MapWithPointer() {
    const N = 10000000
    m := make(map[string]string)
    for i := 0; i < N; i++ {
        n := strconv.Itoa(i)
        m[n] = n
    }
    now := time.Now()
    runtime.GC()      // 手動(dòng)觸發(fā) GC
    fmt.Printf("With a map of strings, GC took: %s\n", time.Since(now))

    _ = m["0"]          // 引用一下防止被 GC 回收掉
}

func MapWithoutPointer() {
    const N = 10000000
    m := make(map[int]int)
    for i := 0; i < N; i++ {
        str := strconv.Itoa(i)
        // hash string to int
        n, _ := strconv.Atoi(str)
        m[n] = n
    }
    now := time.Now()
    runtime.GC()
    fmt.Printf("With a map of int, GC took: %s\n", time.Since(now))

    _ = m[0]
}

func TestMapWithPointer(t *testing.T) {
    MapWithPointer()
}

func TestMapWithoutPointer(t *testing.T) {
    MapWithoutPointer()
}
------------------------------------------------------------------------------------------
=== RUN   TestMapWithPointer
With a map of strings, GC took: 150.078ms
--- PASS: TestMapWithPointer (4.22s)
=== RUN   TestMapWithoutPointer
With a map of int, GC took: 4.9581ms
--- PASS: TestMapWithoutPointer (2.33s)
PASS

這是一個(gè)簡(jiǎn)單的測(cè)試程序,保存字符串的 map 和 保存整形的 map GC 的效率相差幾十倍,是不是有同學(xué)會(huì)說明明保存的是 string 哪有指針?這個(gè)要說到 Go 語(yǔ)言中 string 的底層實(shí)現(xiàn)了,源碼在 src/runtime/string.go里,可以看到 string 其實(shí)包含一個(gè)指向數(shù)據(jù)的指針和一個(gè)長(zhǎng)度字段。注意這里的是否包含指針,包括底層的實(shí)現(xiàn)。

type stringStruct struct {
    str unsafe.Pointer    // 指針
    len int
}

Go 語(yǔ)言的 GC 會(huì)遞歸遍歷并標(biāo)記所有可觸達(dá)的對(duì)象,標(biāo)記完成之后將所有沒有引用的對(duì)象進(jìn)行清理。掃描到指針就會(huì)往下接著尋找,一直到結(jié)束。

Go 語(yǔ)言中 map 是基于數(shù)組和鏈表的數(shù)據(jù)結(jié)構(gòu)實(shí)現(xiàn)的,通過優(yōu)化的拉鏈法解決哈希沖突,每個(gè) bucket 可以保存 8 對(duì)鍵值,在 8 個(gè)鍵值對(duì)數(shù)據(jù)后面有一個(gè) overflow 指針,因?yàn)橥爸凶疃嘀荒苎b 8 個(gè)鍵值對(duì),如果有多余的鍵值對(duì)落到了當(dāng)前桶,那么就需要再構(gòu)建一個(gè)桶(稱為溢出桶),通過 overflow 指針鏈接起來。

因?yàn)?overflow 指針的緣故,所以無(wú)論 map 保存的是什么,GC 的時(shí)候就會(huì)把所有的 bmap 掃描一遍,帶來巨大的 GC 開銷。官方 issues 就有關(guān)于這個(gè)問題的討論,runtime: Large maps cause significant GC pauses #9477

If we have a map[k]v where both k and v does not contain pointers and we want to improve scan performance, then we can do the following.
Add 'allOverflow []unsafe.Pointer' to hmap and store all overflow buckets in it. Then mark bmap as noScan. This will make scanning very fast, as we won't scan any user data.
In reality it will be somewhat more complex, because we will need to remove old overflow buckets from allOverflow. And also it will increase size of hmap, so it may require some reshuffling of data as well.

無(wú)腦機(jī)翻如下:
如果我們有一個(gè)map [k] v,其中k和v都不包含指針,并且我們想提高掃描性能,則可以執(zhí)行以下操作。
將“ allOverflow [] unsafe.Pointer”添加到 hmap 并將所有溢出存儲(chǔ)桶存儲(chǔ)在其中。 然后將 bmap 標(biāo)記為noScan。 這將使掃描非常快,因?yàn)槲覀儾粫?huì)掃描任何用戶數(shù)據(jù)。
實(shí)際上,它將有些復(fù)雜,因?yàn)槲覀冃枰獜腶llOverflow中刪除舊的溢出桶。 而且它還會(huì)增加 hmap 的大小,因此也可能需要重新整理數(shù)據(jù)。

最終官方在 hmap 中增加了 overflow 相關(guān)字段完成了上面的優(yōu)化,這是具體的 commit 地址。

下面看下具體是如何實(shí)現(xiàn)的,源碼基于 go1.15,src/cmd/compile/internal/gc/reflect.go 中

// bmap makes the map bucket type given the type of the map.
func bmap(t *types.Type) *types.Type {
    ...

    // If keys and elems have no pointers, the map implementation
    // can keep a list of overflow pointers on the side so that
    // buckets can be marked as having no pointers.
    // Arrange for the bucket to have no pointers by changing
    // the type of the overflow field to uintptr in this case.
    // See comment on hmap.overflow in runtime/map.go.
    otyp := types.NewPtr(bucket)
    if !types.Haspointers(elemtype) && !types.Haspointers(keytype) {
        otyp = types.Types[TUINTPTR]
    }
    overflow := makefield("overflow", otyp)
    field = append(field, overflow)

    ...
    return bucket
}

通過注釋可以看出,如果 map 中保存的鍵值都不包含指針(通過 Haspointers 判斷),就使用一個(gè) uintptr 類型代替 bucket 的指針用于溢出桶 overflow 字段,uintptr 類型在 GO 語(yǔ)言中就是個(gè)大小可以保存得下指針的整數(shù),不是指針,就相當(dāng)于實(shí)現(xiàn)了 將 bmap 標(biāo)記為 noScan, GC 的時(shí)候就不會(huì)遍歷完整個(gè) map 了。隨著不斷的學(xué)習(xí),愈發(fā)感慨 GO 語(yǔ)言中很多模塊設(shè)計(jì)得太精妙了。
差不多說清楚了,能力有限,有不對(duì)的地方歡迎留言討論,源碼位置還是問的群里大佬_

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容