freecache原始碼分析
freecache實現分析
概述
作用比較直白,類似map的kv記憶體結構,所以hash會成為它的主要基調,也是為何能高效的根本。整個freecache被劃分成了256個segment,根據key的hash值選擇具體的segment,這一點我認為是它對併發友好的原因,假如不同執行緒操作不同的segment,意味著各自都是獨立的空間,無需加鎖(當然,它並沒有做到這一點)。每個segment可以分成兩部分:真實的資料儲存以及對存放方式的描述結構。前者是一個ringbuff,所有kv結構的具體資料存放在這裡,比較容易理解。後者相對麻煩一些,它描述了segment的邏輯組成:256個slot,每個slot含有不定數量的entry,每個entry就是每條kv記錄的描述資訊。
這裡是它的原始碼 freecache ,宣稱有不少好處,有一點是說Zero GC overhead,我覺得應該主要是因為它整體上其實是一個位元組陣列空間,並無任何物件的存放,所以不存在物件的分配和釋放,鑑於自己對golang GC的無知,後續再補充。
這裡有一個插曲,是給作者提了一個。
資料結構
如圖所示。

這裡面我認為有點不好理解的就是slot的組織方式。在segment的結構中,與slot存放相關的有3個變數:
-
slotLens:是一個數組,存放了每個slot中實際的entry個數。
-
slotCap:是一個具體的數值,意味著每個slot中最多可以存放多少個entry,如果實際存放的個數超過了這個容量就需要擴充(seg.expand),按*2擴充套件(熟悉c++STL的人可能比較明白,這裡倒也不是拍腦袋的結論,最樸素的原因就是確保在於每次新分配的尺寸必然大於之前所有分配的總和,這樣可以確保之前的資料具有O(1)的插入效率,但也不能太多,否則浪費,這裡2倍剛好滿足這個需求。但也會導致無法重用之前已經釋放的快取,效率有所降低,所以有的C++庫會選擇1.5倍,比如Facebook的Folly的FBVector。)
-
slotsData:是一個數組,存放了所有slot中包含的entry。
具體操作
set
一般步驟
-
根據key的hash值的低16位作為索引定位到某個segment。segId := hashVal & 255
-
定位segment的slot:slotId := uint8(hashVal » 8)
-
定位key對應的entry:seg.lookup(slot, hash16, key)
-
如果該key已經存在了:
- 當value的長度不大於之前value的長度(valCap),那麼直接在當前位置寫入kv記錄
- 否則,刪除並擴充套件value的容量
-
檢查當前ringbuff的空間是否滿足將當前kv記錄存入,是則插入否則按LRU演算法淘汰部分則插入
一些細節
-
每一條kv記錄都有兩部分組成,頭部資訊和entry描述結構,其中頭部和真實的kv資料會存放在segment的ringbuff中:
type entryHdr struct { accessTime uint32// 最近一次訪問kv記錄的時間 expireAtuint32// 超時時間 keyLenuint16// hash16uint16// 當前entry所在slot的位置 valLenuint32 valCapuint32// 當前entry所能容納的val的最大長度, // 如果重複寫入的value小於這個值會直接在當前位置寫入, // 否則會擴充這個value的容量 deletedbool// 為了避免不必要的記憶體拷貝,刪除的時候並不會移動其他kv結構來覆蓋當前kv, // 而只是通過這個標記來識別當前kv是無用的 slotIduint8// 當前entry所在的slot reserveduint16 } type entryPtr struct { offsetint64// 由於ringbuff就是一個位元組陣列,所以在其中定位一個kv必然需要一個偏移量。 hash16uint16 // 我們知道slot中可能並非只有一個entry,這是由於我們的slot是有限的。 // 當key多到一定程度時,hash之後必然會出現衝突,不同的key定位到同一個slot, // 因此就必然會存放多個entry。這些entry是按這裡的hash16的大小排序存放的, // 所以查詢時也是O(1)的。 keyLenuint16 // 該entry中存放的kv記錄的key的長度,主要用於比較是否是要查詢的key。 reserved uint32 // 對齊欄位 }
所以,計算真實耗用記憶體的時候,每條kv都要加上24B的頭部消耗。
-
上面簡單說了,定位一個key,要先找到segment,再找到slot,再找到entry,才能找到具體的kv記錄,在實現中由於使用了陣列來存放所有slot的entry,所以這裡的查詢並非顯而易見。
-
從slotsData中查詢某個slot所對應的區間:
// 每個slot在slotsData中的偏移量 slotOff := int32(slotId) * seg.slotCap // 偏移量+該slot所存放的entry個數,即為當前狀態下該slot所對應的區間 slot := seg.slotsData[slotOff : slotOff+seg.slotLens[slotId] : slotOff+seg.slotCap]
-
從該區間中查詢某個key所對應的entry:
// 可以看到,這就是一個簡單二分查詢,直接使用key的hash16的值作為對比欄位(插入的時候似乎並未排序??) high := len(slot) for idx < high { mid := (idx + high) >> 1 oldEntry := &slot[mid] if oldEntry.hash16 < hash16 { idx = mid + 1 } else { high = mid } } return
-
如果找到了對應的entry —— 說明之前曾經設定過同樣的key,檢查key是否都是一樣的,這裡可能會有一個小疑問,因為查詢的hash16已經是通過hash(key)得到的值,按理找到idx即意味著key是一樣的了,但稍微一想就知道hash是不可能保證不同的key得到不同的值,更何況這裡還只是截取了高16位,所以從位元組的層次上檢查key是否相同:
// ringbuff的按位元組判斷 —— 略 match = int(ptr.keyLen) == len(key) && seg.rb.EqualAt(key, ptr.offset+ENTRY_HDR_SIZE)
-
如果找到了對應的key,那麼:
// 從ringbuff中,該kv記錄所在的偏移處讀取,先讀頭部 seg.rb.ReadAt(hdrBuf[:], matchedPtr.offset) // 更新頭部欄位 …… // 如果新的value不大於之前老的value長度,那麼會在原來的偏移處寫入新的值 if hdr.valCap >= hdr.valLen { …… } // 否則(新的value更長)刪除該entry // 刪除的時候只是將位於ringbuff中的kv記錄的頭部欄位deleted設定為true // (這裡可能存在一些重複計算 —— lookup和lookupByOff會有2次針對slotIdx的二分查詢,感覺應該是可以優化的) seg.delEntryPtr(slotId, hash16, slot[idx].offset) // 並擴充kv中value的容量為原來容量的2倍但要確保,kv之和不能超過maxKeyValLen // 假設,將freecache設定為1G,那麼maxKeyValLen = ((1G/256)/4) - 24 ……
-
如果沒有找到對應的entry或者對比後key不一致,更新為新key的頭部描述資訊
-
找到一塊合適的記憶體供新的entry使用:
// 在插入之前,先檢查一遍ringbuff,是否有需要淘汰的:seg.vacuumLen < entryLen,成立則進入淘汰策略 // 1. 優先清理已經標註為刪除的:if oldHdr.deleted { // 2. 再清理過期的:expired := oldHdr.expireAt != 0 && oldHdr.expireAt < now, // 所以可以看到,這裡的過期是一種懶清理策略——寫時清理 // 3. 再根據LRU策略選擇需要淘汰的entry,這個LRU的計算有點沒看明白,但明確的是,越早訪問的肯定越先被淘汰, // 關鍵就是早到什麼時候就會被淘汰 // 4. 如果entry的淘汰演算法無法得到一個合適的大小,那麼就需要淘汰ringbuff中的資料,跟entry的淘汰的區別是, // 此處並未參考訪問時間這一因素,僅僅是越早寫入的越先淘汰 // 5. 直至空閒出滿足需求的空間 // 這一段邏輯比較複雜,尚未完全明白,待完善 slotModified := seg.evacuate(entryLen, slotId, now)
-
寫入新的entry,有一個值得注意的點是,寫入都是發生在ringbuff的最後,順序寫入的,並不會產生空的“氣泡”:
// 緊跟著ringbuff的最後 newOff := seg.rb.End() // 插入上面說過的kv的描述結構:entry seg.insertEntryPtr(slotId, hash16, newOff, idx, hdr.keyLen) // 寫入具體的kv資料到ringbuff seg.rb.Write(hdrBuf[:]) …… // 更新ringbuff中可用的空閒長度 seg.vacuumLen -= entryLen
-
get
一般步驟
-
查詢的方式跟set一致,不再贅述。
-
如果該key已經存在了,檢查是否超時,如果超時就將其刪除(打標記),無資料返回,否則正常返回。
-
更新訪問時間
del
一般步驟
比較簡單,查詢並刪除,不再贅述。
原理
上文也說過,此處並非真正的釋放記憶體,可以說整個執行期freecache都沒有主動釋放記憶體的行為,這裡的刪除也只是將key對應的entry的頭部標記欄位deleted設定為true,這樣後續如果再需要插入資料時可以直接使用這塊記憶體。
這裡有一個值得注意的地方是,在segment.del中通過lookup()找到了slot中entry對應的索引idx,然後呼叫seg.delEntryPtr發起刪除(標記)行為,此時用同樣的slotId和hash16,通過lookupByOff再次查詢了entry對應的索引idx,我一直認為這兩次的值都是一樣的,不知道作者為何會再次檢查一遍。我的理解是:當key一樣,則slotId、hash16、slotsData都不會變,所以key所在的slot區間是一樣的,也就是說對應的entry的idx是一樣的,因此,entry必然也是一樣的,且lookupByOff的引數offset是通過lookup查詢到的entry的offset設定的,這兩者還有必要再對比一次嗎?
所以,我給作者提了一個 issue ,然後他回覆確實是多餘的,也做了一次 commit 。當然,錯了不在我,反正是他改的,哈哈。