Golang 中使用 Slice + 索引 Map 替代 Map 獲得性能提升

起因

在我們的多個線上遊戲項目中,很多模塊和服務爲了提高響應速度,都在內存中存放了大量的(緩存)數據以便獲得最快的訪問速度。

通常情況下,爲了使用方便,使用了 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。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章