C++内存泄漏排查实战:从Valgrind到ASan的工具选择与流程 从线上事故说起内存泄漏排查该怎么入手C 项目跑久了内存悄悄涨最后被运维告警打断这种事做过服务端的兄弟多少都遇到过。我之前接手某一下单服务时它的内存占用像温水煮青蛙一样两个星期稳定涨 30%重启就好跑一阵又不行。这种问题最难受的地方在于不崩溃、不报错、日志全正常但内存曲线就是止不住地往上走。查到最后根因就是一个回调里忘记释放的容器节点。排查过程前后折腾了一周工具换了好几轮最后才整理出一套相对顺手的 C 内存泄漏检测流程。这篇文章就是把这一套流程沉淀下来工具怎么选、参数怎么配、报告怎么解读、坑在哪里尽量一次说透。内容覆盖 Linux 和 Windows 两个常见开发环境适合正在跟内存问题死磕的服务端开发、客户端开发也适合刚接触 C 内存调试的同学做方法参考。1. 内存泄漏为什么难查先搞清楚泄漏的三副面孔很多人一上来就开工具结果被一堆输出淹没半天定位不到问题。我建议先在心里给内存泄漏分个类因为不同类型的泄漏检测工具的选择和流程差异很大。别急这不是理论课这三类是我在实际项目里真真切切都遇到过的。1.1 堆内存泄漏最常见也最好查的类型堆内存泄漏就是 new/malloc 出来的内存没有对应的 delete/free。这类泄漏最容易被工具捕获也是本文后面讲的所有工具的主战场。这种泄漏的特征是每次执行某个操作内存就涨一小块涨到一定程度就不涨了因为程序里的触发路径可能已经跑完一轮。比如某个接口每调用一次就泄漏 100 字节调用一万次就是 1MB这个过程不会崩溃只有时间长了才看得出来。定位这类泄漏的思路很朴素找出所有 new 对应的 delete、malloc 对应的 free然后把没有配对的找出来。但代码量大起来人工一对一是完全不现实的必须借助工具。Valgrind、AddressSanitizer、Visual Leak Detector 这类工具做的事情本质上是一样的拦截内存分配和释放的调用记录分配时的调用栈程序结束时检查哪些分配没有释放。这就是“检测流程”的第一块基石。注意堆泄漏里还有一种特殊情况叫“隐式泄漏”——对象被放进了某个全局容器但容器在程序退出前一直持有它。这类问题严格来说不算资源泄漏因为内存还能被访问但它确实永远用不到工具往往不报需要靠代码审查或内存快照来排查。后面第 4 节我会专门讲。1.2 资源泄漏文件句柄、Socket、数据库连接资源泄漏不是堆内存泄漏但最终表现出来的症状和内存泄漏几乎一样程序内存持续上涨直到触发系统限制。原因在于操作系统对文件描述符、socket、线程、GDI 对象都有限额每打开一个资源就占一块内核内存不释放就一直累积。很多排查内存泄漏的兄弟查了半天 new/delete最后发现是句柄没关。典型的例子某个模块里有个读取配置的函数每次调用都 open 一个文件但异常分支里忘记 close。平时跑着没事一触发异常就漏一个句柄跑一周后文件描述符耗尽进程开始报“Too many open files”再往后连日志都写不进去。定位这类问题不能只盯着内存工具还得结合系统监控来看进程的 fd 数量、线程数量变化趋势。我习惯在压测的时候每隔几秒记一次/proc/pid/fd的数量如果这个数字持续增长基本就是资源泄漏直接去查所有 open/connect/socket 的调用点。1.3 第三方库与静态变量生命周期问题最隐蔽的一类这一类最坑因为代码不是你的逻辑有问题而是某个第三方库在内部缓存了数据或者说某个单例对象的析构时机不对导致退出时没有释放资源。我之前遇到过一个问题程序里用了一个开源的日志库日志库内部会维护一个全局的线程池每个线程分配了固定大小的栈空间。正常情况下线程池会在库卸载时清理但因为我们程序里有另一个静态对象在退出时还在写日志导致日志库的内部状态被延迟访问线程池没有正常回收于是每次正常退出时都报泄漏。这类问题靠常规检测工具很难干净定位因为工具会报出很多“仍在使用的内存”和“可能泄漏”的记录全是噪音。我的经验是用工具初步确认没有明显堆泄漏之后把怀疑重点转移到模块卸载顺序、静态对象析构顺序上必要时直接写脚本反复启停程序观察退出时的内存变化。这个思路比在代码里瞎猜要省时间得多。2. Linux 下的两条主流检测路线Valgrind 与 AddressSanitizer 实战Linux 是 C 服务端的主战场所以先从这里讲起。针对堆内存泄漏业内用得最多的是 Valgrind 和 AddressSanitizerASan。两条路线各有不可替代的场景最好都掌握。2.1 Valgrind不求最快但求最细Valgrind 的 memcheck 工具是内存泄漏检测的老牌武器。它模拟了一个 CPU在程序每次读取、写入、分配、释放内存时进行拦截和检查。这意味着不需要重新编译程序直接用valgrind --leak-checkfull ./your_program就能跑。用归用有三点必须提前知道。第一程序必须在 Valgrind 下跑完整个生命周期它靠的是程序退出时的堆快照来判断哪些内存没释放。如果你的程序是个常驻服务平时直接跑那得先让它正常启动、处理一批请求、然后在受控状态下退出。我一般准备一个专门的重放脚本让程序启动后自动跑一遍核心业务流程再自己退出专门用于 Valgrind 检测。第二会有 10 到 50 倍的性能损耗。这不是夸张memcheck 每执行一条指令都要查表做校验计算密集型的模块跑起来会慢到你怀疑人生。所以常规做法是不要把整个服务跑在 Valgrind 下做压测而是开一个测试实例用少量真实请求走一遍流程能触发泄漏路径就够了。第三要有 debug 符号。编译时务必加上-g最好还关掉优化用-O0否则报告里全是乱码地址和 unknown symbols等于白跑。有些老项目发布用的是 Release 配置我都是单独拉一条编译线出来专门做 with-debug-info 的检测版本。跑完的典型输出长这样12345 40 bytes in 1 blocks are definitely lost in loss record 1 of 3 12345 at 0x484586F: operator new(unsigned long) (vg_replace_malloc.cpp:342) 12345 by 0x4012A4: create_node(int) (leak_demo.cpp:15) 12345 by 0x4012E0: main (leak_demo.cpp:23)definitely lost是重点这表示这块内存确定没有任何指针指向它就是教科书式的泄漏。indirectly lost表示指针还在但指向它的父对象已经 lost 了比如一棵树的根节点丢了剩下子节点都是 indirectly lost。这两类都是实打实的泄漏。而possibly lost是“可能漏”有可能是内部指针运算导致的误报优先级可以往后放。Valgrind 的局限也很明显没法在 CI 流水线里大规模跑太慢了也查不了数组越界写导致的内存破坏——它主要管分配/释放的配对不管你的指针写到哪去了。所以生产节奏里Valgrind 适合在发版前做一次集中深度体检平时靠另一条更快的检测路子。2.2 AddressSanitizer编译期插桩的利器ASan 是编译器内置的内存错误检测工具GCC 和 Clang 都支持用起来只需两个编译选项g -fsanitizeaddress -g -O1 your_program.cpp -o your_program_asan它的原理不像 Valgrind 那样模拟 CPU而是在编译阶段把内存访问的检查指令直接插到你的代码里。程序运行时会维护一块“影子内存”用来标记真实内存区域的可访问状态每次读写指针前都查一次影子内存。这块影子内存的开销比 Valgrind 的模拟执行小得多运行只慢 2 倍左右内存占用则多出约 2 到 3 倍。常用运行参数是这几条export ASAN_OPTIONSdetect_leaks1:halt_on_error1:log_path./asan_report ./your_program_asandetect_leaks1是打开泄漏检测的开关ASan 的 LeakSanitizer 模块负责这块功能。halt_on_error1是遇到第一个错误就退出适合在 CI 里用。log_path指定报告输出路径不然报告和业务日志混在一起看起来非常费劲。还需要重点说明的是链接顺序。工程里如果用了动态库ASan 要求动态库也要用-fsanitizeaddress重新编译或者至少让主程序链接时带上-fsanitizeaddress。实际开发中我用 CMake 通常会这样处理add_compile_options(-fsanitizeaddress -fno-omit-frame-pointer -g) add_link_options(-fsanitizeaddress)-fno-omit-frame-pointer这个选项很关键它让编译器保留栈帧指针这样报告才能回溯出完整的调用栈。忘了加这个报告往往只能看到一层定位价值直接减半。ASan 输出 LeakSanitizer 报告时会明确告诉你泄漏发生在程序启动后的哪个阶段还能列出可疑的分配调用栈。比如我跑某图像处理 Demo 时LeakSanitizer 直接定位到一个边缘检测函数里申请了临时矩阵、在提前 return 的分支里没释放。这种“提前返回导致跳过释放”的漏法在代码审查里很难一眼看出来ASan 倒是十分钟就抓出来了。2.3 两条路线的取舍与配合我见过的很多团队只装一种工具Valgrind 或者 ASan结果遇到对方擅长的场景就抓瞎。其实这两者不是替代关系而是互补关系。对比维度Valgrind memcheckAddressSanitizer使用方式无需重新编译直接运行必须重新编译并链接 ASan 运行时运行开销慢 10~50 倍慢约 2 倍内存多 2~3 倍检测能力泄漏、非法读写、使用未初始化内存溢出、释放后使用、泄漏推荐场景发版前深度体检、无法重编的旧模块日常 Dev 自测、CI 回归、压测阶段我自己的流程是提交代码后开发机先跑 ASan 单元测试和集成测试高效快速每周或发版前在专门的检测环境上跑一次 Valgrind 全集覆盖那些 ASan 可能漏掉的使用未初始化内存的问题。两个工具的最佳实践均强调要有对应的回归用例否则就是“查一次好一次过一阵又犯”。3. Windows 平台与 VS 环境的检测方案VLD 和 CRT 调试堆很多做 Windows 客户端、游戏和工业软件的兄弟开发环境是 Visual Studio。Linux 那套 Valgrind 在 Windows 上不直接可用ASan 在 MSVC 上支持度也一般老项目往往还停在 VS2015 甚至更老。那 Windows 上有两样东西足够用了Visual Leak DetectorVLD和 Visual Studio 自带的 CRT 调试堆。3.1 Visual Leak Detector历史悠久的第三方检测器VLD 使用起来极其快捷只要做三步把 vld.h 头文件路径配进 include把 vld.lib 加进链接设置然后在代码里包含一次vld.h最好放在所有源文件共用的预编译头里。#include vld.h void create_leak() { int* p new int[16]; // 没有 delete[] }程序以 Debug 模式启动并正常退出时VLD 会在输出窗口打出完整的泄漏报告包括每个泄漏块的大小、分配时的调用栈、文件行号。VLD 的工作原理是重载了全局的 new/delete 和 malloc/free 操作符在分配时把调用栈记录下来退出前遍历堆块找没有释放的那些。它是基于 CRT 调试堆机制做增强的比系统自带的报告更直观。VLD 有一个使用细节容易踩坑它只在包含vld.h的翻译单元里生效。如果泄漏发生在某个动态库里而这个动态库没有包含 vld.hVLD 就看不到那边的情况。解决方法是保证所有与业务直接相关的源文件都走同一个预编译头并且把 VLD 的头文件放在最前面。我遇到过好几回“明明有泄漏但 VLD 不报”的情况最后都发现是某个 dll 自己管着内存没走主程序的 new/delete。3.2 CRT 调试堆系统内置也别小看如果不想引入任何第三方依赖Visual Studio 自带的 CRT 调试堆可以直接用主要靠几个函数。在 main 开头设置输出配置_CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE); _CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDOUT); _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);然后在程序退出点通常是 main 返回前调用_CrtDumpMemoryLeaks()输出窗口会显示类似这样的内容Detected memory leaks! Dumping objects - {123} normal block at 0x00BB9A20, 16 bytes long. Data: .... 01 00 00 00 02 00 00 00 ...花括号里的{123}是分配的序号这个序号非常有用。你可以用它来设置断点在_CrtDumpMemoryLeaks调用前加一行_CrtSetBreakAlloc(123);调试器会在第 123 次分配时自动中断这时候直接看调用栈就知道泄漏点在哪。这是 Windows 下最快的内存泄漏定位手法之一比对着报告猜要高效得多。需要提醒的是 CRT 调试堆会显著改变 new/delete 的行为。比如重载了全局 operator new 的项目在 Debug 模式下要小心跟 CRT 的_malloc_dbg冲突。我踩过这个坑表现是程序在退出时崩在堆清理阶段push 了很多版本才发现是重载 new 时没考虑 debug 堆帧头的问题。解决方法是让重载的 new 在_DEBUG宏下调用_malloc_dbg在其他情况下调用普通 malloc。3.3 Windows 特有资源泄漏GDI 句柄与 USER 对象Windows 客户端还有一类特有的“内存泄漏”本质上不是堆内存而是 GDI 句柄和 USER 对象泄漏。表现是程序内存占用不大但任务管理器里的“GDI 对象”数持续增长最后界面绘制异常或者创建窗口失败。排查这类问题不能用 VLD得靠系统监控写一个小脚本每隔几秒读取目标进程的 GDI 句柄数趋势上涨就可以确认泄漏。定位手段比较原始但有效屏蔽可疑的绘制代码块逐段二分。因为 GDI 泄漏通常发生在CreateBrush、CreatePen、CreateCompatibleDC这些调用上每创建一个都要成对地DeleteObject。我在代码里写过一个简单的调试封装宏定义LOG_GDI_COUNT()在关键绘制函数入口和出口处检查当前 GDI 对象数量数量不一致的函数就是泄漏点这个方法救过我很多次。4. 复现与回归如何构造稳定触发内存泄漏的用例工具选好了但很多人忽略了一步先构造一个能稳定触发问题的场景。没有复现路径再强的工具也是白搭有了复现路径定位通常只是时间问题。4.1 稳定复现的三要素我总结出三个要素一台干净环境、一个最小触发路径、一个可观察信号。干净环境指的是排除干扰。检测内存泄漏时最好关掉无关的后台模块和服务尤其不能在做 ASan 检测的进程里再挂一堆动态库插件。因为插件可能自身就带检测逻辑或者干扰编译插桩。最小触发路径是关键。不要直接在完整业务系统里跑工具那会输出大量与本问题无关的分配记录。正确姿势是写一个小的测试程序调用你怀疑的那个类、那个函数或那组交互逻辑循环执行几百上千次。循环次数怎么定我一般先跑 1000 次观察 RSSResident Set Size是否线性增长如果没涨就加到 10000 次再观察。可观察信号是判断问题是否发生的标准。最常用的是/proc/pid/status里的 VmRSS 变化或者 Windows 任务管理器里的内存列。用脚本记录连续 10 次采样只要整体趋势向上基本可以认定为泄漏。4.2 用“分配指纹法”缩小怀疑范围有时候泄漏发生在非常复杂的多层调用链里即使有了复现用例报告里的调用栈也指向一个通用函数比如std::string::_Copy。这时候我强烈推荐一个笨但有效的方法分配指纹法。原理很简单给目标类型加一个静态计数器构造时析构时--。如果程序在某个阶段之后这个计数不归零说明有对象没被销毁。class RequestContext { public: RequestContext() { live_count_; } ~RequestContext() { --live_count_; } static int live_count_; }; // 在测试循环前后打印 std::cout live contexts: RequestContext::live_count_ std::endl;这个方法看起来土但精准度极高。它不依赖任何外部工具运行开销几乎为零可以直接埋到生产代码里用条件编译控制对复杂项目的定位效率有奇效。4.3 把检测纳入 CI防止回归泄漏问题高发期有两个新功能开发阶段和第三方库升级阶段。新功能上线前做一次集中检测之后还要靠 CI 持续保护。我推荐在 CI 流水线里加一个“ASan 测试”任务编译时开-fsanitizeaddress跑已有的单元测试和少量集成测试遇到任何错误直接让流水线失败。要注意给这个任务单独分配一台构建机因为 ASan 版本的内存占用比普通版本高不少和普通构建抢同一台机器容易 OOM。在写回归用例时记得记录“基线值”修复前跑这套用例的泄漏报告有多长、涉及多少个字节修复后应为 0。我在实践中把报告文件存为 CI artifact每次改动后对比差异。这样以后谁再往那里塞一个泄漏报告对比会直接揪出来。5. 常见问题与排查技巧实录最后这部分是我这几年实际踩过的一些具体问题和对应的处理思路按“症状-根因-排查动作”的方式整理出来方便你以后翻出来对照。每个问题都是真实存在过的类型只是项目细节做了脱敏处理。5.1 典型问题一Valgrind 报告全是 unknown symbols症状报告里有大段的???看不到函数名只知道在哪块内存里丢的。根因程序没带调试符号或者用了动态库但动态库是 Release 编译版。Valgrind 解析不了符号自然显示不出函数。处理方式给整个构建链加上-g并且去掉链接器里的-sstrip。如果某个第三方库是只读的必须换用 ASan 的 LeakSanitizer因为它是在编译期做插桩即使库符号不全至少主程序里的调用栈是清晰的。5.2 典型问题二ASan 报“allocated by thread”但泄漏点看起来在正常清理逻辑里症状LeakSanitizer 报告显示某块内存在一个线程里分配在另一个线程里应该释放但没释放。根因跨线程的对象所有权转移没有处理好。常见于任务队列场景线程 A 创建任务对象塞进队列线程 B 取出来处理结果处理失败的路径上直接 return 了忘记 delete 任务对象。处理方式这种问题靠代码审查往往看不出得结合队列长度监控。我在任务入队和出队的地方各加一个计数器如果“入队次数 - 出队次数 - 已处理次数”不恒等于 0就是有任务对象卡在某个分支上。定位到这一步之后在可疑的 return 分支上打日志几轮迭代就能找到那个漏掉释放的路径。5.3 典型问题三全局容器导致程序退出时报告 massive leak症状Valgrind 或 ASan 报告里出现几千上万条“still reachable”全是同一个全局 map 或者 vector 里的元素。根因这些对象不是真正的泄漏程序如果继续跑还能访问它们只是退出时没人清理。全局对象的析构顺序晚于泄漏检测函数或者析构函数本来就没有清空容器的逻辑。处理方式这类问题优先级要降低但也不是完全不管。如果容器元素在退出时的清理逻辑会造成很大的 CPU 尖峰或触发其它问题建议显式写一个shutdown()函数在退出前清空容器。如果容器里的元素确实是要存活到程序最后的那就在工具配置里设置忽略它比如 Valgrind 的--show-reachableno就不会把 still reachable 当成错误。5.4 一个真实的排查案例从 8GB 内存崩溃到定位到一行漏删有一次我接手一个网络服务崩溃前内存涨到 8GB明显是快速泄漏而不是缓慢增长。这种快速度泄漏反而好定位因为它一定是每请求都漏而不是只在异常条件下漏。我先用 ASan 跑了一轮单请求压力测试LeakSanitizer 直接报出一个 64 字节的块在协议解析函数里泄漏。看调用栈函数里 new 了一个字节流缓冲正常情况下应该用完释放但代码里有个性能优化分支走那个分支时把释放逻辑跳过去了。修复就是把释放放进统一的清理路径。那次之后我给自己定了一个规矩每个函数如果有提前 return 的分支就检查这些分支前面有没有申请资源。这个检查现在已经成为我的 code review checklist 常驻项。5.5 排查流程速查表症状优先排查动作辅助工具备注内存缓慢线性增长检查 fd/socket/句柄数量排除资源泄漏/proc/pid/fd、任务管理器 GDI 列先排除资源类再做堆检测内存快速暴涨ASan 跑单请求/单次调用复现LeakSanitizer通常能在 30 分钟内定位内存只涨不降但工具不报分配指纹法检查对象生命周期静态计数器埋点往往是全局容器或单例缓存退出时大段 still reachable判断是否为真实泄漏Valgrind show-reachableno全局对象清理顺序问题报告有栈但符号不全重新编译加-gCMake 加 debug 配置动态库也要带符号这张表是我给团队内部培训时画的基本覆盖了我遇到过的所有普通场景。真正的疑难杂症比如堆内存被越界写破坏导致释放时崩溃那就不是泄漏检测流程能解决的了还得靠 ASan 的 address error 检测功能去抓越界点那就是另一个主题了。6. 将检测流程固化到日常开发节奏里工具和方法再好如果只用一次就扔下次问题来了还是手忙脚乱。我建议把整个检测流程固化成日常开发的一部分而不是应急时才想起来。这里分享一套我验证过可持续运转的节奏。日常开发阶段ASan 编译版本要成为开发机上的默认测试版本之一。不要嫌它慢、嫌它耗内存开发机性能是够的。最合理的做法是每次本地提交前跑一遍 ASan 的测试集十几分钟之内能得到结果。这跑的不是全量业务而是单元测试和模块级集成测试。如果某天提交直接让 ASan 爆出一堆错误说明这次改动里有问题当场解决远比以后回溯要省力。代码审查阶段内存生命周期要作为检查项写进 checklist。重点看四种模式裸指针成员变量在析构函数里有没有释放异常处理路径里有没有跳过了 delete对象有没有被放进容器后就再也不取出来跨线程传递的指针有没有明确的所有权标记。这几种模式我几乎在每次大项目里都能逮到几个。发版前阶段做一次 Valgrind 全集检测。这个环节慢但对未初始化内存、复杂继承下的成员释放遗漏很敏感。我一般是挑一个专门的 Release candidate 分支编一个 debug 符号版在独立环境上跑核心场景回归一个晚上能完成。线上问题出现后的应急阶段先看监控曲线判断是缓慢泄漏还是快速泄漏再根据曲线斜率结合发布历史找出对应的时间窗口和最近的代码改动然后用最小复现用例在手边的检测环境里跑 ASan。大多数情况下这样一轮下来能锁定的怀疑范围已经从整个服务缩小到几个函数了。我个人在实际排查里体会最深的一句话工具解决的是“哪里漏了”的问题而流程解决的是“怎么持续让它不漏”的问题。很多团队把内存泄漏检测写成一次性的“救火任务”没有沉淀成可重复执行的机制结果每次靠老员工用经验硬碰运气。当你把 Valgrind 或 ASan 常态化跑起来配上自动化的回归用例和监控指标很多泄漏在到达用户之前就会被拦截掉省下的时间和精力远比搭这套流程的成本多。最后再分享一个小技巧在你把 Valgrind 和 ASan 都跑到零报告之后记得跑一次-O2 -DNDEBUG的版本再测一轮。因为有些问题只在优化和宏裁剪下才会暴露比如成员函数里用assert包着的释放逻辑在 Release 下整个消失。检测不出问题不会是终点能经受住优化版考验的代码才是真的干净。