Dig101:Go 之讀懂 map 的底層設計

newbmiao發表於2020-02-05

檢視更多:歷史集錄


Dig101: dig more, simplified more and know more

在 golang 中,map是一個不可或缺的存在。

它作為雜湊表,簡單易用,既能自動處理雜湊碰撞,又能自動擴容或重新記憶體整理,避免讀寫效能的下降。

這些都要歸功於其內部實現的精妙。本文嘗試去通過原始碼去分析一下其背後的故事。

我們不會過多在原始碼分析上展開,只結合程式碼示例對其背後設計實現上做些總結,希望可以簡單明瞭一些。

希望看完後,會讓你對 map 的理解有一些幫助。網上也有很多不錯的原始碼分析,會附到文末,感興趣的同學自行檢視下。

(本文分析基於 Mac 平臺上 go1.14beta1 版本。長文預警 ... )

我們先簡單過下 map 實現 hash 表所用的資料結構,這樣方便後邊討論。

0x01 map 的內部結構

map資料結構

在這裡我們先弄清楚 map 實現的整體結構

map 本質是 hash 表(hmap),指向一堆桶(buckets)用來承接資料,每個桶(bmap)能存 8 組 k/v。

當有資料讀寫時,會用key的 hash 找到對應的桶。

為加速 hash 定位桶,bmap裡記錄了tophash陣列(hash 的高 8 位)

hash 表就會有雜湊衝突的問題(不同 key 的 hash 值一樣,即 hash 後都指向同一個桶),為此 map 使用桶後鏈一個溢位桶(overflow)連結串列來解決當桶 8 個單元都滿了,但還有資料需要存入此桶的問題。

剩下noverflow,oldbuckets,nevacuate,oldoverflow 會用於擴容,暫時先不展開

具體對應的資料結構詳細註釋如下:

(雖然多,先大致過一遍,後邊遇到會在提到)

// runtime/map.go
// A header for a Go map.
type hmap struct {
  //用於len(map)
  count     int
  //標誌位
  // iterator     = 1 // 可能有遍歷用buckets
  // oldIterator  = 2 // 可能有遍歷用oldbuckets,用於擴容期間
  // hashWriting  = 4 // 標記寫,用於併發讀寫檢測
  // sameSizeGrow = 8 // 用於等大小buckets擴容,減少overflow桶
  flags     uint8

  // 代表可以最多容納loadFactor * 2^B個元素(loadFactor=6.5)
  B         uint8
  // overflow桶的計數,當其接近1<<15 - 1時為近似值
  noverflow uint16
  // 隨機的hash種子,每個map不一樣,減少雜湊碰撞的機率
  hash0     uint32

  // 當前桶,長度為(0-2^B)
  buckets    unsafe.Pointer
  // 如果存在擴容會有擴容前的桶
  oldbuckets unsafe.Pointer
  // 遷移數,標識小於其的buckets已遷移完畢
  nevacuate  uintptr

  // 額外記錄overflow桶資訊,不一定每個map都有
  extra *mapextra
}

// 額外記錄overflow桶資訊
type mapextra struct {
  overflow    *[]*bmap
  oldoverflow *[]*bmap

  // 指向下一個可用overflow桶
  nextOverflow *bmap
}

const(
  // 每個桶8個k/v單元
  BUCKETSIZE  = 8
  // k或v型別大小大於128轉為指標儲存
  MAXKEYSIZE  = 128
  MAXELEMSIZE = 128
)

// 桶結構 (欄位會根據key和elem型別動態生成,見下邊bmap)
type bmap struct {
  // 記錄桶內8個單元的高8位hash值,或標記空桶狀態,用於快速定位key
  // emptyRest      = 0 // 此單元為空,且更高索引的單元也為空
  // emptyOne       = 1 // 此單元為空
  // evacuatedX     = 2 // 用於表示擴容遷移到新桶前半段區間
  // evacuatedY     = 3 // 用於表示擴容遷移到新桶後半段區間
  // evacuatedEmpty = 4 // 用於表示此單元已遷移
  // minTopHash     = 5 // 最小的空桶標記值,小於其則是空桶標誌
  tophash [bucketCnt]uint8
}

// cmd/compile/internal/gc/reflect.go
// func bmap(t *types.Type) *types.Type {
// 每個桶內k/v單元數是8
type bmap struct{
  topbits [8]uint8 //tophash
  keys [8]keytype
  elems [8]elemtype
  // overflow 桶
  // otyp 型別為指標*Type,
  // 若keytype及elemtype不含指標,則為uintptr
  // 使bmap整體不含指標,避免gc去scan此類map
  overflow otyp
}

這裡有幾個欄位需要解釋一下:

  • hmap.B

這個為啥用 2 的對數來表示桶的數目呢?

這裡是為了 hash 定位桶及擴容方便

比方說,hash%n可以定位桶, 但%操作沒有位運算快。

而利用 n=2^B,則hash%n=hash&(n-1)

則可優化定位方式為: hash&(1<<B-1)(1<<B-1)即原始碼中BucketMask

再比方擴容,hmap.B=hmap.B+1 即為擴容到二倍

  • bmap.keys, bmap.elems

在桶裡儲存 k/v 的方式不是一個 k/v 一組, 而是 k 放一塊,v 放一塊。

這樣的相對 k/v 相鄰的好處是,方便記憶體對齊。比如map[int64]int8, v 是int8,放一塊就避免需要額外記憶體對齊。

另外對於大的 k/v 也做了優化。

正常情況 key 和 elem 直接使用使用者宣告的型別,但當其 size 大於 128(MAXKEYSIZE/MAXELEMSIZE) 時,

則會轉為指標去儲存。(也就是indirectkey、indirectelem

  • hmap.extra

這個額外記錄溢位桶意義在哪?

具體是為解決讓gc不需要掃描此類bucket

只要 bmap 內不含指標就不需 gc 掃描。

mapkeyelem型別都不包含指標時,但其中的overflow是指標。

此時 bmap 的生成函式會將overflow的型別轉化為uintptr

uintptr雖然是地址,但不會被gc認為是指標,指向的資料有被回收的風險。

此時為保證其中的overflow指標指向的資料存活,就用mapextra結構指向了這些buckets,這樣 bmap 有被引用就不會被回收了。

關於 uintptr 可能被回收的例子,可以看下 go101 - Type-Unsafe Pointers 中 Some Facts in Go We Should Know

0x02 map 的 hash 方式

瞭解 map 的基本結構後,我們通過下邊程式碼分析下 map 的 hash

var m = map[interface{}]int{}
var i interface{} = []int{}
//panic: runtime error: hash of unhashable type []int
println(m[i])
//panic: runtime error: hash of unhashable type []int
delete(m, i)

為什麼不可以用[]int作為 key 呢?

查詢原始碼中 hash 的呼叫鏈註釋如下:

// runtime/map.go
// mapassign,mapaccess1中 獲取key的hash
hash := t.hasher(key, uintptr(h.hash0))

// cmd/compile/internal/gc/reflect.go
func dtypesym(t *types.Type) *obj.LSym {
  switch t.Etype {
    // ../../../../runtime/type.go:/mapType
  case TMAP:
    ...
    // 依據key構建hash函式
    hasher := genhash(t.Key())
    ...
  }
}

// cmd/compile/internal/gc/alg.go
func genhash(t *types.Type) *obj.LSym {
  switch algtype(t) {
  ...
  //具體針對interface呼叫interhash
  case AINTER:
    return sysClosure("interhash")
  ...
  }
}

// runtime/alg.go
func interhash(p unsafe.Pointer, h uintptr) uintptr {
  //獲取interface p的實際型別t,此處為slice
  a := (*iface)(p)
  tab := a.tab
  t := tab._type
  // slice型別不可比較,沒有equal函式
  if t.equal == nil {
    panic(errorString("hash of unhashable type " + t.string()))
  }
  ...
}

如上,我們會發現 map 的 hash 函式並不唯一。

它會對不同 key 型別選取不同的 hash 方式,以此加快 hash 效率

這個例子slice不可比較,所以不能作為 key。

也對,不可比較的型別作為 key 的話,找到桶但沒法比較 key 是否相等,那 map 用這個 key 讀寫都會是個問題。

還有哪些不可比較?

cmd/compile/internal/gc/alg.goalgtype1 函式中可以找到返回ANOEQ(不可比較型別)的型別,如下:

  • func,map,slice
  • 內部元素有這三種型別的 array 和 struct 型別

0x03 map 的擴容方式

map不可以對其值取地址;

如果值型別為slicestruct,不能直接操作其內部元素

我們用程式碼驗證如下:

m0 := map[int]int{}
// ❎ cannot take the address of m0[0]
_ = &m0[0]

m := make(map[int][2]int)
// ✅
m[0] = [2]int{1, 0}
// ❎ cannot assign to m[0][0]
m[0][0] = 1
// ❎ cannot take the address of m[0]
_ = &m[0]

type T struct{ v int }
ms := make(map[int]T)
// ✅
ms[0] = T{v: 1}
// ❎ cannot assign to struct field ms[0].v in map
ms[0].v = 1
// ❎ cannot take the address of ms[0]
_ = &ms[0]
}

為什麼呢?

這是因為map內部有漸進式擴容,所以map的值地址不固定,取地址沒有意義。

也因此,對於值型別為slicestruct, 只有把他們各自當做整體去賦值操作才是安全的。 go 有個 issue 討論過這個問題:issues-3117

針對擴容的方式,有兩類,分別是:

  • sameSizeGrow

過多的overflow使用,使用等大小的 buckets 重新整理,回收多餘的overflow桶,提高 map 讀寫效率,減少溢位桶佔用

這裡藉助hmap.noverflow來判斷溢位桶是否過多

hmap.B<=15 時,判斷是溢位桶是否多於桶數1<<hmap.B

否則只判斷溢位桶是否多於 1<<15

這也就是為啥hmap.noverflow,當其接近1<<15 - 1時為近似值, 只要可以評估是否溢位桶過多不合理就行了

  • biggerSizeGrow

count/size > 6.5 (裝載因子 :overLoadFactor), 避免讀寫效率降低。

擴容一倍,並漸進的在賦值和刪除(mapassign和mapdelete)期間,

對每個桶重新分流到x(原來桶區間)和y(擴容後的增加的新桶區間)

這裡overLoadFactor (count/size)是評估桶的平均裝載資料能力,即 map 平均每個桶裝載多少個 k/v。

這個值太大,則桶不夠用,會有太多溢位桶;太小,則分配了太多桶,浪費了空間。

6.5 是測試後對 map 裝載能力最大化的一個的選擇。

原始碼中擴容程式碼註釋如下:

// mapassign 中建立新bucket時檢測是否需要擴容
if !h.growing() && //非擴容中
  (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
  // 提交擴容,生成新桶,記錄舊桶相關。但不開始
  // 具體開始是後續賦值和刪除期間漸進進行
  hashGrow(t, h)
}

//mapassign 或 mapdelete中 漸進擴容
bucket := hash & bucketMask(h.B)
if h.growing() {
  growWork(t, h, bucket)
}

// 具體遷移工作執行,每次最多兩個桶
func growWork(t *maptype, h *hmap, bucket uintptr) {
  // 遷移對應舊桶
  // 若無迭代器遍歷舊桶,可釋放對應的overflow桶或k/v
  // 全部遷移完則釋放整個舊桶
  evacuate(t, h, bucket&h.oldbucketmask())

  // 如果還有舊桶待遷移,再遷移一個
  if h.growing() {
    evacuate(t, h, h.nevacuate)
  }
}

具體擴容evacuate(遷移)時,判斷是否要將舊桶遷移到新桶後半區間(y)有段程式碼比較有趣, 註釋如下:

newbit := h.noldbuckets()
var useY uint8
if !h.sameSizeGrow() {
  // 獲取hash
  hash := t.hasher(k2, uintptr(h.hash0))
  if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) {
  // 這裡 key != key 是指key為NaNs,
  // 此時 useY = top & 1 意味著有50%的機率到新桶區間
    useY = top & 1
    top = tophash(hash)
  } else {
    if hash&newbit != 0 {
  // 舉例來看 若擴容前h.B=3時, newbit=1<<3
  // hash&newbit != 0 則hash形如 xxx1xxx
  // 新hmap的BucketMask= 1<<4 - 1 (1111: 15)
  // 則 hash&新BucketMask > 原BucketMask 1<<3-1 (111: 7)
  // 所以去新桶區間
      useY = 1
    }
  }
}

// 補充一個 key != key 的程式碼示例
n1, n2 := math.NaN(), math.NaN()
m := map[float64]int{}
m[n1], m[n2] = 1, 2
println(n1 == n2, m[n1], m[n2])
// output: false 0 0
// 所以NaN做key沒有意義。。。

弄清楚 map 的結構、hash 和擴容,剩下的就是初始化、讀寫、刪除和遍歷了,我們就不詳細展開了,簡單過下。

0x04 map 的初始化

map 不初始化時為 nil,是不可以操作的。可以通過 make 方式初始化

// 不指定大小
s := make(map[int]int)
// 指定大小
b := make(map[int]int,10)

對於這兩種 map 內部呼叫方式是不一樣的

  • small map

當不指定大小或者指定大小不大於 8 時,呼叫

func makemap_small() *hmap {

只需要直接在堆上初始化hmap和 hash 種子(hash0)就行。

  • bigger map

當大小大於 8, 呼叫

func makemap(t *maptype, hint int, h *hmap) *hmap {

hint溢位則置 0

初始化hmap和 hash 種子

根據overLoadFactor:6.5的要求, 迴圈增加h.B, 獲取 hint/(1<<h.B) 最接近 6.5 的h.B

預分配 hashtable 的 bucket 陣列

h.B 大於 4 的話,多分配至少1<<(h.B-4)(需要記憶體對齊)個 bucket,用於可能的overflow桶使用,

並將 h.nextOverflow設定為第一個可用的overflow桶。

最後一個overflow桶指向h.buckets(方便後續判斷已無overflow桶)

0x05 map 的讀取

對於 map 的讀取有著三個函式,主要區別是返回引數不同

mapaccess1: m[k]
mapaccess2: a,b = m[i]
mapaccessk: 在map遍歷時若grow已發生key可能有更新需用此函式重新獲取k/v

計算 key 的 hash,定位當前 buckets 裡桶位置

如果當前處於擴容中,也嘗試去舊桶取對應的桶,需考慮擴容前 bucket 大小是否為現在一半,且其所指向的桶未遷移

然後就是按照 bucket->overflow 連結串列的順序去遍歷,直至找到tophash匹配且 key 相等的記錄(entry)

期間,如果 key 或者 elem 是轉過指標(size 大於 128),需轉回對應值。

map 為空或無值返回 elem 型別的零值

0x06 map 的賦值

計算 key 的 hash,拿到對應的桶

如果此時處於擴容期間,則執行擴容growWork

對桶 bucket->overflow 連結串列遍歷

  • 若有空桶 (對應 tophash[i] 為空),則準備在此空桶儲存 k/v

  • 若非空,且和 tophash 相等,且 key 相等,則更新對應 elem

  • 若無可用桶,則分配一個新的 overflow 桶來儲存 k/v, 會判斷是否需要擴容

最後若使用了空桶或新overflow桶,則要將對應tophash更新回去, 如果需要的話,也更新count

0x07 map 的刪除

獲取待刪除 key 對應的桶,方式和 mapassign 的查詢方式基本一樣,找到則清除 k/v。

這裡還有個額外操作:

如果當前 tophash 狀態是:當前 cell 為空(emptyOne),

若其後桶或其後的 overflow 桶狀態為:當前 cell 為空前索引高於此 cell 的也為空(emptyRest),則將當前狀態也更新為emptyRest

倒著依次往前如此處理,實現 emptyOne -> emptyRest的轉化

這樣有什麼好處呢?

答案是為了方便讀寫刪除(mapaccess,mapassign,mapdelete)時做桶遍歷(bucketLoop)能減少不必要的空 bucket 遍歷

擷取程式碼如下:

bucketloop:
  for ; b != nil; b = b.overflow(t) {
    for i := uintptr(0); i < bucketCnt; i++ {
      if b.tophash[i] != top {
        // 減少空cell的遍歷
        if b.tophash[i] == emptyRest {
          break bucketloop
        }
        continue
      }
    ...
  }

0x08 map 的遍歷

先呼叫mapiterinit 初始化用於遍歷的 hiter結構體, 這裡會用隨機定位出一個起始遍歷的桶hiter.startBucket, 這也就是為啥 map 遍歷無序。

隨機獲取起始桶的程式碼如下:

r := uintptr(fastrand())
// 隨機數不夠用得再加一個32位
if h.B > 31-bucketCntBits {
  r += uintptr(fastrand()) << 31
}
it.startBucket = r & bucketMask(h.B)

在呼叫mapiternext去實現遍歷, 遍歷中如果處於擴容期間,如果當前桶已經遷移了,那麼就指向新桶,沒有遷移就指向舊桶


至此,map 的內部實現我們就過完了。

裡邊有很多優化點,設計比較巧妙,簡單總結一下:

  • 以 2 的對數儲存桶數,便於優化 hash 模運算定位桶,也利於擴容計算
  • 每個 map 都隨機 hash 種子,減少雜湊碰撞的機率
  • map 以 key 的型別確定 hash 函式,對不同型別針對性優化 hash 計算方式
  • 桶內部 k/v 並列儲存,減少不必要的記憶體對齊浪費;對於大的 k/v 也會轉為指標,便於記憶體對齊和控制桶的整體大小
  • 桶內增加 tophash 陣列加快單元定位,也方便單元回收(空桶)標記
  • 當桶 8 個單元都滿了,還存在雜湊衝突的 k/v,則在桶裡增加 overflow 桶連結串列儲存
  • 桶內若只有 overflow 桶連結串列是指標,則 overflow 型別轉為 uintptr,並使用 mapextra 引用該桶,避免桶的 gc 掃描又保證其 overflow 桶存活
  • 寫操作增加新桶時如果需要擴容,只記錄提交,具體執行會分散到寫操作和刪除操作中漸進進行,將遷移成本打散
  • 雜湊表的裝載因子不滿足要求是,擴容一倍,保證桶的裝載能力
  • 雜湊表 overflow 桶過多,則記憶體重新整理,減少不必要的 overflow 桶,提升讀寫效率
  • 對指定不同大小的 map 初始化,區別對待,不必要的桶預分配就避免;桶較多的情況下,也增加 overflow 桶的預分配
  • 每次遍歷起始位置隨機,嚴格保證 map 無序語義
  • 使用 flags 位標記檢測 map 的併發讀寫,發現時 panic,一定程度上預防資料不一致發生

趁熱打鐵,建議你再閱讀一遍原始碼,加深一下理解。

附上幾篇不錯的原始碼分析文章,程式碼對應的go版本和本文不一致,但變化不大,可以對照著看。

本文程式碼見 NewbMiao/Dig101-Go

歡迎關注,不定期挖點技術 歡迎關注我,不定期挖點技術

文章首發:公眾號 newbmiao

更多原創文章乾貨分享,請關注公眾號
  • Dig101:Go 之讀懂 map 的底層設計
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章