解碼 xsync 的 map 實現

轩脉刃發表於2024-07-17

解碼 xsync 的 map 實現

最近在尋找 Go 的併發 map 庫的時候,翻到一個 github 寶藏庫,xsync (https://github.com/puzpuzpuz/xsync) 。這個庫提供了一些支援併發的資料結構,計數器Counter,雜湊 Map,佇列Queue。我著重看了下它的 Map 的實現,遇到一個新的知識點:Cache-Line Hash Table (CLHT) 。問了半天 GPT,大致瞭解了其中的內容,這裡總結下。

利用 Cache-Line 實現無鎖程式設計

CacheLine 是 CPU 一次性讀取記憶體的最小單元。它在不同的硬體裝置上有不同的大小。在x86-64的機器上是 64 位元組。就代表著 CPU 一次效能從記憶體中獲取64 位元組的大小。CPU處理器在處理一個變數資料的時候,會依次從暫存器,CPU 快取,記憶體,磁碟中進行獲取,當然他們的處理速度也是依次遞減。

當計算機中多個 CPU 核讀寫同一個資料結構的時候,他們每次都會讀取CacheLine 結構的資料進入自己的 CPU 快取中。這裡不同的資料結構設計,就會有不同的效能。

假設有一個大的資料結構 Object,a1 核負責讀Object 的 field1 欄位,而 a2 核負責寫 Object 的 field2 欄位,而 field1 和 field2 都在同一個 Cache Line 中,這就意味著 a1 和 a2 在平行計算的時候,都會把包含有 field1 和 field2 欄位的 Cache line 讀取到自己的 CPU 快取中。那麼問題來了,當 a2 核變更 field2 欄位的時候,就要想辦法通知a1 核,更新 CPU 快取,否則a1 核計算可能是有問題的。a2核變更通知 a1 核更新 CPU 快取,這種互動機制叫做 MESI。

image-20240715003658244

當然這種互動機制是非常低效的。我們應該想辦法儘量避免!

其中一種避免的方法之一就是使用鎖,在修改資料的時候上鎖,讀取資料的時候讀鎖。但這種方式並不高效。我們在想,是否有一種無鎖的程式設計方式呢?

控制 Object 結構的設計是個好辦法,我們設計結構將其中的 field1 欄位放在一個 CacheLine 中,另外一個 field2 在第二個 CacheLine 中,那麼如果我們使用 a1 執行緒讀 field1,a2 執行緒寫 field2,那麼我們就能做到無鎖讀寫。

這就是利用 Cache-line 實現的無鎖程式設計。

基於 CacheLine 的雜湊表

節點設計

現在回到 hash 表,我們使用 hash 表的時候最頭疼的就是 hash 表是非併發安全的,一般我們使用 hash 表的時候,都會帶一個全域性鎖,我們讀寫hash 表的時候會讀或者寫一下這個全域性鎖。

但是這明顯效率就比較低了。

要想效率高,Cache-Line Hash Table (CLHT) 就提出了使用 CacheLine 的邏輯來最佳化 hash 表。一個 hash 表一般就是一個 hash 函式+節點連結串列,我們如果讓每個節點都保持一個 CacheLine 的大小(64 byte)。那麼每次 cpu 讀寫的時候,就只會讀取一個完整的節點進入到 cpu 快取中,這樣不是就能無鎖使用 hash 表了嗎?

是的,這種方案確實可行,但是最重要的就是設計這個 由多個hash節點組成的 hash 表結構。

我們先需要回答:一個 CacheLine(64 bytes = 64 * 8 bit = 512 bit) 能儲存多少個hash key-value 對呢?

解:

一個指標是 uint64 型別,hash 的每個 key-value 對中 key 和 value 都是指標,key 指向一個 string,value 指向一個 interface{}, 即任何的資料結構, 那麼一個 key-value 對佔2*64bit = 128bit。

由於 hash 表中相同 hash的節點是透過指標鏈連結起來的,所以至少節點中要儲存一個指向 next 節點的指標,uint64 = 64bit。

所以一個 cacheline 最多可以有3 個key-value 對 + 1 個 next 指標 = 128 * 3 + 64 = 448 bit。

解答完畢。

但是如此設計,cacheline 的空間還有盈餘,還多了一個 512 - 448 = 64 bit 的大小,我們利用這個空間設計了一個 topHashMutex 結構(uint64),具體它是做什麼用的,後面詳聊。

我們的 bucke 節點在程式碼中如上設計,實現如下:

type bucket struct {
	next   unsafe.Pointer // *bucketPadded
	keys   [3]unsafe.Pointer
	values [3]unsafe.Pointer
	
	...
	topHashMutex uint64
}

而我們的 hash 表結構就有如下展示:

image-20240716171705057

不同場景分析

這樣根據 cacheline 設計 hash 表,是否能實現真正的無鎖化呢?我們需要分析不同場景:

1 兩個 cpu 核,讀取兩個不同的 bucket 節點

這是我們最希望見到的情況,由於我們事先設計了每個 bucket 節點正好是一個 hash 大小。

所以兩個 cpu 讀取自己的cpu 快取即可,裡面的節點互相不干擾,這個時候效率非常高。

2 兩個cpu核,讀取相同的 bucket 節點

這個 bucket 節點會從記憶體中被複制兩份到兩個 cpu 快取中,但是這種場景,由於沒有任何更新操作,我們也用不到任何鎖。

3 兩個 cpu 核,對相同 bucket 節點,一個在讀取,另一個在更新

這種情況,我們要保證的是讀取的操作一定是原子的,我們可以讀取更新前的值,也可以讀取更新後的值,但是不能讀取一箇中間無效的值。

所以讀取的 cpu 核在讀取自己 cpu 快取內容的時候,必須小心 cpu 快取被修改,而導致了無效值。那麼我們能怎麼做呢?

指標快照的方法(snapshot)

首先,我們先從 bucket 節點中找到目標 key-value 對(這裡如何快速找到後面會說),我們先讀取一次key1 和 value1 ,但是注意,由於之前設計,我們bucket 裡面儲存的是key1 指標,value1 指標,所以我們實際讀取的是指標。這個時候並不直接使用這個指標指向的內容,而是相當於我們為 key1 和 value1 做了一個快照。

這裡要注意的是,讀取 key1 和 value1 的指標快照是2 個原子操作。但是這兩個原子操作,由於另外一個核在更新這個 key-value 對,就是在透過 MESI 機制同步修改我們的 cpu 快取,我們是有可能讀取到一個無效指標 value1 的(我們是否不會讀到無效指標 key1,因為更新操作不會修改 key1 的指標)。

那麼我們如何確定 value1 是可用的呢?辦法就是我們再取一次cpu 快取中的 key1 和 value1 指標,判斷他們是否有變化。

如果快照 key1和快照 value1 等於第二次查詢的 key1 和 key2,那麼就證明快照的 key1 和 value1 是可用的,不是正被修改中的記憶體。

如果快照 key1和快照 value1 不等於第二次查詢的 key1 和 key2,那麼就證明快照的 key1 和 value1 是不可用的,當前正在有其他cpu 在修改我的 cpu 快取,這時候要做的就是重新進行快照過程。

這就是 atomic snapshot 的方法。

程式碼實現如下:

func (m *Map) Load(key string) (value interface{}, ok bool) {
	...
	for {
		...
		atomic_snapshot:
			// Start atomic snapshot.
			vp := atomic.LoadPointer(&b.values[i])
			kp := atomic.LoadPointer(&b.keys[i])
			if kp != nil && vp != nil {
				if key == derefKey(kp) {
					if uintptr(vp) == uintptr(atomic.LoadPointer(&b.values[i])) {
						// Atomic snapshot succeeded.
						return derefValue(vp), true
					}
					// Concurrent update/remove. Go for another spin.
					goto atomic_snapshot
				}
			}
		}
		
		bptr := atomic.LoadPointer(&b.next)
		if bptr == nil {
			return
		}
		b = (*bucketPadded)(bptr)
	}
}

引申一下,這個更新操作,實際換成刪除操作也是生效的,因為刪除操作相當於試一次特殊的更新(將 value1 的指標替換為 nil)。

4 兩個CPU核,對相同的 bucket 節點,兩個都在寫

在這種場景下,我們需要保證只有一個 cpu 核在寫,另外一個需要等待,我們不得不使用鎖了,但是這個鎖是非常小的,它只保證鎖住 cacheline 就行了。

鎖放在哪裡呢?前面設計的 uint64 topHashMutex , 我們只需要使用1bit 的大小(最後一個 bit),標記 0/1就行了,0 代表沒有鎖,1 代表鎖。

更新操作的時候,我們需要用 atomic.CompareAndSwapUint64 來搶到這個 topHashMutex 的最後一個 bit 的鎖。

如果搶到的話,當前 cpu 核就可以心安理得的處理自己的 cpu 快取區的內容,並且通知其他的 cpu 快取區內容進行更新。

如果沒有搶到的話,當前 cpu 核使用自旋鎖,進入鎖等待階段,runtime.Gosched(), 讓渡這個 goroutine 的執行權。等著go 排程機制再次排程到到這個goroutine,再次搶鎖。

加鎖程式碼邏輯如下:

func lockBucket(mu *uint64) {
	for {
		var v uint64
		for {
			v = atomic.LoadUint64(mu)
			if v&1 != 1 {
				break
			}
			runtime.Gosched()
		}
		if atomic.CompareAndSwapUint64(mu, v, v|1) {
			return
		}
		runtime.Gosched()
	}
}

同樣引申一下,這個情景也適用於兩個 CPU的刪除操作,或者更新刪除操作並行的情況。cacheline 小鎖的機制,保證了同一時間只有一個 cpu 核能對這個節點進行操作。

如何加速節點中 key-value 對的查詢

好了,以上四種情況基本把併發讀寫同一個 map 節點的情景都列出來了。

但是還差一點,bucket 中的 topHashMutex 結構還有 63bit 的剩餘空間,我們是否可以利用它來加速key-value 對的查詢?答案是可以的,我們可以透過建立索引機制來加速。

我們將這個 63bit 分為 3 x 20 + 3 。前面的 3 個 20 是3 個 key-value 對的 key 值的索引。至於索引方式嘛,我們可以簡單將 key 的 hash 值的前 20 位作為索引。這樣我們在查詢一個 key 的時候,先判斷下其 hash 值的前 20 位在不在這個索引中,就能大機率判斷出是否在這個 bucket 節點中了。

但是建立索引還是不夠,前面說過了,刪除某個 key-value,我們是直接將 value 的指標置為 nil,那麼這個時候,它的 key 還存在,我們需要標記位來標記這個 key 是否能用。

topHashMutex 後面的 3 個 bit 就啟動用處了, 0/1表示3 個 key-value 是否可用。

topHashMutex 的結構如下:

image-20240717014513117

我們再舉例說明:

假設我有一個 key 為 "foo", value 為 struct Bar。儲存時,我們算好要存入 bucket 的第二個 key-value 位置。

"foo" 的 hash 值為 uint64: 1721009463561,轉為二進位制:1100010000110110110110110110110111101001001,取前 20 位,11000100001101101101。

我們把 topHashMutex 的第二個 20bit 設定為11000100001101101101。再把 topHashMutex 的第 62(3 x 20 + 2)設定為 1.表示可用。

在查詢操作,我們在拿著 key = "foo" 來查詢 value 的時候,先去判斷 key 的 hash是否在前 60bit 中,然後再確認下對應的 bitmap 是否是可用的,我們就能判斷目標 key 大機率是在這個 bucket 的第二個位置,我們這時候再走快照邏輯,判斷快照的 key 的值是否是 “foo”,並且快照原子獲取其value 值。

結論

這就是這個開源 go 的 xsync 庫中的 Map 結構的核心原理了。確實是非常巧妙的設計思路。核心思想就是利用cpu 一次讀取 cacheline 大小的內容進 cpu 快取區,就設計一個符合這個特性的 hash 表,儘量保證每個 cpu 的讀取互不干擾,對於可能出現的併發干擾的情況,使用快照機制能保證讀取的原子性,這樣能有效避免全域性鎖的使用,提高效能。

至於可以看這個 benchmark 測評,https://github.com/puzpuzpuz/xsync/blob/main/BENCHMARKS.md 比較了xsync 的 map 和標準庫 sync.Map。基本上真是秒殺,特別是在讀寫混雜的情況下,xsync 能比 sync.Map 節省2/3 的時間消耗。

image-20240717015313392

參考

cacheline 對 Go 程式的影響

CPU快取記憶體與極性程式碼設計

相關文章