Go: 通過代碼學習 Map 的設計 — Part II

Map Design

這篇文章是 "Go: 通過例子學習 Map 的設計 [1]" 的下一篇,它從高層次上介紹了 map 的設計。爲了理解下文討論的概念,我強烈建議你從上一篇文章開始閱讀。

map 的內部設計向我們展示了它如何優化性能和內存管理。讓我們從 map 的內存分配開始。

map 初始化

Go 提供了兩種初始化 map 和內部 bucket 的方式:

m := make(map[string]int, 10)
m := make(map[string]int)
m[`foo`] = 1

在第二個例子中,map 的容量未指定,因此當 map m 創建時,不會創建 bucket,Go 會等待直到 map 的第一次更新時才初始化 map。因此,第二行會創建 bucket。

在上述兩個案例中,map 會根據我們的需要擴容。在第一個例子中,如果我們需要多於 10 個 key,預定義的容量不會阻止 map 的擴容,它只是幫助我們優化對 map 的使用,因爲按需擴容會有性能損耗。

按需擴容的影響

Go 足夠智能來對 map 進行按需擴容。然而,這種原生行爲是有代價的。讓我們運行一些基準測試,初始化兩個 map,並創建 100/1000 個鍵值對。前兩個基準測試使用初始化容量的 map,容量分別爲 100 和 1000,另一個使用容量未定義,即按需增長的 map:

// 100 allocations
name                   time/op
LazyInitMap100Keys-8   6.67 µ s ± 0%
InitMap100Keys-8       3.57 µ s ± 0%
name                   alloc/op
LazyInitMap100Keys-8   5.59kB ± 0%
InitMap100Keys-8       2.97kB ± 0%
name                   allocs/op
LazyInitMap100Keys-8     18.0 ± 0%
InitMap100Keys-8         7.00 ± 0%
// 1000 allocations
name                   time/op
LazyInitMap1000Keys-8  77.8 µ s ± 0%
InitMap1000Keys-8      32.2 µ s ± 0%
name                   alloc/op
LazyInitMap1000Keys-8  86.8kB ± 0%
InitMap1000Keys-8      41.2kB ± 0%
name                   allocs/op
LazyInitMap1000Keys-8    66.0 ± 0%
InitMap1000Keys-8        7.00 ± 0%

在這裏,我們很清晰的看到擴容和遷移 bucket 的代價,它多消耗了 80% 到 140% 的時間。內存消耗也受到了相同比例的影響。Go 對 map 進行了精妙的設計來減少內存消耗。

bucket 填充

正如之前看到的,每個 bucket 僅包含 8 個鍵值對。這裏有一件有趣的事情值得注意,Go 先存儲 key,後存儲 value:

img

這避免了填充齊導致的內存浪費。事實上,由於 key 和 value 的大小可能不同,最終可能導致大量的內存填充。下面是兩個 string / bool 對的例子,展示了 key 和 value 混在一起的情況:

made from Golang-sizeof.tips

如果 key 和 value 分別分組存放:

made from Golang-sizeof.tips

我們可以清楚的看到,填充被消除了很多。下面看一下如何訪問這些值。

數據訪問

Go 提供了兩種訪問 map 中數據的方式:

m := make(map[string]int)

v := m[`my_key`]
v, ok := m[`my_key`]

我們可以單獨訪問值,也可以攜帶一個布爾變量,用來表示是否在 map 中找到該值。我們可能會好奇,既然所有的返回值都應該明確的映射到一個變量,至少是 _,怎麼可能會有兩種訪問方式。實際上,Go 生成的彙編代碼 [2] 會給我們提示:

(main.go:3) CALL    runtime.mapaccess1_faststr(SB)
(main.go:4) CALL    runtime.mapaccess2_faststr(SB)

我們可以看到,根據你訪問數據的方式,編譯器將會使用擁有正確簽名的兩個不同的內部方法:

func mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer

func mapaccess2_faststr(t *maptype, h *hmap, ky string) (unsafe.Pointer, bool)

編譯器的這個小技巧非常有用,使我們可以靈活地訪問數據。其實編譯器甚至做的比這更好,它可以根據 map 的類型來選擇數據訪問方法。在這個例子中,我們的 map 使用 string 作爲 key,編譯器會選擇 mapaccess1_faststr 作爲數據訪問方法。後綴 str 表明了它對於 string 作爲 key 的 map 進行了優化。讓我們嘗試使用 integer:

m := make(map[int]int)

v := m[1]
v, ok := m[1]

彙編代碼會爲我們選擇如下方法:

(main.go:3) CALL    runtime.mapaccess1_fast64(SB)
(main.go:4) CALL    runtime.mapaccess2_fast64(SB)

這次,編譯器將會使用 int64(在 64 位機器上,是 int) 作爲 key 的專用方法。每個方法都會針對哈希比較進行優化,如果開發人員在分配 map 時未指定容量,惰性初始化也會被優化。

下一篇,也就是這個系列的最後一篇文章 "Go: 併發訪問 Map[3]",將會講解 map 在併發上下文中的表現。


via: https://medium.com/@blanchon.vincent/go-map-design-by-code-part-ii-50d111557c08

作者:blanchon.vincent[4] 譯者:DoubleLuck[5] 校對:polaris1119[6]

本文由 GCTT[7] 原創編譯,Go 中文網 [8] 榮譽推出,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。

參考資料

[1]

Go: 通過例子學習 Map 的設計: https://studygolang.com/articles/22773

[2]

彙編代碼: https://golang.org/doc/asm

[3]

Go: 併發訪問 Map: https://medium.com/@blanchon.vincent/go-concurrency-access-with-maps-part-iii-8c0a0e4eb27e

[4]

blanchon.vincent: https://medium.com/@blanchon.vincent

[5]

DoubleLuck: https://github.com/DoubleLuck

[6]

polaris1119: https://github.com/polaris1119

[7]

GCTT: https://github.com/studygolang/GCTT

[8]

Go 中文網: https://studygolang.com/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/wilXpQwFNeuF2f8b-eeARg