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

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

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

[[349821]]

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

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

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

 

自己画的:横轴是时间,纵轴是机器占用内存

 

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

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

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

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

责任编辑:未丽燕 来源: 知乎专栏
相关推荐

2020-08-27 21:36:50

JVM内存泄漏

2022-02-08 17:17:27

内存泄漏排查

2019-02-20 09:29:44

Java内存邮件

2023-01-04 18:32:31

线上服务代码

2018-09-14 10:48:45

Java内存泄漏

2021-08-19 09:50:53

Java内存泄漏

2018-07-20 08:44:21

Redis内存排查

2011-06-16 09:28:02

C++内存泄漏

2017-01-23 12:40:45

设计演讲报表数据

2021-11-02 07:54:41

内存.NET 系统

2022-09-13 17:46:19

STA模式内存

2021-02-11 14:06:38

Linux内核内存

2021-05-13 08:51:20

GC问题排查

2019-03-15 16:20:45

MySQL死锁排查命令

2011-06-30 22:23:21

打印机常见问题

2023-04-06 07:53:56

Redis连接问题K8s

2014-11-12 13:22:34

2021-11-08 12:44:48

AndroidC++内存

2019-09-29 00:25:11

CC++内存泄漏

2022-11-03 16:10:29

groovyfullGC
点赞
收藏

51CTO技术栈公众号