17

一次C++伪“内存泄漏”的排查之旅

 3 years ago
source link: https://zhuanlan.zhihu.com/p/270913476
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

​前段时间做一个需求,需要用到一个本地词典文件。该词典原始文件超过2G,在服务启动的时候加载到内存中,并且 保持词典数据的热加载 ,也就是不停服更新词典数据到服务进程的内存中。

之前有同事在其他项目中有热更新词典的代码,我就直接拿来用了。这是典型的双Buffer词典。也就是程序运行期间,内存中会同时维持两份词典:一份前台词典供运行时各处理逻辑检索,另一份是后台词典,在检测到目标文件修改时(通过检查文件 mtime 判断的是否更新)。在词典数据更新时,重新解析加载,最新的数据储存到后台词典中。最后两个词典做0 - 1 切换,也就是前台词典变后台词典,后台词典变前台词典。

词典类在服务中采用的核心数据结构是 unordered_map 。前后台词典也就是会存在两个unordered_map。key是某某ID,value是词典原始文件逐行解析后重组出来的 protobuf Message 对象。

在线下环境(非线上生产环境)测试的时候,自测完代码逻辑无问题。喵了一眼机器基础指标,发现内存会多次上涨。

7nIfQb6.png!mobile 自己画的:横轴是时间,纵轴是机器占用内存

内存占用在 5-10G之间那次是第一次启动完成的时间,后面又连续涨了两次。怀疑是有内存泄露,在把流量停掉以后,重启服务。观测到内存仍旧会规律上涨,且 一个小时会涨一次 。如此规律,让人不得不怀疑是词典更新导致。词典文件是ceph挂载的,会自动更新,所以我几乎没关注过。确认了一下词典的更新时间和更新频率。确实也是一小时更新一次,且其每次更新的时间和内存每次上涨时间相match。

想尽快验证一下是否真的是词典更新导致的内存上涨,等着词典一次一次例行更新就太慢了。不过由于这个词典API判断词典是否更新是检测的文件修改时间(mtime),所以通过 touch 该词典文件,可以提前触发词典的加载。

按理说双buffer的词典,在正常启动后暴涨一次内存是合理的 。因为启动的时候内存中加载了词典的一个版本。一个小时之后词典更新,第二个版本的词典数据也会加入到内存。而彼时原先的前台词典虽然变成了后台词典,但是内存并不会立即delete(持有旧词典数据的unordered_map)。因为可能运行的请求处理逻辑仍然会用到旧词典。

重新阅读这个词典API的实现。当内存中存在两个版本的词典后,等到词典第二次更新到时候(也就是第三个版本词典出现的时候),该实现逻辑是先创建一个词典对象存储第三个版本词典的数据。若其加载解析成功则原先的后台词典对象就会被delete( 第一个版本的词典占用的内存被释放 )。然后后台词典的指针指向刚新建的对象( 第三个版本的词典正式成为后台词典 ),最后做前后台词典的切换( 第三个版本词典成为前台词典,第二个版本的词典变成后台词典 )。

也就是说按照这个词典API的实现逻辑,内存中确实存在某个时刻存储着三份词典的数据,涨两次内存也说得通,但是当新的词典加载完成,上上个版本的词典对象是会被delete的。所以内存应该回落才对!难道是delete没有被触发吗?

尝试了touch了几次词典文件发现,确实词典文件更新会导致内存连续上涨。但诡异的是后来我尝试缩减词典到一个特别小的大小,却观察到机器内存并不会下降!哦?这是词典API本身存在内存泄露的风险吗?和刚才看代码时的疑惑一样,上上版本的词典没有触发delete?然而通过多次测试又发现这样一个事实:

词典内存不会永远上涨,启动完成之后,最多涨两次,第三次也会涨但比较少,第四次五次更新词典文件,则几乎不会导致内存的变化!如果说存在词典对象没有被正常delete,那么内存占用应该会继续上涨,而不是趋于稳定。

头疼。 一方面内存不会无限上涨,不像是内存泄露;但另一方面词典缩小却不会导致内存占用减少。

这……让我在十月的深夜凌乱了。问题又兜回来了吗?这到底是不是内存泄露?或者到底是不是词典更新导致的呢?

尝试了用一些工具来辅助定位是否有内存泄露的风险,但一无所获。后来注释掉了每行词典数据重组成pb对象之后insert进unordered_map的代码,经测试词典更新确实不会再导致内存上涨。说白了实锤了内存上涨就是这两个前后台的unordered_map引起的。然而通过加日志也能证实每次旧map对象的delete每次都有被调用到,也就是不存在第三个map对象没被delete的情况,那么为什么delete掉对象后,其占用的内存无法释放呢?

遽然陷入绝境,坐困愁城。

突然我灵光一现:会不会是 glibc 导致的持呢?我们都知道内存分配器,比如glibc的ptmalloc,有时候内存分配器的内存管理策略并不一定如我们所愿。

紧接着我以『glibc delete对象内存不释放』为开端经过一系列搜索,终于发现其他人也遇到过类似的问题:

一次"内存泄漏"引发的血案 www.jianshu.com nyAvmi7.jpg!mobile

经证实确实glibc有这样的内存分配策略:为了避免大对象频繁的内存分配和释放,glibc并不一定会把delete的对象内存立即归还给操作系统,有时候可能继续让进程持有该内存。当后续再有大对象需要分配的时候,可以直接使用,而不再需要再去向操作系统申请内存。glibc这个策略其实是为了提高内存分配效率的,并且也不会无限占用内存,而是在达到某个平衡点之后内存便不再增长,这也和我所观察到的现象一致。

说到底这其实不算是一次『内存泄露』。然而这个现象既然不会持续占用内存,那么到底需不需要解决呢?在我的场景下,答案是肯定的。因为我们的词典比较大,且不可控,当线上正常服务的时候,内存也会正常上涨,其实是存在OOM风险的。在运行效率和服务稳定性之间相比较,自然要让步于稳定性。

那么怎么解决呢?虽然没有直接搜索到答案,但是直觉告诉我一个更好的内存分配器或许可以解决。死马当活马医,于是我尝试了让程序链接tcmalloc或jemalloc。最终 jemalloc 表现良好,可以慢慢释放掉多余占用的内存。

FJzyAve.png!mobile

那些凸起的线是加载和解析词表的过程中,突然飙上来的内存,但随机又很快回落,接着慢慢继续回落。其实jemalloc在针对大对象存储时,其性能表现也并不差,甚至使用了jemalloc之后服务一次请求响应的耗时还有不少缩减。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK