ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++服务内存暴涨?用core_analyzer从core文件精准定位泄漏

2026/10/5 12:50:54 拓冰建站 浏览量
C++服务内存暴涨?用core_analyzer从core文件精准定位泄漏 线上服务的内存占用一路涨到 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 APIcore_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::vectorint* vec new std::vectorint(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|VmRSS4.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. 输出文件逐项拆解别只会看 summary5.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 服务里加LimitCOREinfinity并确认ulimit -c unlimited。设置规范的 core 文件路径和命名echo kernel.core_pattern/var/crash/core_%e.%p.%t /etc/sysctl.conf sysctl -p这样 core 文件会附带可执行文件名、进程号和时间戳找文件时不会被一堆core.1234搞晕。对于 setuid 进程需要打开fs.suid_dumpable1否则即使 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 补上最后一厘米的精度两者配合使用才是我个人最推荐的方式。