起因
在我們的多個線上遊戲項目中,很多模塊和服務爲了提高響應速度,都在內存中存放了大量的(緩存)數據以便獲得最快的訪問速度。
通常情況下,爲了使用方便,使用了 go 自身的 map 作爲存放容器。當有超過幾十萬 key 值,並且 map 的 value 是一個複雜的 struct
時,額外引入的 GC 開銷是無法忽視的。在 cpu 使用統計圖中,我們總是觀測到較爲規律的短時間峯值。這個峯值在使用 1.3 版本的 go 中顯得特別突出(stop the world
問題)。後續版本 go gc 不斷優化,到我們現在使用的 1.10 已經是非常快速的併發 gc 並且只會有很短暫的 stw
。
不過在各種 profile 的圖中,我們依然觀察到了大量的 runtime.scanobject
開銷!
在一個14年開始的討論中,就以及發現了 大 map 帶來(特別是指針作爲 value 的 map)的 gc 開銷。遺憾的是在 2019 年的今天這個問題仍然存在。
在上述的討論帖子中,有一個 Contributor randall77 提到:
Hash tables will still have overflow pointers so they will still need to be scanned and there are no plans to fix that.
不明白他的 overflow pointers
指的什麼,但是看起來如果你有一個大的,指針作爲 value 的 map 時,gc 的 scanobject
耗時就不會少。
處理
所以我們項目裏面自己弄了一個名爲 slice_map
的東西來專門優化內存中巨大的 map。這個 map 的實現機制是基於一下幾個觀察到的現象:
-
map[int]*obj
gc 極慢 -
map[int]int
gc非常快 -
[]*obj
gc 也很快
於是我們使用一個 []interface
來存放數據,map[int]int
做一個 key -> slice
來映射 key 到存放數據的 slice 的下標的索引。
最初的版本,刪除 key 之後,留下的 slice 的空間資源,使用了一個 freelist 來維護管理,但這個方案的問題在於:一旦系統中爆發大量突發性的插入將 slice 撐大
,後面就再也沒有機會回收內存了。
所以後面的版本使用了 挪動代替刪除 的操作,將騰出的空間移動到末尾(一個 O(1) 的操作),再在合適的時機回收 slice 後面沒有使用的空間(Shrink
操作),可以防止內存的浪費。
這樣,既得到了 便宜 的 gc,又獲得了 map 的便利性。
這個項目放到了 github 上: legougames/slice_map
在自帶的性能測試中,額外收穫了幾點:
- 插入效率比原生 map 快了一倍。
- 如果使用
FastIter
,遍歷的速度快1個數量級(而且還是穩定的)。 - 如果使用普通的
Iter
,那麼可以在遍歷的過程中刪除 key。