缘起
最近 Go 1.15 发布了,我也第一时间更新了这个版本,毕竟对 Go 的稳定性还是有一些信心的,于是直接在公司上了生产。
结果,上线几分钟,就出现了 OOM,于是 pprof 了一下 heap,然后赶紧回滚,发现某块本应该在一次请求结束时被释放的内存,被保留了下来而且一直在增长,如图(图中的 linkBufferNode):
这次上线的变更只有 Go 版本的升级,没有任何其它变动,于是在本地开始测试,发现在本地也能百分百复现。
排查过程
看了 Go 1.15 的 Release Note,发现有俩高度疑似的东西:
- 去除了一些 GC Data,使得 binary size 减少了 5%;
- 新的内存分配算法。
于是改 runtime,关闭新的内存分配算法,切换回旧的,等等一顿操作猛如虎下来,发现问题还是没解决,现象仍然存在。
于是实在不行,祭出了GODEBUG="allocfreetrace=1
大法,肉眼从100MB+的日志文件里面看啊看啊看啊看啊看啊看啊看啊看啊看啊看啊……(此处省略心酸过程)
最终直觉告诉我,这个问题可能和 Go 1.15 中 sync.Map 的改动有关(别问我为啥,真的是直觉,我也说不出来)。
示例代码
为了方便讲解,我写了一个最小可复现的代码,如下:
1 | package main |
Go 1.15 中 sync.Map 改动
在 Go 1.15 中,sync.Map 增加了一个方法LoadAndDelete
,具体的 issue 在这:sync: add new Map method LoadAndDelete,CL 在这:CL。
为什么我确认是这个改动导致的呢?很简单:我在本地把这个改动 revert 掉了,问题就没了,好了关机下班……
当然没这么简单,知其然要知其所以然,于是开始看到底改了哪块……(此处省略100000字)
最终发现,关键代码是这段:
1 | // LoadAndDelete deletes the value for a key, returning the previous value if any. |
在这段代码中,会发现在 Delete 的时候,并没有真正删除掉 key,而是从 key 中取出了 entry,然后把 entry 设为 nil……
所以,在我们场景中,我们把一个连接作为 key 放了进去,于是和这个连接相关的比如 buffer 的内存就永远无法释放了……
那么为什么在 Go 1.14 中没有问题呢?以下是 Go 1.14 的代码:
1 | // Delete deletes the value for a key. |
在 Go 1.14 中,如果 key 在 dirty 中,是会被删除的;而凑巧,我们其实“误用”了 sync.Map,在我们的使用过程中没有读操作,导致所有的 key 其实都在 dirty 里面,所以当调用 Delete 的时候是会被真正删除的。
要注意,无论哪个版本的 Go,一旦 key 升级到了 read 中,在没有 miss 到一定的值让 dirty 提升为 read 时,key 都是永远不会被删除的。也就是说,极端情况之下,key 是会泄露的。
总结
在 Go <= 1.15 版本中,sync.Map 中的 key 在极端情况下是不会被删除的,如果在 Key 中放了一个大的对象,或者关联有内存,就会导致内存泄漏。
针对这个问题,我已经向 Go 官方提出了Issue,目前来看这个 behaviour 定义为了 bug(因为违背了 Go 1 兼容性承诺,和 1.14 中的 behaviour 不同了),已经由 @ChangKun Ou 大佬提了 pr 修复了,并且 backport 到了 1.15.1 中。
而针对 read 中的 key 在没有 dirty 被提升时不会删除的问题,目前看来是一个设计上的 trade-off,如果有真实世界中的程序(real-world program)出问题的话,再提 issue,看看是否要解决。