在咱们的多个线上游戏项目中,不少模块和服务为了提升响应速度,都在内存中存放了大量的(缓存)数据以便得到最快的访问速度。git
一般状况下,为了使用方便,使用了 go 自身的 map 做为存放容器。当有超过几十万 key 值,而且 map 的 value 是一个复杂的 struct
时,额外引入的 GC 开销是没法忽视的。在 cpu 使用统计图中,咱们老是观测到较为规律的短期峰值。这个峰值在使用 1.3 版本的 go 中显得特别突出(stop the world
问题)。后续版本 go gc 不断优化,到咱们如今使用的 1.10 已是很是快速的并发 gc 而且只会有很短暂的 stw
。github
不过在各类 profile 的图中,咱们依然观察到了大量的 runtime.scanobject
开销!golang
在一个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 的便利性。code
这个项目放到了 github 上: legougames/slice_map
在自带的性能测试中,额外收获了几点:
FastIter
,遍历的速度快1个数量级(并且仍是稳定的)。Iter
,那么能够在遍历的过程当中删除 key。