ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++ std::bad_alloc 崩溃排查:内存泄漏定位与 Valgrind/ASan 实战

C++ std::bad_alloc 崩溃排查:内存泄漏定位与 Valgrind/ASan 实战 1. 从一次深夜崩溃说起std::bad_alloc到底在喊什么凌晨两点服务端进程突然挂掉日志里只留下一行冷冰冰的terminate called after throwing an instance of std::bad_alloc紧接着就是what(): std::bad_alloc。如果你写过稍微复杂一点的 C 程序尤其是那种跑几天才出问题的长驻服务这个场景大概率不陌生。std::bad_alloc不是普通的逻辑错误它是 C 运行时在operator new无法满足内存分配请求时抛出的异常翻译成人话就是系统已经拿不出你要的那块内存了。很多人第一反应是机器内存不够了于是加内存、重启服务问题暂时消失过几天又回来。这种处理方式掩盖了真正的病根——绝大多数情况下std::bad_alloc的背后是内存泄漏也就是程序不断申请内存却从不归还最终把可用地址空间耗尽。注意这里说的耗尽不一定是物理内存耗尽32 位进程有 2GB 到 4GB 的地址空间上限64 位进程虽然地址空间巨大但长期泄漏同样会把 RSS 撑爆触发 OOM Killer 或者分配失败。这篇内容面向的是已经能写 C、但对内存问题排查还没有系统方法的开发者。我会把std::bad_alloc当成一个症状来对待从它出发倒推内存泄漏的定位思路讲清楚 Valgrind、AddressSanitizer 这些工具怎么用、什么时候用哪个、输出怎么读以及那些工具抓不到的坑该怎么绕。核心关键词围绕C、std::bad_alloc、内存泄漏、Valgrind、AddressSanitizer展开但重点不是罗列工具手册而是把我遇到这个问题会怎么一步步查讲透。先说一个反直觉的结论看到std::bad_alloc时最不该做的就是立刻去查泄漏点。因为此时进程往往已经处于濒死状态堆已经碎片化或接近耗尽任何额外的内存分配包括日志、异常处理本身都可能再次失败。正确的第一步是保留现场——core dump、内存快照、/proc/pid/status里的VmRSS和VmSize这些才是后续分析的原料。等进程重启、内存恢复之后再从容地做泄漏检测。2. 先分清三种内存不够泄漏、碎片、还是真不够在动手用工具之前得先把问题归类。std::bad_alloc的成因至少有三类排查路径完全不同搞错了方向会浪费大量时间。2.1 真·内存不足请求本身就超过了可用量最直接的一种你一次性申请了一块超大内存比如读一个 8GB 的文件却想new char[8GB]而机器只有 4GB 物理内存加 2GB swap。这种情况下bad_alloc是合理的程序逻辑本身有问题。判断方法很简单看崩溃时的分配大小——如果异常发生在某个明确的、巨大的单次分配上那基本就是它。还有一种隐蔽的情况overcommit 策略。Linux 默认允许 overcommitmalloc返回成功不代表真的有物理内存等到真正写入页面时才触发 OOM。所以有时候你看到的是进程被 OOM Killer 杀掉而不是抛bad_alloc反过来如果系统禁用了 overcommitmalloc就会直接失败抛异常。这两种表现背后的根因可能一样但排查入口不同。2.2 内存碎片总量够但没有连续的大块这个最容易被忽略。假设进程总共用了 1GB 内存但被切成了几百万个大小不一的小块此时你想申请一块 100MB 的连续内存即使空闲总量有 500MB也可能失败。碎片化在长期运行的服务里非常常见尤其是频繁new/delete不同大小对象、又混用std::vector扩容的场景。碎片导致的bad_alloc有个特征崩溃点往往在某个较大的分配上而进程的 RSS 并没有接近上限。这时候查泄漏是查不出东西的因为内存确实被释放了只是释放得七零八落。解决办法通常是引入内存池、对象池或者换用jemalloc、tcmalloc这类对碎片更友好的分配器。2.3 内存泄漏只借不还温水煮青蛙这才是标题里点名的重点。泄漏的特征是RSS 随时间单调增长跑得越久越高最终在某个分配点崩掉。它和碎片的区别在于碎片是总量稳定但布局糟糕泄漏是总量持续上涨。判断方法就是持续监控VmRSS画一条时间曲线如果斜率稳定为正基本可以锁定泄漏。提示不要只看top里的 RES 一栏就下结论。RES 包含共享库、mmap 的文件页等真正反映堆增长的是/proc/pid/status里的VmRSS配合VmData或者用pmap看[heap]段的增长。把这三类分清楚之后工具的选择就有了依据怀疑泄漏上 Valgrind 或 ASan 做检测怀疑碎片看分配器统计和pmap布局怀疑真不够看单次分配大小和系统内存水位。下面重点讲泄漏这条线因为它是std::bad_alloc最常见的幕后黑手。3. 内存泄漏的四种典型形态与代码级识别在掏出工具之前先靠代码审查能抓出一大半泄漏。C 的泄漏无非几种固定套路认熟了之后看代码就有感觉。3.1 裸 new 配裸 delete异常路径漏了最经典的形态void process() { Widget* w new Widget(); doSomething(); // 这里可能抛异常 delete w; // 异常一抛这行永远执行不到 }doSomething()一旦抛异常delete w就被跳过w指向的内存永久泄漏。这种代码在正常路径下测试完全没问题只有异常路径才暴露。修复方式是用std::unique_ptr或std::make_unique让 RAII 接管生命周期。3.2 容器存裸指针容器析构了指针没释放std::vectorWidget* widgets; for (int i 0; i 1000; i) { widgets.push_back(new Widget()); } // widgets 析构时只释放了 vector 自己的缓冲区 // 那 1000 个 Widget 对象全部泄漏这是新手最容易踩的坑也是std::vectorstd::unique_ptrWidget存在的意义。判断方法搜索代码里所有std::vectorX*、std::mapK, V*这类容器逐个确认指针的归属。3.3 循环引用shared_ptr 的经典死锁struct Node { std::shared_ptrNode next; std::shared_ptrNode prev; // 双向链表用 shared_ptr引用计数永远归不了零 };两个shared_ptr互相持有引用计数永远至少为 1析构函数永不调用。修复方式是把其中一个改成std::weak_ptr。这类泄漏的特点是对象数量不多但每个对象都很大RSS 增长缓慢但稳定。3.4 第三方库或系统调用的隐式分配有时候泄漏不在你的代码里而在某个库的 API 用法上。比如strdup返回的字符串要freegetaddrinfo的结果要freeaddrinfo某些 C 接口返回的句柄要显式关闭。这类泄漏靠读自己的代码是找不到的必须靠工具。实操心得我排查泄漏时有个习惯先grep -rn new src/把所有裸 new 列出来再grep -rn malloc\|strdup\|calloc src/列出所有 C 风格分配逐个确认释放路径。这一步能解决大概六成的泄漏剩下的才交给工具。4. Valgrind慢但准的泄漏检测老炮Valgrind 的memcheck工具是内存泄漏检测的经典选择它的原理是动态二进制插桩——把程序跑在虚拟 CPU 上每条内存访问指令都被拦截检查。这带来两个特点检测极其精确能定位到具体行号和调用栈但速度极慢通常慢 10 到 50 倍。4.1 基本用法与关键参数valgrind --leak-checkfull \ --show-leak-kindsall \ --track-originsyes \ --log-filevalgrind.log \ ./your_program arg1 arg2逐个解释这些参数为什么这么设--leak-checkfull给出每个泄漏块的完整调用栈这是定位问题的核心不加的话只告诉你有泄漏。--show-leak-kindsallValgrind 把泄漏分成四类——definitely lost确定泄漏没有任何指针指向、indirectly lost间接泄漏指向它的块本身泄漏了、possibly lost可能泄漏有指针指向块中间而非开头、still reachable程序结束时仍可达通常不算泄漏。默认只显示前两类加上这个参数能看到全部。--track-originsyes追踪未初始化值的来源排查用了未初始化内存这类问题时必开但会进一步拖慢速度。--log-file输出到文件避免和程序自己的 stdout 混在一起。4.2 读懂 Valgrind 的泄漏报告一份典型的报告长这样12345 40 bytes in 1 blocks are definitely lost in loss record 1 of 3 12345 at 0x4C2B0E7: operator new(unsigned long) (vg_replace_malloc.c:334) 12345 by 0x400F2A: Widget::create() (widget.cpp:42) 12345 by 0x400E15: main (main.cpp:18)关键信息是调用栈Widget::create()第 42 行new出来的内存在main第 18 行调用后没有被释放。顺着这个栈去看代码基本一眼就能找到问题。definitely lost是最需要关注的它意味着这块内存已经彻底失联。still reachable要区别对待如果是一个全局单例在程序结束时没释放那属于设计如此不算 bug但如果是某个本该释放的对象一直挂在全局容器里那就是真泄漏。4.3 Valgrind 的局限与应对Valgrind 最大的问题是慢跑一个正常 1 分钟的程序可能要 30 分钟以上。对于长驻服务你不可能让它跑一整天。常见的应对策略是写针对性的测试用例把可疑的功能模块单独抽出来构造一个能触发泄漏的最小场景用 Valgrind 跑这个场景而不是整个服务。缩短运行时间如果泄漏是每次请求泄漏一点跑几百个请求就能看出趋势不需要跑几万个。配合--leak-checksummary先看总量先快速跑一遍看有没有泄漏有的话再开full精确定位。注意Valgrind 对多线程程序的支持是有的但会串行化线程执行某些依赖真实并发的 bug 可能复现不出来。另外它和某些 JIT、自定义分配器可能冲突遇到奇怪的报错先怀疑这两点。5. AddressSanitizer快得多的现代选择如果说 Valgrind 是慢工出细活那 AddressSanitizerASan就是又快又准的现代方案。它是编译器插桩——在编译时往代码里插入检查指令运行时开销通常只有 2 倍左右比 Valgrind 快一个数量级。代价是需要重新编译程序且对内存的占用会显著增加ASan 会预留大块虚拟地址空间做影子内存。5.1 编译与运行g -fsanitizeaddress -fno-omit-frame-pointer -g -O1 your_program.cpp -o your_program ./your_program参数说明-fsanitizeaddress开启 ASan。-fno-omit-frame-pointer保留帧指针让调用栈更完整排查时非常关键。-g带调试符号否则报告里只有地址没有行号。-O1建议用-O1而不是-O0因为-O0下某些优化相关的 bug 不会暴露-O1是检测和性能的平衡点。5.2 ASan 报告怎么读ASan 检测到泄漏时程序退出会打印类似这样的报告12345ERROR: LeakSanitizer: detected memory leaks Direct leak of 40 byte(s) in 1 object(s) allocated from: #0 0x4f2b0e7 in operator new(unsigned long) #1 0x400f2a in Widget::create() widget.cpp:42 #2 0x400e15 in main main.cpp:18Direct leak对应 Valgrind 的definitely lostIndirect leak对应indirectly lost。调用栈同样清晰直接定位到行。ASan 相比 Valgrind 的优势不只是快还有能检测栈和全局变量的越界Valgrind 主要管堆ASan 连栈缓冲区溢出、全局变量越界都能抓。能检测 use-after-free这块内存被释放后又被访问ASan 会立刻报错而 Valgrind 需要特定条件才触发。和 CI 集成方便因为快可以放进持续集成流水线每次提交都跑一遍。5.3 ASan 的坑与注意事项ASan 不是万能的有几个坑必须知道内存占用大ASan 的影子内存机制会让程序虚拟内存占用膨胀32 位程序基本没法用64 位程序也要留足余量。跑 ASan 时如果看到bad_alloc先确认是不是 ASan 自己把地址空间吃掉了。和某些库冲突如果程序链接了预编译的、没开 ASan 的第三方库可能出现误报或漏报。理想情况下所有代码都用 ASan 编译。泄漏检测默认在退出时进行如果程序是被kill -9干掉的ASan 来不及输出报告。所以测试时要让程序正常退出或者用__lsan_do_leak_check()手动触发。实操心得我现在的习惯是日常开发用 ASan 编译跑单元测试因为它快能天天跑遇到 ASan 报不出来但怀疑有泄漏的疑难杂症再上 Valgrind 做深度检查。两者互补不是二选一的关系。6. 工具抓不到的泄漏那些藏在分配器背后的坑Valgrind 和 ASan 都很强但它们有一个共同的盲区它们监控的是malloc/new这一层如果你的程序用了自定义内存池或者内存是被某个库内部缓存起来的工具可能看不到。6.1 内存池与对象池的假泄漏很多高性能服务会用内存池预分配一大块内存然后自己管理分配。对 Valgrind 来说这块内存从malloc出来之后就一直still reachable它不会报泄漏但实际上池子内部可能已经漏得一塌糊涂。这种情况下工具帮不了你只能靠池子自己的统计——记录分配次数、释放次数、当前占用定期打印出来看趋势。6.2 glibc malloc 的 arena 与碎片glibc 的malloc在多线程下会创建多个 arena每个线程可能绑定到不同的 arena。这会导致一个现象某个线程释放的内存不一定能被另一个线程复用因为它们在各自的 arena 里。表现出来就是 RSS 居高不下但 Valgrind 说没有泄漏。这时候可以调MALLOC_ARENA_MAX环境变量限制 arena 数量或者换jemalloc/tcmalloc。6.3 第三方库的内部缓存有些库比如某些解析库、图像处理库会在内部做缓存缓存不设上限用久了就涨。这类问题工具完全无能为力只能靠读库的文档、看它的配置项或者用ltrace/strace观察它的系统调用行为。排查这类工具抓不到的泄漏我的经验是先看 RSS 增长曲线再看分配器统计最后才怀疑代码。顺序反了容易在代码里大海捞针。7. 一套可复用的排查流程从崩溃现场到根因把前面所有东西串起来形成一套我实际在用的流程。假设线上服务抛了std::bad_alloc我会这么做7.1 第一步保留现场进程崩溃前如果配了 core dump先拿到 core 文件。同时抓取崩溃时刻的/proc/pid/status、/proc/pid/maps、pmap -x pid。这些数据能告诉你崩溃时进程的地址空间布局、堆有多大、有没有异常的 mmap 区域。7.2 第二步确认是泄漏还是碎片看VmRSS的历史曲线。如果是从启动开始就缓慢上涨那是泄漏如果一直稳定然后突然崩可能是碎片或单次大分配。这一步决定了后面走哪条路。7.3 第三步本地复现把可疑模块抽出来写一个能循环调用它的测试程序用 ASan 编译跑。如果 ASan 报泄漏直接定位如果不报换 Valgrind 再跑一遍。两者都不报但 RSS 确实在涨那就是分配器或库的问题回到第 6 节。7.4 第四步修复与验证修复之后用同样的测试程序再跑一遍确认 RSS 曲线变平。然后把这个测试用例加进 CI防止回归。下面这张表是我整理的常见现象与对应排查方向可以直接当速查表用现象可能原因首选工具/方法RSS 随时间单调上涨内存泄漏ASan 日常跑Valgrind 深查RSS 稳定但大分配失败内存碎片pmap 看布局换 jemalloc崩溃在单次超大分配逻辑错误或真不足看分配大小检查系统内存工具报 still reachable全局单例或缓存判断是否设计如此工具无报告但 RSS 涨内存池/库缓存池统计、库文档、MALLOC_ARENA_MAX多线程下才泄漏竞态或 arena 问题线程检查工具、限制 arena8. 几个让我印象深刻的真实案例理论讲完了说几个我实际踩过的坑比任何文档都管用。案例一异常路径泄漏。一个图像处理服务正常跑几天没事一旦遇到损坏的图片文件就慢慢涨内存。原因是解析函数里new了一个缓冲区解析失败时直接return没释放。ASan 在单元测试里一跑就报出来了修复只花了五分钟但定位花了两天——因为正常测试数据不会触发异常路径。案例二shared_ptr 循环引用。一个任务调度器任务对象之间互相持有shared_ptr形成环任务完成后对象不释放。RSS 每小时涨几十 MB跑一周就崩。Valgrind 报的是indirectly lost顺着栈找到环的入口把其中一个改成weak_ptr解决。案例三glibc arena 导致的假泄漏。一个多线程服务RSS 涨到某个值就稳定了但比预期高很多。Valgrind 说没有泄漏。最后发现是线程频繁创建销毁每个线程绑定的 arena 释放的内存无法被其他线程复用。设置MALLOC_ARENA_MAX2之后 RSS 降了一半。案例四ASan 自己触发 bad_alloc。有一次用 ASan 跑一个内存占用本来就大的程序结果程序抛bad_alloc查了半天发现是 ASan 的影子内存把地址空间吃掉了。换成 Valgrind 跑就正常。这个坑提醒我工具本身也会消耗资源排查时要意识到工具的存在。9. 预防胜于排查把泄漏挡在提交之前最后聊聊怎么少踩这些坑。排查是不得已预防才是正道。第一默认用智能指针。裸new只在极少数需要精细控制的场景用其余一律unique_ptr/shared_ptr。这一条能消灭大部分泄漏。第二CI 里跑 ASan。因为 ASan 快完全可以做到每次提交都跑一遍带 ASan 的单元测试。泄漏在提交阶段就被拦住比线上崩溃后再查成本低得多。第三监控 RSS 曲线。线上服务一定要有内存监控画出 RSS 随时间的变化。泄漏的特征是缓慢上涨早发现早处理别等到bad_alloc才动手。第四定期用 Valgrind 做深度体检。ASan 覆盖不到的角落比如某些库内部每隔一段时间用 Valgrind 跑一次完整场景作为补充。第五给自定义分配器加统计。如果你用了内存池一定要让它能报告当前占用、峰值、分配次数。这是工具盲区里唯一的眼睛。我个人在实际操作中的体会是内存问题排查最耗时的从来不是修复而是定位。工具能帮你把定位时间从几天压缩到几小时但前提是你得知道什么时候用哪个工具、报告怎么读、哪些情况工具帮不了你。把这套流程走熟之后再看到std::bad_alloc心里就有底了——它不再是一个吓人的崩溃而是一个有明确排查路径的普通问题。
返回列表