0%

c++服务内存持续增长问题的解决

问题

线上有个用C++写的b服务,随着运行时间的增减,占用内存也会不断增加,最终kernel会触发OOM,导致服务不可用。

所使用的物理机内存为160GB,以上图中一个broker进程就占了大约6.6GB的内存,运行了10天后内存还在持续增长,存在内存泄漏的问题。

排查过程

代码review

主要以下几个方面:

  • malloc/free、new/delete、new []/delete []是否都配套使用了
  • 构造函数里申请的内存,在析构函数里都有相应的释放
  • 指针的错误使用,没有释放内存。

但没有发现有问题的地方。

内存泄漏检查工具

valgrind

valgrind介绍

Valgrind是一个开源工具包,提供了许多调试和分析工具。

  • Memcheck detects memory-management problems
  • Cachegrind a cache profiler
  • Callgrind Cachegrind + callgraphs(调用关系图)
  • Massif a heap profiler
  • Helgrind a thread debugger which finds data races in multithreaded programs
  • ……

代码编译

set(CMAKE_CXX_FLAGS “${CMAKE_CXX_FLAGS} -std=c++11 -g -O0 -mavx2 -Wall -DDEBUG_RING”)

  • -g include debugging information so that Memcheck’s error messages include exact line numbers
  • -O0 禁止编译器的优化,确保valgrind的输出信息的准确性

Memcheck的使用

valgrind --leak-check=yes --track-origins=yes /root/ufile/XsqUFileBroker-set7/UFileBroker -c /root/ufile/XsqUFileBroker-set7/config-set7.ini

线上不建议开启,能使性能下降10~30倍,线上实测下来最多有下降200倍。

在正常压测下,并不会出现内存泄漏的错误信息。但当把broker依赖的底层服务osd重启后,立刻出现以下的错误信息。

错误信息1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
==31907== 773,314 (9,216 direct, 764,098 indirect) bytes in 288 blocks are definitely lost in loss record 1,997 of 2,023
==31907== at 0x4C2A5B3: operator new(unsigned long) (vg_replace_malloc.c:342)
==31907== by 0x63FD3B: uevent::ConnectionLibevent::Init() (connection_libevent.cc:67)
==31907== by 0x63AECA: uevent::ConnectorLibevent::Connect() (connector_libevent.cc:30)
==31907== by 0x5FBAAE: BrokerServerHandle::GetConnection(std::shared_ptr<uevent::ConnectionUevent> const&, std::string const&, unsigned int) (broker_server.cc:587)
==31907== by 0x600644: BrokerManagerHandle::GetConnection(std::shared_ptr<uevent::ConnectionUevent> const&, std::string const&, unsigned int) (broker_manager.cc:87)
==31907== by 0x5E0194: BrokerGetHandle::GetECOnePieceData(int) (broker_get.cc:312)
==31907== by 0x5E1EBF: BrokerGetHandle::GetECObjectRequest() (broker_get.cc:371)
==31907== by 0x5E3913: BrokerGetHandle::EntryInit(std::shared_ptr<uevent::ConnectionUevent> const&, std::shared_ptr<MessageHeader> const&) (broker_get.cc:110)
==31907== by 0x5F7AC1: BrokerServerHandle::MessageDispatchHandle(std::shared_ptr<uevent::ConnectionUevent> const&, std::shared_ptr<MessageHeader> const&) (broker_server.cc:173)
==31907== by 0x5FAAA3: BrokerServerHandle::MessageReadHandle(std::shared_ptr<uevent::ConnectionUevent> const&) (broker_server.cc:108)
==31907== by 0x5CE779: std::_Function_handler<void (std::shared_ptr<uevent::ConnectionUevent> const&), void (*)(std::shared_ptr<uevent::ConnectionUevent> const&)>::_M_invoke(std::_Any_data const&, std::shared_ptr<uevent::ConnectionUevent> const&) (functional:2071)
==31907== by 0x640D94: operator() (functional:2471)
==31907== by 0x640D94: uevent::ConnectionLibevent::HandleReadEvent(int, std::shared_ptr<uevent::ConnectionUevent> const&) (connection_libevent.cc:114)

问题:某个osd进程关闭后,broker对保存长连接而占用的内存,没有释放。
修复方法:调用delete释放。

注意:
这里的connectors类型是std::unordered_map<ConnectorKey, uevent::ConnectorUevent*, ConnectorKeyHash> connectors_
obj_handle→connectors_.erase(it)只会删除指针本身,并不会销毁指针指向的内容,所以导致内存泄漏。
参考https://stackoverflow.com/questions/39605945/remove-element-from-unordered-map-without-calling-destructor-on-it

错误信息2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
==31907== 4,405,094 (507,936 direct, 3,897,158 indirect) bytes in 1,716 blocks are definitely lost in loss record 2,018 of 2,023
==31907== at 0x4C2A5B3: operator new(unsigned long) (vg_replace_malloc.c:342)
==31907== by 0x5FB78C: BrokerServerHandle::GetConnection(std::shared_ptr<uevent::ConnectionUevent> const&, std::string const&, unsigned int) (broker_server.cc:578)
==31907== by 0x600644: BrokerManagerHandle::GetConnection(std::shared_ptr<uevent::ConnectionUevent> const&, std::string const&, unsigned int) (broker_manager.cc:87)
==31907== by 0x5E0194: BrokerGetHandle::GetECOnePieceData(int) (broker_get.cc:312)
==31907== by 0x5E1EBF: BrokerGetHandle::GetECObjectRequest() (broker_get.cc:371)
==31907== by 0x5E3913: BrokerGetHandle::EntryInit(std::shared_ptr<uevent::ConnectionUevent> const&, std::shared_ptr<MessageHeader> const&) (broker_get.cc:110)
==31907== by 0x5F7AC1: BrokerServerHandle::MessageDispatchHandle(std::shared_ptr<uevent::ConnectionUevent> const&, std::shared_ptr<MessageHeader> const&) (broker_server.cc:173)
==31907== by 0x5FAAA3: BrokerServerHandle::MessageReadHandle(std::shared_ptr<uevent::ConnectionUevent> const&) (broker_server.cc:108)
==31907== by 0x5CE779: std::_Function_handler<void (std::shared_ptr<uevent::ConnectionUevent> const&), void (*)(std::shared_ptr<uevent::ConnectionUevent> const&)>::_M_invoke(std::_Any_data const&, std::shared_ptr<uevent::ConnectionUevent> const&) (functional:2071)
==31907== by 0x640D94: operator() (functional:2471)
==31907== by 0x640D94: uevent::ConnectionLibevent::HandleReadEvent(int, std::shared_ptr<uevent::ConnectionUevent> const&) (connection_libevent.cc:114)
==31907== by 0x4E47A13: event_base_loop (in /usr/lib64/libevent-2.0.so.5.1.9)
==31907== by 0x630C14: uevent::PosixWorker::Running(std::string const&) (posix_stack.cc:192)

问题:某个osd进程关闭后,但数据库里还没有标记成BAD。此时broker还会尝试连接该osd,因连接失败不会发送请求,这部分逻辑正确。但是,失败的连接占据的内存没有释放。
修复方法:调用delete释放。

修复以上2个错误后,用valgrind已经没有其他错误信息了。但是线上broker的内存,在osd没有重启的情况下,依然还会不断增长。这说明还有其他内存泄漏的地方。

Dump内存并查找问题

观察线上broker服务内存,在上次版本更新一周后,占用内存分布大致在1~5GB之间。
查看broker的虚拟内存分布,有80%的内存都是同一块内存空间,且是不同broker的共同现象。

自己通过压测环境,也能查到类似现象。
压测环境:6块盘;线上环境:72块盘。

dump内存

所以这块内存是什么呢?可以dump下来查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
gdb attach 3096

(gdb) dump memory /tmp/3096_broker.dump 0x7fdf33400000 0x7fdf33410000

strings /tmp/3096_broker.dump
ZZZZ7_0:4096_broker-32576-25146--7-64115
ZZZ$
ZZZZ7_0:4096_broker-32576-25146--7-64280
ZZZ$
ZZZZ7_0:4096_broker-32576-25146--7-65085
ZZZZ7_0:4096_broker-32576-25146--7-64116
7_0:4096_broker-32576-25146--7-64355
7_0:4096_broker-32576-25146--7-64605
7_0:4096_broker-32576-25146--7-64117
ZZZZ7_0:4096_broker-32576-25146--7-64227
ZZZZ7_0:4096_broker-32576-25146--7-64235
ZZZZ7_0:4096_broker-32576-25146--7-64118

因为压测时上传的数据都是ZZZZ…格式的,可以判断跟上传文件的内容有关。

valgrind massif的使用

再用valgrind massif的工具去剖析heap的占用情况。

1
2
valgrind --tool=massif /root/ufile/XsqUFileBroker-set7/UFileBroker -c /root/ufile/XsqUFileBroker-set7/config-set7.ini
ms_print massif.out.907

发现有lru_cache的部分占用,但为了分析内存占用,lru_cache已经禁止使用了,但为什么还会有呢?

lru_cache的问题

再去查看跟lru_cache相关的代码,
7_0:4096_broker-32576-25146–7-64605的格式正好是定义的BROKER_CACHE_KEY的格式,

1
2
#define BROKER_CACHE_KEY \
to_string(SET_ID) + "_" + to_string(offset_) + ":" + to_string(data_size_) + "_" + objectid_

并进一步分析lru_cache相关的代码,最后发现又是unordered_map的错误使用。
每次get数据的时候,代码里都会判断下在lru_cache里是否已经存在;如果不存在,则插入该数据。

1
2
3
4
5
6
7
8
9
10
11
12
lru_cache.h

private:
std::unordered_map<K, Node<K, T> *> umap;

void Put(K key, T data) {
......
Node<K, T> *node = umap[key]; //放入cache前,都会先判断下是否已经存在
if (node){
......
}
}

但是std::unordered_map<>::[key]在查不到时,会自动插入该数据。

Returns a reference to the value that is mapped to a key equivalent to key, performing an insertion if such key does not already exist.

到这里就真相大白了,罪魁祸首就是std::unordered_map<>::[key]的使用不当,每次get一个没有在cache里的key时,就会插入一条数据,导致内存不断增大。

TCP Connection的buffer问题

再压测时,pmap看时,还是有一块几个GB的内存空间,dump出来也看不出来是什么。

Buffer 问题

那么到底是哪里占用的呢?
想到golang里面的heap profile,搜到C++里也能用类似的工具gperftools。
具体使用过程参考c++ profile的大杀器-gperftools的使用

发现压测后占用3GB的内存中,95%都是每条TCP连接的buffer。

网络框架里有一个buffe模块r,维护tcp conncetion占用的Buffer。
Buffer底层由std::vector实现,是一块连续的内存;可以自适应增长;保存数据并提供相应的访问函数。
上层代码,只要对Buffer发送数据或者接收数据,而不用管理具体的存放和容量等。

然后查看Buffer的代码,发现retrive(回收内存的函数)的实现逻辑有问题。

修改后再次压测,对比结果如下:
6磁盘的测试环境,跑压测脚本,证明修改的逻辑有效。

改进前 改进后
retrieve后,>=8Mb的buffer出现的次数 7219 449

72磁盘的大数据测试环境,连续压测2h:

改进前 改进后
占用内存RSS 4.5GB 3.0GB

Broker服务的内存模型

进一步验证:既然是tcp conncetion占用的Buffer,那么把连接断开后,是否会释放buffer呢?

查看跟brorker服务的连接分为两类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Broker服务的内存模型
进一步验证:既然是tcp conncetion占用的Buffer,那么把连接断开后,是否会释放buffer呢?

查看跟brorker服务的连接分为两类:

// 总共的连接数
[root@bj-ufile-storage-set-osd ~]# netstat -antp | grep 8219 | wc -l
986

// broker --> osd的连接
[root@bj-ufile-storage-set-osd ~]# netstat -antp | grep 8219 | grep :200 | wc -l
711
[root@bj-ufile-storage-set-osd ~]# netstat -antp | grep 8219 | grep 172.22.18.9:20011 | wc -l
10
[root@bj-ufile-storage-set-osd ~]# netstat -antp | grep 8219 | grep 172.22.18.9:20011
tcp 0 0 172.22.18.9:39996 172.22.18.9:20011 ESTABLISHED 8219/ufile-broker-s
tcp 0 0 172.22.18.9:37220 172.22.18.9:20011 ESTABLISHED 8219/ufile-broker-s
tcp 0 0 172.22.18.9:11742 172.22.18.9:20011 ESTABLISHED 8219/ufile-broker-s
tcp 0 0 172.22.18.9:30720 172.22.18.9:20011 ESTABLISHED 8219/ufile-broker-s
tcp 0 0 172.22.18.9:58094 172.22.18.9:20011 ESTABLISHED 8219/ufile-broker-s
tcp 0 0 172.22.18.9:10776 172.22.18.9:20011 ESTABLISHED 8219/ufile-broker-s
tcp 0 0 172.22.18.9:45502 172.22.18.9:20011 ESTABLISHED 8219/ufile-broker-s
tcp 0 0 172.22.18.9:40626 172.22.18.9:20011 ESTABLISHED 8219/ufile-broker-s
tcp 0 0 172.22.18.9:11806 172.22.18.9:20011 ESTABLISHED 8219/ufile-broker-s
tcp 0 0 172.22.18.9:37098 172.22.18.9:20011 ESTABLISHED 8219/ufile-broker-s

// goproxy --> broker的连接
[root@bj-ufile-storage-set-osd ~]# netstat -antp | grep 8219 | egrep "172.22.14.33|172.22.14.35|172.22.14.32|172.22.14.34|172.22.14.36|172.22.14.37|172.22.18.23|172.22.18.18" | wc -l
271

重启大数据set的osd和goproxy后,内存释放。

所以衡量broker服务的占用内存可以用公式:

broker_rss = 3MB * (osd连接数+goproxy连接数) ≈ 4MB * osd连接数

再经过反复压测,内存不再增长,问题得到解决。

一般排查步骤的总结

命令

lsof 列举出正在使用的文件

lsof,这个工具用于排查是否存在很在很多超出预料的文件的情况,比如打开某文件未关闭,建立很多的socket连接等等。当然,发现问题只能靠眼力劲了。
lsof -p #查看进程打开的文件情况。

pmap 查看进程内存概要

pmap,用于查看进程的内存映像信息, 发现内存中大块的占用所在,以及分析内存可能存在的异常。
pmap {pid}
pmap -x {pid}
pmap -X {pid}

或者
cat /proc/{pid}/maps, 以及cat /proc/{pid}/smaps,都可以查看内存段的具体起始位置

perf

可以先捕获数据,然后进行性能分析,然后得到可疑的点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
perf record -g -e cpu-clock -p 5545        # 记录进程 5545 的相关性能信息
perf report -i perf.data # 读取刚刚记录的数据,可以显示出种操作的占用情况,如下
Samples: 908 of event 'cpu-clock', Event count (approx.): 227000000
Children Self Command Shared Object Symbol
+ 32.27% 0.00% java libpthread-2.17.so [.] start_thread
+ 32.27% 0.00% java libjvm.so [.] java_start
+ 26.54% 0.00% java libjvm.so [.] ConcurrentG1RefineThread::run
+ 26.54% 0.11% java libjvm.so [.] ConcurrentG1RefineThread::run_young_rs_sampling
+ 25.77% 5.62% java libjvm.so [.] YoungList::rs_length_sampling_next
+ 22.58% 0.55% java [kernel.kallsyms] [k] tracesys
+ 11.01% 0.00% java perf-5545.map [.] 0x00007f553ec1e981
+ 10.79% 0.44% java libjvm.so [.] JVM_Sleep
+ 9.36% 0.55% java libjvm.so [.] G1CollectorPolicy::update_incremental_cset_info
+ 8.70% 0.55% java libjvm.so [.] os::sleep
+ 8.26% 0.00% java [unknown] [k] 0xee83b0ac00709650
+ 8.15% 0.99% java libpthread-2.17.so [.] pthread_cond_timedwait@@GLIBC_2.3.2
+ 7.93% 0.00% java perf-5545.map [.] 0x00007f553f7f7d30

gdb 调试工具dump出可疑内存

1
2
3
4
5
6
7
8
9
gdb attach <pid>                    # 先连接到进程中
dump memory /path/dump.bin 0x0011 0x0021 # dump 出内存段的信息,具体要 dump 的内存段地址,可以借助之前pmap 或 cat /proc/<pid>/smaps 或 cat /proc/<pid>/maps 中指示的地址段得出

更好的方式
gdb --batch --pid {PID} -ex "dump memory native_memory.dump 0x7f7588000000 0x7f7588001A40"

strings /path/dump.bin | less # 查看内存内容, 相信你能从中发现一些不一样的东西
strings -n 10 /path/dump.bin | less # 只查看>=10B字符串的行

以上这些命令,在不需要改变线上服务的二进制或服务启动方式下,就可以使用,比较方便。如果以上手段都没有效果,再用更复杂的工具。

工具

valgrind

以上已经介绍过,这里就不再说明了。
需要注意的是10~30倍的性能损耗,在线上一般无法接受使用。

gcc

gcc 命令行参数 -fsanitize=address -fno-omit-frame-pointer
新版本的gcc(gcc49)提供了很好的内存访问检查机制,实践中发现对性能的影响居然比Valgrind小很多。在实践中 Electric Fence 和 Valgrind 严重影响了程序的性能,难以触发内存访问问题,而gcc的-fsanitize=address编译参数解决了大问题,唯一的缺点是gcc高版本才支持,而实践中,生成环境的代码都是老版本编译器编译的。

这里有gcc版本问题的讨论。
https://github.com/Raymo111/i3lock-color/issues/79

bcc

可以输出内存火焰图.
具体参考
http://www.brendangregg.com/FlameGraphs/memoryflamegraphs.html
http://www.brendangregg.com/ebpf.html#bcc%22
对os有要求,centos仅限于7.6。

pprof / gperftools

https://github.com/gperftools/gperftools
对性能影响小,可以直接压测,但建议有问题时开启,不要一直运行。

参考链接

  1. Valgrind’s Tool Suite
  2. The Valgrind Quick Start Guide
  3. The Memory Layout of a 64-bit Linux Process
  4. 内存泄漏排查攻略之:Show me your Memory
  5. Linux 环境下多线程 C/C++ 程序的内存问题调试