真希望你也明白runtime.Map和sync.Map

面向加薪學習發表於2022-12-14

Map 官方介紹

One of the most useful data structures in computer science is the hash table. Many hash table implementations exist with varying properties, but in general they offer fast lookups, adds, and deletes. Go provides a built-in map type that implements a hash table.
雜湊表是計算機中最有用的資料結構之一。提供快速查詢、新增和刪除。 Go 提供了一個實現雜湊表的內建 Map 型別。

Hash 衝突

那對於 Hash 的一個最重要的問題,就是 hash 衝突。下面我們看一下常用的解決方案。

開放定址法

開放定址法想象成一個停車問題。若當前車位已經有車,則繼續往前開,直到找到一個空停車位。
go-32-001

上圖,每個方格子,就是一個車位,當一輛車來的時候,會依次查詢是否有空位,如果沒有,則繼續向後面找,如果發現空位置,就會停到空位置中。

go-32-002

下面看一下,我們的程式碼是如何實現的?

go-32-003

go-32-004

  1. m["面向加薪學習"]="從 0 到 Go 語言微服務架構師-訓練營"
  2. 要對鍵-"面向加薪學習",進行 hash
  3. 拿到全體格子的總數,然後取模
  4. 如果取模發現是位子 1,但是發現 1 已經被別人佔了,那麼就向後走,直到有空位,再把自己放進去。

看了上面的步驟是不是和停車,是一個道理?

那我們再看,如果想讀取資料的時候:

  1. 同樣對 J 鍵進行 hash
  2. 拿到全體格子的總數,然後取模
  3. 找到位置是 1,但是發現 key 不一樣,它可能在後面,就一直向後查詢。

拉鍊法

go-32-005

go-32-006

  1. m["面向加薪學習"]="從 0 到 Go 語言微服務架構師-訓練營"
  2. 要對鍵-"面向加薪學習",進行 hash
  3. 找到對應到槽位,每個槽位並不儲存具體資料,只是一個指標,它指向下面的連結串列
  4. 當新增資料的時候,會把資料新增到連結串列頭部(上圖中黃色小球為例)

Go 語言的 Map

runtime/map.go,看到 hmap 這個結構體,它就 go 語言的 map

type hmap struct {
    count     int
    flags     uint8
    B         uint8
    noverflow uint16
    hash0     uint32
    buckets    unsafe.Pointer
    oldbuckets unsafe.Pointer
    nevacuate  uintptr
    extra *mapextra
}
  1. count 鍵值對的數量
  2. B 是以 2 為底,桶個數的對數
  3. hash0 hash 的種子
  4. oldbuckets 舊的 hash 桶
  5. buckets hash 桶

下面看一個 Go 語言的雜湊桶具體長什麼樣?用 bmap 結構體表示

bucketCntBits = 3
bucketCnt     = 1 << bucketCntBits

type bmap struct {
    tophash [bucketCnt]uint8
}

bucketCntBits 一個雜湊桶可以存放最大的 KV 鍵值對的數量
bucketCnt Hash 桶的數量(左移 3 位,也就是 8。)

tophash [8]uint8

uint8 是無符號的 8 為數字,也就是 1 個位元組。 tophash 是儲存桶中的每個鍵的雜湊值的頂部位元組(1 個位元組)。同樣,k 和 v 也是對應的 8 個。然後在編譯的時候,將所有鍵和值再打包,這樣就避免了在 bmap 中固定 K 和 V 的型別,最後還有一個 overflow 的指標,指向一個溢位桶。

go-32-007

新建 Map

package main

import "fmt"

func main() {
    m := make(map[string]int, 16)
}

16 代表預計要有 16 個 Key,當然你也可以放更多的 Key,Map 會擴容,後面我們介紹到。

下面到命令列執行 go build -gcflags -S main.go

go-32-008

看到它呼叫了 runtime.makemap 這個函式。到 runtime/map.go 中,找到 makemap()函式。

func makemap(t *maptype, hint int, h *hmap) *hmap {
    ...
    if h == nil {
        h = new(hmap)
    }
    h.hash0 = fastrand()

    B := uint8(0)
    for overLoadFactor(hint, B) {
        B++
    }
    h.B = B
}
...
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
    h.extra = new(mapextra)
    h.extra.nextOverflow = nextOverflow
}
...

4 行 新建 hmap
6 行 獲取 hash 種子
9-11 行 計算 B 的個數,根據初始化的時候傳入的資料。(make(map[string]int, 16) 就是這個數字 16)
15 行 生成多個 hash 桶
16-19 行 生成溢位桶,並存放在.extra 中,當一個正常的 Bmap 裝滿資料後,會去到 NextOverflow 中找到空閒的溢位桶,因為 Bmap 欄位中,也有個 overflow 的指標。(也就是說,一開始先保持空閒捅的指標,每個 bmap 資料也不多,當哪個桶裝滿了,就是那個桶的 overflow 指標指向原來閒置的的溢位桶地址,然後 nextOverflow 再繼續指向下一個空閒的溢位桶,也就是 nextOverflow 永遠指向下一個空閒的溢位桶,等待著哪個捅滿了需要新桶來裝資料了,再透過那個裝滿資料桶的 overflow 指向這個桶,然後 NextOverflow 接著移動指標指向新空閒桶)

go-32-009

Map 讀取資料

1.計算在哪個桶裡?

Hash("鍋包肉"+hash0),如果生成的二進位制是 0110001101001011,如果我們的 HMap 中的 B 是 3,那麼末尾取 3 位 011, 換算成十進位制就是 3,就可以拿到 buket 3,由於陣列是從 0 開始,所以也就是 4 號桶。

2.獲取 TopHash

獲取二進位制前 8 位 01100011,換算成 16 位是 0x63

3.遍歷 TopHash

到陣列中遍歷,看看哪個位置的 tophash 是 0x63

4.TopHash 相同

繼續檢視 key,如果相等,就返回元素,如果不相等,繼續對比查詢。

5.TopHash 不同

如果 4 號桶的陣列都遍歷完了,沒有 0x63 的 tophash,如果有溢位桶,那就再去溢位桶中查詢。如果都沒有找到,那就是找不到 key 所對應的元素。

Map 寫資料

1.找到對應的桶(桶自身或溢位桶)

2.找到對應的 key

3.修改資料的值

4.如果這個桶裡沒有對應的 key,那麼就直接插入一個

Map 擴容都做了什麼?

if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
    hashGrow(t, h)
    ...
}

可以看到有 2 個條件可以觸發 Map 的擴容

  1. hmap 不在增加並且溢位因子很多

    func overLoadFactor(count int, B uint8) bool {
         return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
    }

    bucketCnt 是 8,loadFactorNum 是 13,loadFactorDen 是 2

  2. 太多的溢位桶(這個會形成非常長的連結串列,導致嚴重的效能下降)

go-32-010

go-32-011

看一下程式碼

func hashGrow(t *maptype, h *hmap) {
    ...
    oldbuckets := h.buckets
    newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
    ...
    h.B += bigger
    h.flags = flags
    h.oldbuckets = oldbuckets
    h.buckets = newbuckets
    if h.extra != nil && h.extra.overflow != nil {
        ...
    }
}
  • 3 行 把原來的桶給 oldbuckets
  • 4 行 h.B+bigger 進行建立新桶和溢位桶
  • 6 行 更新 B 的值
  • 7 行 更新 flags
  • 8 行 把 oldbuckets 給 h.oldbuckets
  • 9 行 把 newbuckets 給 h.buckets
  • 10 行 溢位桶如果不為空,更新新桶的溢位桶

此時,新桶和老桶都存在,還沒涉及到資料遷移的問題,下面我們看
Hash(“鍋包肉”+hash0),如果生成的二進位制是 0110001101001011,如果我們的 HMap 中的 B 是 1,那麼末尾取 1 位 1, 換算成十進位制就是 1,現在擴容,B 是 2,末尾取 2 位,就是 11,換算十進位制就是 3,也就是說,未來資料會分配到 buket-1 和 buket-3 上。
接下來看如何處理資料

func growWork(t *maptype, h *hmap, bucket uintptr) {
    evacuate(t, h, bucket&h.oldbucketmask())

    // evacuate one more oldbucket to make progress on growing
    if h.growing() {
        evacuate(t, h, h.nevacuate)
    }
}

growWork()將舊桶上的資料放到新桶中去。

資料遷移完成後,把舊桶給 GC 了。

Map 是併發安全的嗎?

基於上面的學習,我們也可以看到 擴容前和擴容後, 當舊桶和新桶同時存在的時候,小明發起讀資料,小剛發起寫資料,小剛就會進入舊桶,進行資料遷移,那麼小明很有可能在讀取的時候,舊桶的資料已經被遷移到了新桶中,這樣資料就會讀錯亂。

下面看一下 Snyc.Map(注:上面的 map 是 runtime 包下的)

type Map struct {
    mu Mutex
    read atomic.Value // readOnly
    dirty map[any]*entry
    misses int
}

type readOnly struct {
    m map[any]*entry
    amended bool
}

type entry struct {
    p unsafe.Pointer // *interface{}
}
  • 2 行 mu 是一個鎖
  • 8-11 行 read 對應 readOnly 的結構體,readOnly 中的 m 是一個任意型別鍵和任意型別值的 map,entry 是包含一個 unsafe.Pointer 的指標 p 的結構體。
  • 10 行 amended 是修正的意思。
  • 4 行 dirty 是一個任意型別鍵和任意型別值的 map
  • 5 行 misses 未擊中

go-32-013

func (m *Map) Store(key, value any) {
    read, _ := m.read.Load().(readOnly)
    if e, ok := read.m[key]; ok && e.tryStore(&value) {
        return
    }

    m.mu.Lock()
    read, _ = m.read.Load().(readOnly)
    if e, ok := read.m[key]; ok {
        if e.unexpungeLocked() {
            m.dirty[key] = e
        }
        e.storeLocked(&value)
    } else if e, ok := m.dirty[key]; ok {
        e.storeLocked(&value)
    } else {
        if !read.amended {
            m.dirtyLocked()
            m.read.Store(readOnly{m: read.m, amended: true})
        }
        m.dirty[key] = newEntry(value)
    }
    m.mu.Unlock()
}

上面就是儲存資料的程式碼。

2-5 如果 read 中中可以找到這個 Key,並且沒有被標記被刪除,就 tryStore(),試圖去更新值。

7-24 如果 read 中不存在這個 Key 或者被標記為已刪除的情況,此時加鎖/解鎖。

9-13 再次讀取 read,此時已經找到了 Key,如果 entry 被刪除了,那麼就把這個 key 和 value 儲存到 dirty 的 map 中

14-16 dirty 的 map 中存在這個 key,更新這個值

17 到這一步,這個判斷證明 read 和 dirty 都沒有這個 key,如果 read 的 amended 為假,證明 read 和 dirty 的兩個 map 中的資料是相等的

18 如果 dirty 是 nil,就把 read 的資料都放到 dirty 中,否則 dirty 有資料,就怎麼都不做,直接返回。

19 標記 amended 為 true,證明 read 和 dirty 不同了

21 把資料放到 dirty 的 map 中。

單協程程式碼演示

go-32-012

上圖,從 goland 中列印出來的訊息看,資料都在 dirty 的 map 中。

func (m *Map) Load(key any) (value any, ok bool) {
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    if !ok && read.amended {
        m.mu.Lock()
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
            e, ok = m.dirty[key]
            m.missLocked()
        }
        m.mu.Unlock()
    }
    if !ok {
        return nil, false
    }
    return e.load()
}

func (m *Map) missLocked() {
    m.misses++
    if m.misses < len(m.dirty) {
        return
    }
    m.read.Store(readOnly{m: m.dirty})
    m.dirty = nil
    m.misses = 0
}

2-3 到 read 中的 map 查詢 key

4-13 read 中沒找到,並且 amended 為真,意味 read 和 dirty,這 2 個 map 資料不一致。(dirty 的資料通常是多的)

5-12 加鎖,操作 dirty 的 map

6-7 再次讀取 map

8 read 中仍然找不到這個 key,並且 amended 為真

9 去 dirty 中讀取該 key

10 給 misses +1,如果 m.misses == len(m.dirty),那麼就把 m.dirty 放到 read 中的 m 變數裡,然後 dirty 設定 nil,misses 設定為 0

14 read 中沒找到,但是 amended 為假,說明 read 和 dirty 資料相同,所以,直接返回 nil,false

17 read 中找到了 entry,直接呼叫 entry 的 load()方法就可以了

    var m sync.Map
    num := 100
    var w sync.WaitGroup
    w.Add(num)
    m.Store("《Go語言極簡一本通》第4次印刷", 1)
    m.Store("《Go語言微服務架構核心22講》", 2)
    m.Store("《Go語言+Redis》實戰課", 3)
    m.Store("《Go語言+RabbitMQ》實戰課", 4)
    m.Store("《從0到Go語言微服務架構師》訓練營", 5)
    m.Store("《Web3與Go語言》實戰課", 6)

    for i := 0; i < num; i++ {
        go func() {
            v2, _ := m.Load("《Web3與Go語言》實戰課")
            fmt.Println(v2)
            w.Done()
        }()
    }
    w.Wait()
    fmt.Println(m)

go-32-014

刪除方法原始碼分析

func (m *Map) Delete(key any) {
    m.LoadAndDelete(key)
}

func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    if !ok && read.amended {
        m.mu.Lock()
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
            e, ok = m.dirty[key]
            delete(m.dirty, key)
            m.missLocked()
        }
        m.mu.Unlock()
    }
    if ok {
        return e.delete()
    }
    return nil, false
}

6-7 讀取 read 中的 map 的 key

8 如果沒找到,並且 amended 為真,證明 read 和 dirty 中的 map 資料不相等

9-17 加鎖操作 dirty 的 map

10-12 再次讀取 read 中的 map,如果沒找到,並且 amended 為真

13 查詢 dirty 的 map 中 key

14 刪除 dirty 的 map 中 key

15 判斷是否把 dirty 的 map 提升到 read 中去

19-20 在 read 中找到了 entry,那麼就直接呼叫 entry 的 delete()

在 main 協程內,執行刪除操作

    var m sync.Map
    m.Store("《Go語言極簡一本通》第4次印刷", 1)
    m.Store("《Go語言微服務架構核心22講》", 2)
    m.Store("《Go語言+Redis》實戰課", 3)
    m.Store("《Go語言+RabbitMQ》實戰課", 4)
    m.Store("《從0到Go語言微服務架構師》訓練營", 5)
    m.Store("《Web3與Go語言》實戰課", 6)
    m.Delete("《Web3與Go語言》實戰課")
    fmt.Println(m)

go-32-015

多協程刪除操作

    var m sync.Map
    num := 100
    var w sync.WaitGroup
    w.Add(num)
    m.Store("《Go語言極簡一本通》第4次印刷", 1)
    m.Store("《Go語言微服務架構核心22講》", 2)
    m.Store("《Go語言+Redis》實戰課", 3)
    m.Store("《Go語言+RabbitMQ》實戰課", 4)
    m.Store("《從0到Go語言微服務架構師》訓練營", 5)
    m.Store("《Web3與Go語言》實戰課", 6)

    for i := 0; i < num; i++ {
        go func() {
            m.Delete("《Web3與Go語言》實戰課")
            w.Done()
        }()
    }
    w.Wait()
    fmt.Println(m)

相關文章