1. 程式人生 > >golang 的 map 實現(二)

golang 的 map 實現(二)

如何擴容

map 增長過程中,對空間的需求也在增加,那麼如何完成透明擴容的同時,又不會太多影響效能,就是這裡要討論的。

僅考慮空間增長,map 的擴容方式有兩種,overflow 以及 hashGrow

  • overflow 是溢位鏈,是 bucket 級別的擴容,理解成連結串列
  • hashGrow 則是再雜湊的實現。空間變大的同時,重新進行雜湊,不過 rehash 過程不是同步,而是被攤還到了 mapassign 以及 mapdelete變更 的操作上。同時,在這個過程中也涉及老空間的釋放,如何釋放的這個部分將放在下面 『如何遷移』裡面討論

overflow

map 儲存資料的組織形式是通過 bucket 這個結構來做的。 然後在資料結構中的 h.bucket

是代表一組 bucket,其中的每個 bucket 與對應的 overflow 區域,組成了一個連結串列。後面用 表頭 代表 h.bucket 中的 bucket, 用 bucket 代表 bucket 資料結構。 注意:這個表頭不是一個 dummy 節點,也是要用來放資料的。

新的連結串列節點的增加是通過函式 newoverflow 實現的,主要工作就是先獲取空間(可能是預先分配的,也有可能是從系統新申請),然後將其放在連結串列的末端。(針對 overflow[0] 的管理,目前不是很確定管理條件),對應程式碼和解釋如下。

需要注意的就是:在針對預分配的空間和新申請空間的邏輯是有些不同的

func (h *hmap) newoverflow(t *maptype, b *bmap) *bmap {
    var ovf *bmap

    if h.extra != nil && h.extra.nextOverflow != nil {
        // 如果在預先建立了 overflow 區域,則先從本區域進行獲取
        ovf = h.extra.nextOverflow
        if ovf.overflow(t) == nil {
            // overflow() 是讀取的該 bucket 最後一個指標空間,是否有值
// 有則代表預先申請的 overflow 區域已經用完,還記得 makeBucketArray 最後的設定嗎? // 是儲存的 h.buckets 的起始地址 // 然後 nextOverFlow 維護預申請 overflow 域內的偏移量 h.extra.nextOverflow = (*bmap)(add(unsafe.Pointer(ovf), uintptr(t.bucketsize))) } else { // 預先的已經用完了。此時的 ovf 代表了 overflow 內最後一個 bucket,將最後的指標位設定為 空 // 並標記下預先申請的 overflow 區域已經用完 ovf.setoverflow(t, nil) h.extra.nextOverflow = nil } } else { // 沒有預先申請或或者之前已經用完,則從系統中獲取 ovf = (*bmap)(newobject(t.bucket)) } h.incrnoverflow() if t.bucket.kind&kindNoPointers != 0 { // 這個比較的意義我並沒有 get 到,後面附上了大神的解釋,希望以後能看明白 if h.extra == nil { h.extra = new(mapextra) } if h.extra.overflow[0] == nil { h.extra.overflow[0] = new([]*bmap) } h.extra.overflow[0] = append(*h.extra.overflow[0], ovf) } b.setoverflow(t, ovf) // setoverflow 就是將一個節點新增到某個節點的後方,一般就是末位節點(連結串列結構) return ovf }

關於 t.bucket.kind&kindNoPointers, 得到了大佬的回覆如下(雖然我仍舊沒有弄明白)

In that code t is *maptype, which is to say it is a pointer to the
type descriptor for the map, essentially the same value you would get
from calling reflect.TypeOf on the map value. t.bucket is a pointer
to the type descriptor for the type of the buckets that the map uses. This type is created by the compiler based on the key and value types
of the map. If the kindNoPointers bit is set in t.bucket.kind, then
the bucket type does not contain any pointers.

With the current implementation, this will be true if the key and value types do not themselves contain any pointers and both types are less than 128 bytes. Whether the bucket type contains any pointers is interesting because the garbage collector never has to look at buckets that contain no pointers. The current map implementation goes to some effort to preserve that property. See the comment in the mapextra type.

訪問到 overflow 區域有兩種情況
1. 空間滿了
2. 發生了碰撞

hashGrow

hashGrow 的流程如下

  1. 申請新空間(可能同等大小。暫時不考慮)
  2. 將當前 buckets 和 overflow 的鏈,儲存到 oldbuckets 以及對應的鏈
  3. 處理可能的 預申請 overflow 區域
  4. 資料遷移(非同步攤還)

注意: 過程 2 中有一個細節問題,即在進行切換的時候,要求此時的 map 務必不能在 growing 中。即不能同時執行兩個增長,map 在實現上確保了這個情況不會發生,後面會說。

資料遷移

hashGrow 是一個比較長的狀態,從空間申請,一直到資料完成遷移才算結束。那麼中間資料是如何遷移的,是這一小節的重點。有兩個問題需要注意

  1. 資料如何遷移
  2. 如何保證了遷移的完成(即如何確保不會同時執行兩個 hashGrow

資料遷移的入口程式碼如下,在 mapassignmapdelete 中都能找到

    bucket := hash & (uintptr(1)<<h.B - 1)
    if h.growing() {
        growWork(t, h, bucket)
    }

注意:遷移單元不是 一條資料,而是一整個 bucket !!!!

下面是 growWork 的程式碼

func growWork(t *maptype, h *hmap, bucket uintptr) {
    // make sure we evacuate the oldbucket corresponding
    // to the bucket we're about to use
    evacuate(t, h, bucket&h.oldbucketmask())

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

注意growWork 呼叫了兩次 evacuate,一次是釋放之前的資料所落在的 bucket 區域(正常邏輯就是這個),還一個有個是 h.nevacuate,而正是這個呼叫確保了,再次執行 hashGrow 的時候,老資料已經完成了遷移。

evacuate 的末尾有這麼一段程式碼

    // Advance evacuation mark
    if oldbucket == h.nevacuate {
        h.nevacuate = oldbucket + 1
        // Experiments suggest that 1024 is overkill by at least an order of magnitude.
        // Put it in there as a safeguard anyway, to ensure O(1) behavior.
        stop := h.nevacuate + 1024
        if stop > newbit {
            stop = newbit
        }
        for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
            h.nevacuate++
        }
        if h.nevacuate == newbit { // newbit == # of oldbuckets
            // Growing is all done. Free old main bucket array.
            h.oldbuckets = nil
            // Can discard old overflow buckets as well.
            // If they are still referenced by an iterator,
            // then the iterator holds a pointers to the slice.
            if h.extra != nil {
                h.extra.overflow[1] = nil
            }
            h.flags &^= sameSizeGrow
        }
    }

這段程式碼 配合 growWork 中的第二次呼叫 evacuate ,確保了每次能遷移掉兩個 bucket,那就不難理解 h.nevacuate 這個狀態標籤,其含義是,在此之前的 bucket 都已經被遷移。並且,每次都是在操作 一個 資料的時候,就會遷移兩個 bucket,那麼就在實際上保證了在執行下一次 hashGrow 的時候, oldbuckets 已經被清空了,也就是上次的擴容工作已經完成。這樣就解決了最開始提出來的第二個問題。

下面再說如何遷移這個問題(只討論容量變大的場景)。

func evacuate(t *maptype, h *hmap, oldbucket uintptr)

evacuate 接受的引數重點是 oldbucket ,代表的其實是一個偏移量,說明是 表頭中的位置。然後遷移工作是將本表頭及其有的溢位區域全部進行遷移。

那麼遍歷邏輯就如下所示了,遍歷 overflow 區域,然後遍歷內部的 tophash,獲取到所有的 k/v 對

for ; b != nil; b = b.overflow(t) {
   ...
   for i := 0; i < bucketCnt; ... 

此時就可以執行 rehash 了,來確定存放在新空間什麼位置。有意思的地方就在這裡了。

因為空間是 2 倍擴容,那麼 rehash 後的落點,要麼仍舊是原來的位置,要麼是原來的位置加上原先容量的位置。

基於這個邏輯,go 把擴容後的空間,分為 X、Y 兩個區域,X 在前,Y在後,兩個空間均與擴容前空間相同,那麼如何進行快速的確認落點就是關鍵了,確認方式是通過下面這個公式計算。

    useX = hash&newbit == 0

還記得計算餘數的公式麼 mod = hash & (2^n -1) 這裡是把 二進位制形式的 hash 的第 n 位以上(包含第 n 位)給過濾掉了。那麼判斷是否放在 Y 則僅需要判斷第 n 為是否為 1 即可了。

思考

看玩這個遷移邏輯的時候,考慮到一個細節問題,然後又去確認了 mapassign 的邏輯。

發現在寫入一個 key 的時候,並不是碰見一個可以寫入的位置就立刻寫入的。而是優先找到相等的項,也就是優先考慮 更新操作 而不是 插入操作。 那麼這裡就與之前看到的一本書有所出入。這樣就保證了一個 key 只可能出現一次。 那麼刪除操作也只需要刪除一次就可以了。

如果不考慮 tophash 那麼,效能上將,針對 增刪改查 其實是比較平均的狀態。 因為資料中沒有冗餘,那也就沒有傾斜了。

閱讀完後認為設計比較好的地方:

tophash

  1. 控制比對消耗:key 的長度可能不固定,如果使用原始字串做碰撞檢測,那麼此處不可控
  2. 快取:直接將 hash 進行計算,然後儲存,能很大概率避免讀取 key 的原始字串
  3. 使用高 8 位(拆解成兩個問題)
    1. 使用 8 位,可以肯定的是減少了記憶體消耗,但是為什麼是 8 ,有待考證
    2. 之所以使用高 8 位,是因為比低 8 位的碰撞率低,因為低 8 位後幾位的值相等

其他
1. key 和 value 儲存的是引用,使得資料結構緊湊。然後因為分割槽域儲存,那麼也就使得定位方便了很多(很多資料都講過這裡)
2. 資料遷移的攤還時間是一個設計上的選擇
3. 一個很妙的地方在於 rehash 時候對於 X Y 的選擇,雖然沒看懂
4. 還有一個很隱晦,但是很妙的地方就是隱式保證了不會同時指向兩個 hashGrow
5. 用同事的話 『能夠感受到一種說不出來的內功,貫穿整個程式碼,這可能就是所謂的 心手合一』