线上服务的内存占用一路涨到 90%,重启之后过几个小时又开始爬坡,你用 gdb 挂上去看了半天,只能确认它没死,却始终说不清内存到底被谁吃了。这种经历,写过 C++ 服务的同学大概率都不陌生。后来我把 core_analyzer 用起来之后,整个排查链路就变了:拿到一份 core 文件,或者对一个运行中的进程做几次采样,它能直接告诉你堆上分布了多少内存、哪些块已经无人引用、哪些位置像是泄漏点,甚至能顺着调用栈一路追到具体的函数。这篇文章就是我从安装配置到线上实践的一份完整记录,适合 C++ 后端开发、SRE 以及所有被内存占用问题折磨过的人参考。
1. 服务内存告警却找不到元凶时,core_analyzer 是怎么切入的
1.1 先说我遇到的排查场景
之前维护过一个常驻内存的 C++ 服务,业务高峰期 RSS 稳定增长,每 24 小时涨 2GB 左右。一开始我用/proc/<pid>/status里的 VmRSS 确认它在涨,用pmap -x也只能看到 "heap" 整体有多大,再往下就没了。也试过 valgrind,但线上进程 10 倍以上的性能损耗根本不现实,只能拿到测试环境去复现,费了很大劲才构造出接近线上的流量场景。
后来退一步想,我需要的不是"正在发生什么",而是"已经发生了什么"。服务的内存现场其实一直保存在系统的某个地方,只要进程还在,就可以用 gcore 或 kill -6 把完整的 core 文件导出来;如果进程已经崩了,系统本来就留下了一份 core。问题只是这份 core 文件里动辄几个 GB 的二进制内存数据,要怎么变成"哪些函数分配了多少内存"这样的结构化结论。core_analyzer 就是顺着这个思路做的一个工具。
1.2 core_analyzer 能回答的三类问题
我把这个工具在实际工作中的价值归纳成三类,基本覆盖了日常内存排查的大多数场景:
- 内存泄漏:哪些堆块已经没有任何指针引用(孤儿块/泄漏块),累计占了多少内存,回溯到分配时的调用栈。
- 内存占用分布:进程地址空间里堆、栈、mmap、全局数据各占多少,某个动态库或者某个线程栈是不是异常偏大。
- 内存增长趋势:对运行中的进程做多次采样,或者对同一进程不同时刻的 core 做对比,判断增长是集中在某个固定函数还是分散在多处。
第一类是最常被用到的。第二类在排查"明明没有泄漏却占了 8GB"这类问题时很关键,比如某个库初始化时预留了超大内存,又或者某些线程池把栈开得过大。第三类属于锦上添花,但做过几次之后你会越来越依赖它,因为内存问题最怕的就是"单点现场"看不出全貌。
1.3 它和 gdb、valgrind 相比的差异在哪
这并不是说 core_analyzer 要替代 gdb 或者 valgrind,而是三者的定位完全不同:
- gdb 适合回答"进程当前在执行什么",但你要手工遍历 malloc_chunk 链表才能还原堆结构,操作成本高,也容易漏。
- valgrind 适合回答"运行期每条分配路径是否释放",精度最高,但性能损耗摆在那,线上用不了。
- core_analyzer 适合回答"这个已经存在的内存现场里,内存都去哪了",它把堆结构、符号、调用栈映射结合在一起,不做运行时侵入,所以能对 core 文件直接分析。
实际排查时,我通常先用 core_analyzer 做出一个"嫌疑人列表",再回到 gdb 里针对具体地址做内容验证。工具给线索,人工给最后判断,这样效率最高。
2. 装好并跑通它:依赖、配置与第一次试运行
2.1 安装前先检查这三样东西
core_analyzer 依赖 Linux、Python 3 和带 Python 支持的 gdb,严格来说还得能解析 ELF 文件。先确认环境是否满足:
python3 --version gdb --version gdb --batch -ex "python print('gdb-python ok')"最后一条如果输出gdb-python ok,说明 gdb 编译时带了 Python 支持,这是 core_analyzer 能驱动 gdb 做内存分析的前提。大多数发行版的官方 gdb 包都自带,但如果你是从源码自己编的 gdb,很容易漏掉--with-python,这一步要提前确认。另外建议把 binutils、elfutils 装上,分析符号表和段信息时会用到。
我习惯的工作目录是:
mkdir -p ~/tools && cd ~/tools git clone https://github.com/yanqi27/core_analyzer.git cd core_analyzer工具本身是以 Python 脚本为主,不需要编译。下载完成后最好先跑一下python3 core_analyzer.py -h,确认依赖库有没有缺,再根据输出的参数说明熟悉各选项。
2.2 配置文件里的三个关键开关
core_analyzer 运行时会读取用户目录下的~/.core_analyzer_config,不同版本配置项略有出入,以你仓库里的示例为准。我建议重点关注下面这几个,因为它们的值直接影响结论的准确性和分析耗时:
debug_symbols:是否解析符号。置为 true 时结果里能看到函数名,否则只能看到地址。always_backtrace:是否对每个孤儿块都尝试做栈回溯。开着的确能提升定位精度,但会显著增加耗时。free_blocks:是否把已释放块也纳入分析。开启后能识别"释放了但还被指针引用"这类 use-after-free 引发的内存错乱,但误报率也会上升。
我的策略是:先全部按默认配置跑一遍看 summary,如果怀疑与 free 链表有关,再单独调free_blocks。不要一上来就把所有选项打开,否则一个 2GB 的 core 可能分析几个小时还没出结果。
2.3 用一个小例子验证工具链路
正式用之前,最好先跑一遍通。我拿一个最简单的 demo 验证:
cat > test.cpp <<'EOF' #include <cstdlib> int main() { void *p = malloc(1024 * 1024); sleep(30); return 0; } EOF g++ -g -O0 test.cpp -o test ulimit -c unlimited ./test & PID=$! sleep 2 kill -6 $PID杀掉之后,当前目录会出现类似core.<pid>的文件。接下来:
python3 core_analyzer.py -c core.<pid>如果工具显示开始加载 core、输出内存摘要并生成分析目录,说明整条链路是通的。这一步能提前暴露很多问题,比如 gdb 版本不兼容、符号缺失、core 文件被系统截断等,总比到线上才发现要好。
3. 原理拆解:core 文件里的内存现场是如何被还原的
3.1 从 core 文件到内存地图的处理流程
先说宏观流程。core 文件本质上是 ELF 格式的转储,里面用 PT_LOAD 段保存了进程地址空间里各个区域的原始内存数据。core_analyzer 拿到 core 后,会借助 gdb 的 Python API 读取目标进程的内存映射和符号信息,先还原出"这段地址空间里有什么"。
它的大致处理链路是:读取进程的内存布局 -> 定位 glibc 堆管理器的关键结构 -> 遍历所有堆块并建立索引 -> 对全局变量区和线程栈扫描指针引用 -> 标记孤儿块/泄漏块 -> 生成报告和可视化图。整个过程里最难也最关键的一步,是第二步和第三步——还原堆结构。
3.2 还原 glibc 堆管理器的内部结构
glibc 的 ptmalloc2 分配器把堆组织成 arena。主线程用的是 main_arena,位于 libc 的数据段里;其他线程各自有 non-main arena,每个 arena 有一段通过 mmap 或者 sbrk 拿到的连续内存。代码里还存在 heap_info 结构用来描述每个 sub-heap 的元数据,malloc_chunk 则是真正管理内存块的最小单元,包含 size、prev_size、fd、bk 等字段。
core_analyzer 会先通过符号找到 main_arena,再从 arena 的 top 指针出发,沿着 memory 布局逐个 chunk 遍历。对于 non-main arena,则需要通过 heap_info 的链表把多个 sub-heap 串起来。遍历过程中,它会根据 chunk 的 size 字段和 PREV_INUSE 标志判断块是否有效,还能顺着空闲块的 fd/bk 指针把 free 链表重建出来。这样,进程退出或者崩溃那一刻的整张"内存账本"就恢复了。
3.3 孤儿块与泄漏块的判定逻辑
有了堆块清单之后,下一个问题是怎么判断哪些块没人用。做法很直白:扫描进程的全局变量区和每个线程的栈空间,看这些区域里存的 8 字节对齐指针,是否指向某个堆块。如果一块内存被至少一个指针引用,就认为它"活着";如果一个 in-use 块从头到尾没有任何指针指向它,它就沦为了孤儿块。
但这里有个非常重要的细节:孤儿块不等于泄漏块。很多框架会预留一块内存池,主逻辑里保留一个指向池子的指针,池子内部又切分给各个业务对象,业务对象之间没有指针互指。这种场景下工具可能把池子内部的大量空闲子块也判成孤儿块。所以工具还会结合块大小、数量、存活时间、块内容特征等做二次过滤,最终输出"高度疑似泄漏"的列表。我们在解读结果时也要意识到,这只是概率层面的判断,需要结合代码确认。
3.4 为什么非要用 gdb 当中间层
可能有同学会问,为什么不直接用 Python 解析 core 文件的二进制内容,非要绕一层 gdb?因为 core 文件里的虚拟地址到文件偏移的映射,和符号解析、线程信息解析,都是 ELF 调试生态里已经打磨得很成熟的能力。借助 gdb 的 Python API,core_analyzer 可以把"读内存、查符号、遍历栈"这些底层的脏活全部交给 gdb,自己专心做堆结构分析和数据聚合。这也是这个工具能同时支持 core 文件分析和实时进程分析的原因:两者对 gdb 来说只是数据来源不同,后面都是同一套逻辑。
4. 实测复盘:一个泄漏 C++ 程序从 core 到定位的全过程
4.1 构造一个有代表性泄漏的试验程序
为了讲清楚分析过程,我写了一个可以复现的 C++ 程序,它含两种典型泄漏:一种是每轮泄漏 1MB 的 char 缓冲区,另一种是泄漏一个 10 万元素的 vector。
#include <cstdio> #include <cstring> #include <unistd.h> #include <vector> void leak_buffer(int n) { char* buf = new char[1024 * 1024]; snprintf(buf, 1024, "leak-buffer-%d", n); // 故意不释放 buf } void process_request(int id) { std::vector<int>* vec = new std::vector<int>(10000, id); leak_buffer(id); // 故意不释放 vec } int main() { for (int i = 0; i < 300; i++) { process_request(i); usleep(100 * 1000); } return 0; }这个程序跑完之后,堆上大概会有 300 个 1MB 的 char 块和 300 个 vector 块,总量约 300MB 出头。代码故意写得简单,避免引入复杂上下文干扰分析。
4.2 编译、运行并获得 core 文件
编译时务必带上调试符号并关闭优化,这一步对后面的调用栈回溯至关重要:
g++ -g -O0 leak_demo.cpp -o leak_demo ulimit -c unlimited ./leak_demo & PID=$! sleep 2 kill -6 $PID这里用kill -6发送 SIGABRT,让进程主动产生 core。生产环境里如果你不想打断进程,可以用gcore <pid>直接把当前内存转储出来,效果类似。core 文件生成后,先看一眼大小确认没有被系统截断:
ls -lh core.*我习惯再把进程当时的虚拟内存和实际内存记下来,方便后面和 core_analyzer 的统计结果做交叉比对:
cat /proc/$PID/status | grep -E "VmPeak|VmSize|VmRSS"4.3 执行分析并拆解输出
接下来运行主程序:
python3 core_analyzer.py -c core.<pid>分析结束后,控制台和输出文件里会出现关键摘要。不同版本格式有差异,但通常都包含这几项:总内存量、堆上块数、孤儿块数量、泄漏块累计大小、非堆内存占比,以及全局变量和栈区各自的占用。这个例子里的摘要信息大致会是:
total memory: 约 330 MB heap blocks: 600+ orphan blocks: 300 左右 leaked blocks: 300 左右看到这个结果,基本就可以确认泄漏数量级和核心分布了。真正要定位到具体函数,还需要看孤儿块日志和调用栈回溯。
4.4 用更细的模式回查分配点的调用栈
首轮跑完拿到的是"统计结论",下一步是确认"具体在哪分配"。我用参数逐块检查泄漏块的详细信息:
python3 core_analyzer.py -l -c core.<pid>这个模式会对每个高嫌疑泄漏块做更完整的回溯,输出里能看到leak_buffer和process_request这两个函数反复出现在栈顶。看到这两个名字,业务层的结论基本就清楚了:process_request里两个对象都没有释放。再回到代码一看,确实是典型的裸指针管理失误。
这个过程中我有两点体会:第一,先看 summary 再看 -l,不要反着来,否则信息太多反而抓不住重点;第二,-l模式慢很多,几百 MB 的 core 也要等一段时间,但这一步的投入完全值得,因为它把问题从"哪块内存没了"推进到了"哪个函数导致的"。
5. 输出文件逐项拆解:别只会看 summary
5.1 分析目录里的常见文件与作用
core_analyzer 会在当前目录生成一个分析输出目录,里面文件通常包含下面这些(不同版本命名可能略有差异):
| 文件/目录 | 作用 |
|---|---|
| summary.txt | 汇总信息,先看这个 |
| segments.map | 进程地址空间各内存段的大小分布 |
| heaps.map | 每个堆/arena 内部块分布 |
| globals.map | 全局变量占用明细 |
| orphan-blocks.log | 无人引用的堆块列表 |
| leaked-blocks.log | 高疑似泄漏块列表 |
| graph_*.svg / flamegraph | 可视化调用栈聚合图 |
我自己的习惯是拿到分析目录后,先花 30 秒看完 summary.txt,再根据问题类型决定下一步翻哪个文件。而不是一上来就翻超大的 orphan-blocks.log。
5.2 内存段地图:一图看穿地址空间
segments.map 在排查"非堆内存过大"时特别好用。有时候内存占用高根本不是泄漏,而是某个库初始化时用 mmap 预留了一块超大空间,或者大量线程各自开了很大的栈。这些在堆分布上看不出来,但地址空间段地图一目了然:某个 segment 的 size 突然比同类进程大出几个数量级,顺着起始地址去查是哪个动态库或者哪个线程栈,问题就清楚了。
我之前排查过一个故障,服务 RSS 稳定在 4GB,但堆上几乎找不到大块孤儿块。后来看 segments.map,发现一个日志库把缓冲预分配到了 2GB,场景一下就从"内存泄漏"变成了"内存配置不合理",修复思路也完全变了。
5.3 火焰图的补充价值
当泄漏点分散在多个函数时,火焰图能把"哪些函数路径分配了最多内存"直观地呈现出来。工具会把孤儿块的调用栈聚合起来,生成 SVG 格式的火焰图,纵向是调用深度,横向是内存占比。对比正常和不正常的火焰图,你能很快发现某条调用链的宽度异常。
不过火焰图也有局限:如果程序有大量内联、尾调用优化,或者符号缺失,火焰图会显得非常"秃"。所以我一般把它当作辅助手段,主要结论还是从 summary 和日志里拿。
5.4 用多份 core 对比判断"泄漏"还是"增长"
内存一直增加并不等于泄漏,也可能是缓存、线程池、连接池没设置上限,属于"合理但无限增长"。要区分这两者,光看一份 core 不够,需要对进程做多次采样:
sleep 600 && gcore <pid> python3 core_analyzer.py -c core.<pid>每隔一段时间采集一份,然后把各份 summary.txt 里的孤儿块数量和大小列成表。如果孤儿块大小随时间线性增长,而调用栈每次都指向同一个函数,这是典型泄漏;如果增长的是某个缓存池的大小,但孤儿块比例不高,这可能只是容量规划问题。数据的趋势比单个快照更有说服力。
6. 在线上环境把这套流程固化下来:配置、监控与避坑
6.1 先把 core 文件"留下来"
核心思路是让线上环境具备随时"留档"能力。我建议在部署和系统层做三件事:
- 放开 core 文件限制:systemd 服务里加
LimitCORE=infinity,并确认ulimit -c unlimited。 - 设置规范的 core 文件路径和命名:
这样 core 文件会附带可执行文件名、进程号和时间戳,找文件时不会被一堆echo 'kernel.core_pattern=/var/crash/core_%e.%p.%t' >> /etc/sysctl.conf sysctl -pcore.1234搞晕。 - 对于 setuid 进程,需要打开
fs.suid_dumpable=1,否则即使 ulimit 开放也不会产生 core。
还有一点容易被忽略:core_pattern 里如果用了|管道给外部程序处理,要保证管道处理程序足够健壮,否则 dump 过程可能因为接收方的问题把 core 丢失。我先保证直接落盘,再考虑是否需要后续压缩或上传。
6.2 符号表保留策略
core_analyzer 能不能输出函数名,取决于进程的调试符号。线上发布时为了减小包体,很多人会把二进制 strip 掉,这会让分析结果退化成一片地址。我的建议是:
- 编译时保留
-g,部署后不用 strip,或者至少保留一份未 strip 的对应版本归档。 - 如果必须 strip,可以用
objcopy --only-keep-debug把调试信息单独抽出来,分析时再通过 debuglink 或 debuginfod 配对。 - 发布流程里记录好"哪个二进制对应哪个内核版本",core 文件、二进制、符号文件三者要能正确对齐。
缺少符号时也不是完全不能用,只是定位成本大幅上升:你得先看一眼泄漏块里的内容特征,再到代码里搜索类似字符串,相当于从刑侦取证退回到了拼图。
6.3 实时分析运行中进程:-p模式
有些内存问题是缓慢增长型的,进程还没崩,但你知道它迟早会爆。这种场景可以直接对运行中进程做实时采样分析:
python3 core_analyzer.py -p <pid> -m 2G -t 3-p后面跟目标进程号,-m用来指定进程的内存规模,-t是采样次数。工具会临时用 gdb attach 到进程上读取内存状态,分析完毕再脱离。这样能拿到"此时此刻"的内存分布,不需要重启或中断服务。
注意点有两个:一是 attach 需要足够权限,容器环境里可能要调整kernel.yama.ptrace_scope或用CAP_SYS_PTRACE;二是采样过程中 gdb 会短暂 stop 进程,面向用户的服务要评估这几十毫秒的影响。我一般选择低峰期做,或者配合灰度节点。
6.4 结合监控与 OOM 处理机制形成闭环
最后是把这套分析能力接入监控告警。我的做法是:
- 在指标采集里加入
/proc/<pid>/status的 VmRSS 和 VmPeak,绘成趋势线。 - 设置内存增长速率告警,而不是只等达到阈值再告警。
- 当趋势异常时,自动触发
gcore和 core_analyzer 分析,把 summary 和火焰图存档,作为排查依据。 - 同时记录 cgroup 内存事件,比如
memory.events里的 oom_count 和memory.peak,判断进程是不是已经踩到过 OOM 边缘。
这套闭环成立之后,内存问题的平均定位时间从之前的按天算,缩短到了按小时算。工具只是其中一环,真正让它发挥价值的是流程:能留档、能自动分析、能关联监控上下文。
7. 几个让我印象深刻的坑与心得
7.1 线程特别多的进程,分析时间会让人崩溃
第一次处理一个 500+ 线程的 C++ 服务时,我开着默认配置直接跑,结果等了将近一小时才出报告。原因是每个线程栈都要扫描引用,线程越多越慢。后来我改成先关掉always_backtrace,只看 summary 和孤儿块列表,确定方向后再对特定地址做精细回溯,效率高了很多。遇到线程超多的进程,建议把分析分成"粗筛"和"精查"两步。
7.2 缺少调试符号时输出全是偏移地址
有一回线上服务的内存持续增长,但我只拿到了一份 stripped 二进制对应的 core。core_analyzer 分析照常完成了,可所有回溯都停在动态库的内部偏移上,根本看不出业务函数。最后是反汇编 + 对照发布记录才找到问题,过程非常痛苦。从那之后我强制在发布流水线里保留了未 strip 的调试包,成本不大,但排查体验差别非常大。
7.3 别被"孤儿块"带偏:先分清楚是泄漏还是暂存
工具说你有 500MB 孤儿块,不代表一定要立刻改代码。框架的连接池、对象池、线程局部缓存都可能让一些堆块暂时没有指针指向。我的判断经验是:看孤儿块是按某个函数稳定累积,还是随流量波动;看块内容是不是同一类结构;再看连续多次采样是不是单调增长。三者都指向"只增不减",才算真正的泄漏。
7.4 最后再分享一个小技巧
分析结束后,我会把泄漏块地址和块内前 64 字节内容导出来,直接去 gdb 里验证:
gdb -batch -ex "x/64bx 0x1a2b3c4d" ./leak_demo core.<pid>这一步虽然笨,却经常能提供额外线索:比如块内容里留下了业务 ID、请求路径或者序列化数据,能直接帮你把泄漏点和具体业务场景对上。core_analyzer 给了全局视野,gdb 补上最后一厘米的精度,两者配合使用才是我个人最推荐的方式。