Skip to main content

Command Palette

Search for a command to run...

了解 Go Map

Updated
4 min read
了解 Go Map

GO map shrink 最近同事給了我這篇文章, 文章想證明 Go 的 map 哪怕改成 swisstable 版本, 在元素都刪除後且 GC 發生後, map 的大小依然不那麼小, 好像不會 shrink. 但這問題的根本我們得先了解 map 的組成.

1.23 版本的 Map

Source Code Go 的 map 實際上是基於 hash table 的資料結構。資料存儲在一個 bucket 的array 中,每個 bucket(Go的map中叫 bmap) 可以存放 8 個key value pair 。 選擇bucket的方式是透過 hash value 的低位值來選擇對應的bucket。如果要存入新的元素時, 所處的 bucket 元素個數已經 8 個了, 則會進行 overflow 的處理, 會使用額外的 bucket 來存儲這些額外的key value。新的overflow bucket 會被鏈接到本來的 bucket,形成一個linked list結構。當然 overflow bucket 只去臨時性的應對方案, 當 overflow bucket 太多時會進行 groupup 擴容.

而當 hash table 的大小達到一定的負載因子(load factor) 時,會將 bucket 的數量成倍增加,通常是將 bucket array 的大小擴展為原來的兩倍。在擴展過程中,舊的 bucket array 中的 bucket 會被逐步複製到新的、更大的 bucket array中(hashGrow+growWork+evacuate )。

// map 的主要結構,包含了關於整個 map 的 metadata
type hmap struct {
    count     int // # live cells == size of map.  Must be first (used by len() builtin)
    flags     uint8
    B         uint8  // log_2  of buckets (can hold up to loadFactor * 2^B items)
    noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
    hash0     uint32 // hash seed

    buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
    oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
    nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

    extra *mapextra // optional fields
}
// 用於儲存與 map 相關的額外資訊,特別是與 overflow bucket 管理相關的內容。
type mapextra struct {
    overflow    *[]*bmap
    oldoverflow *[]*bmap
    nextOverflow *bmap
}
// 每個 bucket 的結構,實際儲存 key-value pair 的數據
// 但因為元素的key與value 的類型其實是動態執行時期才會得知的, 所以編譯時期如下
type bmap struct {
    tophash [abi.MapBucketCount]uint8
}

// 當宣告好 map 的類型時, 就會產生如下的結構
type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

Tophash

bmap 中的 tophash設計很有意思, 因為這樣的設計一定會有不同的 key, 經過hash後的低位值, 選擇在同一個bucket中儲存, 但為了能快速找到空位, 如果一個一個位置慢慢走訪這樣在新增查詢的場景效率會很差.

所以設計了 tophash 來快速的定位是放在bucket 的哪個位置, 但還是有可能該位置有人, 所以設計了以下的值用來快速代表一些語意, 不同的 tophash 值代表了不同的含義,讓我們一起來看看:

其中evacuatedX, evacuatedYevacuatedEmpty都與migration有關.

emptyRest      = 0 // 整個都空的, bucket是初始的狀態, i.e. 沒overflow。
emptyOne       = 1 // 這個 cell 是空的。後面不知道.
evacuatedX     = 2 // key/elem 是有效的。這個 entry 已經遷移到了新的 hashtable 的前半部分。
evacuatedY     = 3 // key/elem 是有效的。這個 entry 已經遷移到了新的 hashtable 的後半部分。
evacuatedEmpty = 4 // 這個 cell 是空的,但是 bucket 已經被 migrated了。
minTopHash     = 5 // 這是一個正常填充 cell 的最小 tophash 值。
// tophash calculates the tophash value for hash.
func tophash(hash uintptr) uint8 {
    // 從輸入的哈希值 hash 中取出高位 8 位,作為 top 變數的初始值。
    top := uint8(hash >> (goarch.PtrSize*8 - 8))
    if top < minTopHash {
        // 如果 top 的值小於 minTopHash 常數
        // ,則將 top 加上 minTopHash 的值。這是為了確保 tophash 值不會太小。
        top += minTopHash
    }
    return top
}

tophash 函數的目的是為了在 map 的查找過程中提高效率。在 mapaccess1 函數中,我們看到會先比較 tophash 值,如果不匹配就可以快速跳過當前 bucket。這樣可以減少不必要的遍歷,提高查找速度。

負載因子 Load factor

在 Go 語言的哈希表設計中,負載因子(load factor)是一個關鍵參數,它影響 hash table 的性能和記憶體的使用。以下是對於負載因子及其影響的詳細說明:

負載因子的選擇:

  • 適當的負載因子可以平衡 hashtable 的性能和記憶體使用。

  • 如果負載因子設置得太大,會導致許多 overflow buckets,增加查找和插入操作的開銷。

  • 如果設置得太小,則會浪費記憶體空間,因為會有很多空桶。

統計數據解釋:

以下是 Go 團隊對不同負載因子的統計數據的解釋:

負載因子溢出桶百分比 (%). 表示有多少百分比的桶擁有溢出桶。這是一個指標,顯示hashtable的擁擠程度。bytes/entry 這個數字越小,表示記憶體使用越高效。命中探查次數 查找已存在key時需要檢查的資料數量。這個數字越小,表示查找效率越高。命中探查次數
4.002.1320.773.004.00
4.504.0517.303.254.50
5.006.8514.773.505.00
5.5010.5512.943.755.50
6.0015.2711.674.006.00
6.5020.9010.794.256.50
7.0027.1410.154.507.00
7.5034.039.734.757.50
8.0041.109.405.008.00

因此選擇適當的負載因子對於確保 Go hashtable的性能至關重要。透過上述統計數據,可以更好地理解如何選擇負載因子,以達到最佳的性能和記憶體使用效果。

透過 load factor 計算 hashtable 是不是用於擁擠而過載的公式如下.

const (
  loadFactorDen = 2
  loadFactorNum = loadFactorDen * abi.MapBucketCount * 13 / 16
)

// overLoadFactor reports whether count items placed in 1<<B buckets is over loadFactor.
func overLoadFactor(count int, B uint8) bool {
    return count > abi.MapBucketCount && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

但我看不懂, 白話點就是,

$$Load Factor = \frac{Number of Elements}{Number of bucket}$$

網路很多文章說算出來超過 6.5 就會擴容, 6.5 還是寫死的, 這我就不懂了.

Aleksandr Kochetkov : some-insights-on-maps-in-golang

Group up 擴容與遷移Migration

當 load factor 過載時, 就會觸發 map 的擴容以及對應的migration機制.

首先觸發的是`growWork` 負責在成長過程中進行桶的清理和合併。它會檢查當前桶是否需要進行清理,並將舊桶的內容移動到新的桶中。

在搬遷到新bucket的過程中, evacuate負責將舊桶中的元素移動到新的桶中,確保元素按照 hash value 正確分配到新的位置, 這步驟就是在做 Rehash.

這個過程其實成本很高的, 像是在 evacuate 就需要把所有元素都走訪一次並且 rehash, 需要大量 CPU 資源. 然後同時又會需要兩份的bucket空間, 這當下會需要不少記憶體.

migration過程中, hmap結構中的oldbuckets 此時就會把原本的bucket做個備份在此. 也能看到有個判斷是不是正在遷移中的函數, 就是直接判斷 oldbuckets 是否為空.

// growing reports whether h is growing. The growth may be to the same size or bigger.
func (h *hmap) growing() bool {
    return h.oldbuckets != nil
}

結論

我講一堆還是沒講到為什麼 map 儲存的元素明明都刪除了(不管value是實值類型還是指標), 手動呼叫 GC 了, 但記憶體配置的大小還不如預想中的小. 是因為 hmap 的各種bucket, 並不會被 GC 回收 XD, 要等到該map變數被重新allocate或沒變數在參考到該map時才會真的被回收乾淨.

因為上面我們隱約能發現, 擴容跟migration其實很耗資源成本, 好不容易已經被擴容完成的map, 就不會刻意在減少bucket 的大小了.

但也因此如果我們能很精準地宣告出map的元素個數的話, 那其實這一切的擴容行為都能避免. 如下:

m := make(map[string]int32, 10000)

不過1.24 應該會改用 Swiss table, 但hmap的本質是不變的, 很多概念應該還是會沿用.

能參考資料[Go map使用Swiss Table重新实现,性能最高提升近50%](https://tonybai.com/2024/11/14/go-map-use-swiss-table/)

415 views

Go

Part 6 of 15

Introducing Go and its related designs

Up next

Go 的測試到底要放哪?

test package

More from this blog

Claude Code 監控秘錄:OpenTelemetry(OTel/OTLP)實戰指南

稟告主公:此乃司馬懿進呈之兵書,詳解如何以 OpenTelemetry 陣法,令臥龍神算之一舉一動盡在掌握,知糧草消耗、察兵器效能、辨戰報異常,使主公運籌帷幄於大帳之中。 為何需要斥候情報? 司馬懿稟告主公: 臥龍神算(Claude Code)乃當世利器,然若無斥候回報,主公便如蒙眼行軍——兵器耗損幾何、糧草消費幾許、哪路斥候出了差錯,一概不知。臣以為,此乃兵家大忌。 無情報之弊,有四: 軍

Feb 19, 202610 min read164
Claude Code 監控秘錄:OpenTelemetry(OTel/OTLP)實戰指南

工程師的 Claude Code 實戰指南:從零開始到高效開發

工程師的 Claude Code 實戰指南:從零開始到高效開發 本文整合 Anthropic 官方 Best Practices 與社群實戰 Tips,帶你由淺入深掌握 Claude Code。 什麼是 Claude Code?為什麼值得學? 如果你還在用「複製程式碼貼到 ChatGPT,再複製答案貼回去」的工作流程,Claude Code 會讓你大開眼界。 Claude Code 是 Anthropic 推出的命令列工具,它直接活在你的 terminal 裡,能夠讀懂你的整個 codeb...

Feb 18, 20265 min read75
工程師的 Claude Code 實戰指南:從零開始到高效開發

System Design Interview Ch 12 Digital Wallet

確立問題與設計範疇 角色對話內容 面試者我們應該只關注兩個數位錢包之間的餘額轉帳操作嗎?我們是否需要擔心其他功能? 面試官讓我們只關注餘額轉帳操作。 面試者該系統需要支援多少 TPS(每秒交易次數)? 面試官讓我們假設是 1,000,000 TPS (每秒 100 萬次交易)。 面試者數位錢包對正確性有嚴格的要求。我們可以假設事務保證 就足夠了嗎? 面試官聽起來不錯。 面試者我們需要證明正確性嗎? 面試官這是一個很好的問題。正確性(Correctness)通常只有在交...

Feb 2, 202610 min read190
System Design Interview Ch 12 Digital Wallet

Claude Code 利用 Event-Driven Hooks 打造自動化開發大腦

在現代 AI 輔助開發中,我們不僅需要 AI 寫程式,更需要它懂規則、記性好,並且能自動處理那些繁瑣的雜事。透過 Claude Code Hooks 機制,我們可以介入 AI 的思考與執行迴圈,實現真正的「人機協作自動化」。 一、 動機與痛點:為什麼你需要介入 AI 的生命週期? 在預設狀態下,Claude Code 雖然強大,但它是「被動」且「無狀態」的,這導致了開發者常遇到以下痛點: 記憶重置 (Session Amnesia): 痛點:每次重啟終端機,AI 就像失憶一樣。 解法:你...

Jan 24, 20266 min read461
Claude Code 利用 Event-Driven Hooks 打造自動化開發大腦
M

MicroFIRE

71 posts