Memory Leak Analyze 01
Last updated on 22 days ago
Memory Leak (内存泄漏)专题指南-01
本文着重分析常见memory leak的现象和原因,以及分享一些解决memory leak的思路和工具
何为内存泄漏
动态存储分配函数动态开辟的空间,在使用完毕后未释放,结果导致一直占据该内存单元
申请过多内存
首先内存只申请不释放未必就是内存泄漏,有可能是你的程序的确需要申请很多内存,这是正常的,然而如果是bug导致申请了很多内存,这就是内存泄漏了,或者也有人将其称为space leak,意思是申请的内存超过了正常所需;不管是有意无意,总之在这种情况下你依然保持对这些内存的引用,因此你总可以找到这些内存并删除它们,就看你删不删。
有很多情况会导致这一问题,像重复使用的某个结构体/对象,当再次复用时没有清理上一次使用遗留的数据、系统中存在cache,但cache的过期策略设置不得当等等。
内存无法删除
另一类比较有趣的内存泄漏是说你申请了一些内存,但最终却没有什么指向它们:
1 | |
在这段代码中我们申请了1k内存,然而当memory_leak函数返回后你就再也不知道这段内存到底在哪里了!对于编程来说就是粗心大意的程序员申请了一些内存后最终“忘掉”了,再也不会有什么东西(变量/指针)指向这些内存,因此在这种情况下你没有办法再找到这些内存并将其删除。
*内存碎片
这是一类特殊的内存泄漏,用停车场的例子来说就是两个停车位中间停靠了一辆小型老年代步车,导致尽管这两个停车位剩余的空间足够大但又恰好都没有办法再停靠一辆小汽车。因此看起来我们的程序占据的内存越来越多,尽管实际上程序可能并不需要那么多内存,仅仅是因为内存碎片的原因导致一部分内存无法被再次被利用起来。
然而对于现代操作系统尤其具备虚拟内存能力的系统来说,内存碎片问题通常可能并不会和我们想象的那样严重,原因就在于分配的内存只需要在虚拟地址空间上连续而不必在物理内存上也连续。
如果你的程序需要重复申请很多对象/数据/结构体,并在最后一次性全部释放,那么内存池是一个避免内存碎片不错的选择,原理在于尽管从内存池的角度看会有碎片,但当我们以内存池大小为单位从堆区中申请释放内存时,这种碎片将不复存在。
内存泄漏有什么问题
在现代操作系统中除非你的程序运行时间足够长或者申请的内存足够快足够多否则内存泄漏可能并不是什么大问题,你甚至可能都察觉不出来有内存泄漏,因为当进程运行结束后其占据的内存会被操作系统收回,在这种情况下你可能不必过于关心这个问题,但对于长时间运行的服务器端程序、数据库程序、操作系统等,内存泄漏就属于比较严重的问题了,因为这些程序必须时刻在线,任何微小的内存泄漏在时间的加持下都会非常明显。
内存持续泄漏会发生什么?
内存的申请速度会对系统性能产生很大的影响,当系统内存不足时,内存分配器找到一块满足要求的空闲内存块将更加困难耗时更多,当程序消耗的内存超过物理内存大小时虚拟内存系统(如果有的话)开始发挥作用,将进程地址空间中不常用的一部分swap出去,此时系统性能将快速下降,表现出来的就是程序员运行变慢、卡顿。
当然,根据系统配置,像Linux系统,可能会将消耗内存很多的进程kill掉,这就是Out of Memory killer,简称oom killer。
如何发现内存泄漏
不像程序崩溃Core dump,这类问题通过debug通常能获取一些线索,但内存泄漏问题就没那么直接了,尤其对于C/C++程序来说,这时我们将不得不借助必要的工具。
下图是常用的一些内存检测工具:

参数说明:
- DBI: dynamic binary instrumentation(动态二进制插桩)
- CTI: compile-time instrumentation (编译时插桩)
- UMR: uninitialized memory reads (读取未初始化的内存)
- UAF: use-after-free (aka dangling pointer) (使用释放后的内存)
- UAR: use-after-return (使用返回后的值)
- OOB: out-of-bounds (溢出)
- x86: includes 32- and 64-bit.
Valgrind(不推荐,慢,但没有侵入性,检测效果一般)
包含下列工具:
memcheck:检查程序中的内存问题,如泄漏、越界、非法指针等。
callgrind:检测程序代码的运行时间和调用过程,以及分析程序性能。
cachegrind:分析CPU的cache命中率、丢失率,用于进行代码优化。
helgrind:用于检查多线程程序的竞态条件。
massif:堆栈分析器,指示程序中使用了多少堆内存等信息。
lackey:是一个示例程序,以其为模版可以创建你自己的工具。在程序结束后,它打印出一些基本的关于程序执行统计数据
nulgrind:
这几个工具的使用是通过命令:valgrand --tool=name 程序名来分别调用的,当不指定 tool 参数时默认是 -–tool=memcheck
1 | |
该工具可以检测下列与内存相关的问题 :
- 未释放内存的使用
- 对释放后内存的读/写
- 对已分配内存块尾部的读/写
- 内存泄露
- 不匹配的使用malloc/new/new[] 和 free/delete/delete[]
- 重复释放内存
结果中包含以下信息:
HEAP SUMMARY,它表示程序在堆上分配内存的情况,2 allocs表示分配了2次内存,0 frees表示释放了0次,72,714 bytes allocated表示分配了72,714个字节
如果有泄漏,valgrind会报告是哪个位置发生了泄漏(main中cpp第8行)
LEAK SUMMARY,表示不同的内存丢失类型
- definitely loss: 确认丢失,需修复因为在程序运行完的时候,没有指针指向它,指向它的指针在程序中丢失了;
- indirectly lost: 间接丢失,无须处理,当使用了含有指针成员的类或结构时可能会报这个错误。这类错误无需直接修复,他们总是与”definitely lost”一起出现,只要修复”definitely lost”即可;
- possibly lost: 可能丢失,需修复,发现了一个指向某块内存中部的指针,而不是指向内存块头部。这种指针一般是原先指向内存块头部,后来移动到了内存块的中部,还有可能该指针和该内存根本就没有关系,检测工具只是怀疑有内存泄漏。
- still reachable: 可以访问,需修复,未丢失但也未释放。如果程序是正常结束的,那么它可能不会造成程序崩溃。表示泄漏的内存在程序运行完的时候,仍旧有指针指向它,因而,这种内存在程序运行结束之前可以释放。一般情况下valgrind不会报这种泄漏,除非使用了参数 –show-reachable=yes。
- suppressed:已被解决,无须处理,出现了内存泄露但系统自动处理了;可以无视这类错误。
AddressSanitizer(推荐,快,需要链接库,检测效果较好)
AddressSanitizer是google开发一个应用内存检查工具,性能据说比valgrind要好不少,可以配合clang或者GCC编译器使用,GCC需要4.8及以上版本。
AddressSanitizer 是一个快速的内存错误检测器。它由一个编译器检测模块和一个运行时库组成。该工具可以检测以下类型的错误:
- Out-of-bounds accesses to heap, stack and globals
- Use-after-free
- Use-after-return (to some extent)
- Double-free, invalid free
使用方法很简单,只需使用-fsanitize=address标志编译和链接您的程序。
要获得合理的性能,请添加-O1或更高。
要在错误消息中获得更好的堆栈跟踪,请添加 -fno-omit-frame-pointer。
要获得完美的堆栈跟踪,您可能需要禁用内联(只需使用-O1)和尾调用消除(-fno-optimize-sibling-calls)。
然后运行可执行文件即可检测。
如何避免内存泄漏
虽然上节提到的工具可以帮助我们解决大多问题,但仍然存在一些“精心编写”的代码,由于种种原因,产生了意想不到的负面影响
以一个真实的反面教材为例,在紧张的开发完成后,经过了valgrind内存测试、性能测试和测试环境测试,并最终上线了生产环境,但却在一到两天后,日志显示系统异常。
现象是现场数十个小时后整个处理逻辑(传感器接入到事件算法完成输出)耗时过久,且top命令显示的内存一直在缓慢的增长。
对此我们分析了种种原因,最主要的猜测是生产环境所需的处理数据较大,而我们实际的运行环境资源非常紧张,无法积累太长时间的数据。
为了验证各种可能,我们开发并嵌入了更为详细的日志,以确定主要影响的代码段,最终定位到问题以下代码耗时过久。
1 | |
在设计中,此段代码应该只需处理1分钟内的数据量,剩下的将会主动清除,然而因为某一行(line 3,&type)的问题,导致没有清除成功,导致map越来越大,以至于影响整个系统。
一些思路/经验
人生三大错觉之一,我能管理好内存
尽量避免在堆上分配内存
既然只有堆上会发生内存泄露,那第一原则肯定是避免在堆上面进行内存分配,尽可能的使用栈上的内存,由编译器进行分配和回收,这样当然就不会有内存泄露了,但需谨慎注意避免爆栈
善用 RAII(Resource Acquisition Is Initialization)
RAII 可以帮助我们将管理堆上的内存,简化为管理栈上的内存,从而达到利用编译器自动解决内存回收问题的效果。此外,RAII 可以简化的还不仅仅是内存管理,还可以简化对资源的管理,例如 fd,锁,引用计数等等。
当我们需要在堆上分配内存时,我们可以同时在栈上面分配一个对象,让栈上面的对象对堆上面的对象进行封装,用时通过在栈对象的析构函数中释放堆内存的方式,将栈对象的生命周期和堆内存进行绑定。
便于 Debug
内存泄漏实战(Apollo+Bazel)
由于常见的valgrand以及AddressSanitizer等工具并未原生支持或嵌入bazel编译系统,所以需要debug前对整套系统进行一些“魔改”来使用,对于其他如Cmake,使用更为容易且相关资料丰富,这里不做补充。
开启Debug模式(-g)
Bazel 提供了
--compilation_mode (fastbuild|opt|dbg) (-c)选项,即gcc -g选项,参考:Path2apollo/docs/specs/apollo_build_and_test_explained.md
https://docs.bazel.build/versions/main/user-manual.html#flag--compilation_mode
1 | |
经过以上 -g 编译后的程序,gdb就可以看到源码并进行一系列操作:
gdb可以读取到symbols
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
26root@in-dev-docker:/apollo# gdb bazel-bin/modules/omnisense/drivers/lidar/innovusion/driver/adapter_component_test
GNU gdb (Ubuntu 8.1.1-0ubuntu1) 8.1.1
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "aarch64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from bazel-bin/modules/omnisense/drivers/lidar/innovusion/driver/adapter_component_test...done. ### 这里可以读取到symbols,即可以gdb
(gdb) l
1 #include "adapter_component.h"
2
3 #include <time.h>
4
5 #include <chrono>
6 #include <iostream>
7
8 #include "cyber/cyber.h"
(gdb)readelf查看到可执行文件是否带有调试功能
1
2
3
4
5
6
7root@in-dev-docker:/apollo# readelf -S bazel-bin/modules/omnisense/drivers/lidar/innovusion/driver/adapter_component_test | grep debug
[30] .debug_info PROGBITS 0000000000000000 00150451
[31] .debug_abbrev PROGBITS 0000000000000000 0023f27f
[32] .debug_aranges PROGBITS 0000000000000000 002416ea
[33] .debug_ranges PROGBITS 0000000000000000 0025140a
[34] .debug_line PROGBITS 0000000000000000 0026271a
[35] .debug_str PROGBITS 0000000000000000 00281445
链接AddressSanitizer
若使用apollo.sh进行编译,需定制
tools/bazel.rc文件1
2
3
4
5# path2apollo/tools/bazel.rc
# line 49
-build:dbg -c dbg
+build:dbg -c dbg --linkopt=-fsanitize=address
# 或 build:dbg -c dbg --copt=-O1 --linkopt=-fsanitize=address随后使用如下命令编译
1
2./apollo.sh build_dbg path2module
#例: ./apollo.sh build_dbg omnisense/drivers若使用bazel直接编译,则使用如下命令
1
2
3bazel build -c dbg --linkopt=-fsanitize=address path2module/...
# 例: bazel build -c dbg --linkopt=-fsanitize=address modules/omnisense/drivers/...
# 可添加--copt=-O1加速CMake 编译,添加以下标志,然后编译命令加入
-DENABLE_ASAN=ON1
2
3if(ENABLE_ASAN)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=[sanitizer_name] [additional_options] [-g] [-OX]")
endif()Use
CMAKE_C_FLAGSinstead ofCMAKE_CXX_FLAGSfor C projects.设置
[sanitizer_name]为以下功能,默认应使用address:address开启 AddressSanitizerleak开启 LeakSanitizerthread开启 ThreadSanitizerundefined开启 UndefinedBehaviorSanitizermemory开启 MemorySanitizeraddress
[Additional_flags]are other compilation flags, such as-fno-omit-frame-pointer,fsanitize-recover/fno-sanitize-recover,-fsanitize-blacklist, etc.Use
[-g]to have file names and line numbers included in warning messages.Add optimization level
[-OX]to get reasonable performance (see recommendations in the particular Sanitizer documentation).
执行UnitTest并分析内存泄漏(二进制模式)
下面将在UnitTest测试中人为制造一个简单的内存泄漏,并希望通过工具准确检测到问题点

若在上个章节使用并链接了AddressSanitizer,则只需直接运行可执行文件,将会自动在终端输出可能存在memory问题的地方,经过运行后,得到输出如下,其中可以成功定位到问题点:

执行Mainboard并分析内存泄漏(动态库模式)
由于Apollo架构使用dlopen/dlclose加载卸载动态库,所以cyber系列也需要重新加入asan链接
经测试,CyberRT框架与AddressSanitizer有可能存在以下限制,建议优先在unittest中做好完整测试:
- 各模块中
Write()系列函数会导致AddressSanitizer异常,异常时需屏蔽相关代码 - AddressSanitizer依赖于完整退出(Ctrl+C),所以需屏蔽各模块添加的
kill族函数
确认按上述限制修复后,执行如下命令:
1 | |
正常可以定位到上节的内存泄漏点。
注意事项
- 若使用cuda的时候需要用asan排查内存问题,cuda相关api无法正常使用,会出现out of memory的错误,需要添加
ASAN_OPTIONS=protect_shadow_gap=0环境变量