当前位置: 首页 > 科技观察

一趟C++伪“内存泄露”调查之旅

时间:2023-03-14 14:13:10 科技观察

前段时间做了个需求,需要用到本地的字典文件。字典原文件超过2G,在服务启动时加载到内存中,并保持字典数据的热加载,即不断更新字典数据到服务进程的内存中.之前有同事在其他项目有热更新字典的代码,就直接用了。这是一个典型的双Buffer字典。也就是说,在程序运行过程中,内存中会同时维护两个字典:一个是前台字典,用于运行时每次处理逻辑检索,另一个是后台字典。).当字典数据更新时,重新解析加载,将最新的数据存入后台字典。最后两个字典0-1切换,即前景字典变成背景字典,背景字典变成前景字典。服务中字典类使用的核心数据结构是unordered_map。前后字典中会有两个unordered_maps。key是某个ID,value是对原始字典文件逐行解析后重新组装的protobufMessage对象。在离线环境(非线上生产环境)测试时,自测后代码逻辑没有问题。看了一眼机器的基本指标,发现内存会增加很多倍。自己画的:横轴是时间,纵轴是机器占用的内存。5-10G之间占用的内存是第一次启动完成的时候,之后连续增加了两次。怀疑是内存泄漏。停止流量后,重新启动服务。观察到内存还是有规律的上升,每小时上升一次。这样的规律性让人不得不怀疑是字典更新导致的。字典文件是ceph挂载的,会自动更新,所以我几乎没注意。确认词典的更新时间和更新频率。确实是每小时更新一次,每次更新的时间和每次内存增加的时间相匹配。想尽快验证一下,是不是字典更新导致的内存增加真的太慢了??,等不及例行更新字典。但是,因为词典API判断词典是否更新是检测到的文件修改时间(mtime),所以通过触摸词典文件,可以提前触发词典的加载。按理说,对于双缓冲的字典,正常启动后增加一次内存是合理的。因为在启动时会在内存中加载一个版本的字典。一个小时后,字典更新,第二版字典数据被添加到内存中。那时,虽然原来的前景字典变成了背景字典,但内存并不会立即被删除(unordered_map持有旧字典数据)。因为可能运行的请求处理逻辑仍然会使用旧的字典。重新阅读这个字典API的实现。当内存中有两个版本的字典时,当字典进行第二次更新时(即出现第三个版本的字典时),实现逻辑是先创建一个字典对象来存放数据第三版词典。如果加载解析成功,则删除原来的后台字典对象(释放第一版字典占用的内存)。然后后台字典的指针指向新创建的对象(第三版字典正式成为后台字典),最后在前后字典之间切换(第三版字典成为前台字典,和第二版词典成为背景词典)。也就是说,按照这个字典API的实现逻辑,某一时刻内存中确实存储了三个字典数据,增加内存两倍是有道理的,但是当加载新字典时,之前的字典对象的版本将被删除。所以记忆应该回落!难道是没有触发删除?试了几次touch字典文件,发现确实是字典文件的更新会导致内存不断上升。但奇怪的是,我后来尝试将字典缩小到很小的体积,却观察到机器内存并没有下降!哦?这是字典API本身存在内存泄漏的风险吗?和刚才看代码时的困惑是一样的。之前版本的词典没有触发删除?但是通过多次测试发现,字典的内存不会一直增加。启动完成后,最多增加两次,第三次也会增加但较少,第四次增加五倍。更新字典文件几乎不会引起内存变化!如果有字典对象没有被正常删除,那么内存占用应该会继续上升,而不是稳定下来。头痛。一方面,内存不会无限增加,不像内存泄漏;但另一方面,缩小字典不会导致内存使用量减少。这...让我在10月下旬的夜晚变得一团糟。问题回来了吗?这是内存泄漏吗?还是字典更新导致的?我尝试使用一些工具来帮助定位内存泄漏的风险,但一无所获。后来把每行字典数据重组为pb对象后插入unordered_map的代码注释掉了。经测试,字典的更新并没有导致内存再次上升。说白了,内存的增加是前后两个unordered_map造成的。但是通过添加日志也可以确认每次调用的都是旧map对象的delete,也就是不存在第三个map对象没有被删除的情况,那么为什么不能查到对象占用的内存呢删除对象后释放什么?顿时陷入绝境,坐拥愁城。突然灵光一闪:会不会是glibc引起的?我们都知道内存分配器,比如glibc的ptmalloc,有时候内存分配器的内存管理策略不一定是我们想要的。已经证实glibc有这样的内存分配策略:为了避免大对象的频繁内存分配和释放,glibc不一定会立即将删除的对象内存归还给操作系统,有时可能会继续让进程持有记忆。后面需要分配大对象时,可以直接使用,不需要向操作系统申请内存。glibc的策略其实是为了提高内存分配的效率,不会无限占用内存,但是内存达到某个平衡点后就不会再增长了,这也和我观察到的一致。毕竟,这实际上并不是“内存泄漏”。但是,既然这种现象不会继续占用内存,那还需要解决吗?在我的场景中,答案是肯定的。因为我们的词典比较大,不可控,在线服务正常的情况下,内存也会正常增加。其实是有OOM风险的。在比较运行效率和服务稳定性时,自然要让位于稳定性。那么如何解决呢?虽然我没有直接找到答案,但我的直觉告诉我,一个更好的内存分配器可能会解决它。死马当活马医,于是尝试让程序链接tcmalloc或者jemalloc。最终jemalloc表现不错,可以慢慢释放多余的内存。那些凸起的线条,就是在加载和解析词汇的过程中,突然涌上来的记忆,又随机的快速回落,然后又慢慢的继续回落。其实jemalloc用于存储大对象时,性能还不错。即使在使用jemalloc之后,服务请求响应的耗时也大大减少了。