ARTICLE DETAIL

资讯详情

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

GODService内存泄漏排查:从RSS增长到strdup与libdlt调用约定

GODService内存泄漏排查:从RSS增长到strdup与libdlt调用约定 1. 从一次深夜告警说起GODService 到底出了什么问题凌晨两点被电话叫醒的滋味做后台服务的同行应该都懂。监控面板上GODService 这个常驻进程的 RSS 内存曲线像爬楼梯一样从早上启动时的 80MB 一路涨到 1.2GB而且完全没有回落的迹象。重启之后曲线归零跑上十几个小时又开始重复同样的轨迹。这不是那种一眼能看出来的崩溃进程活得好好的CPU 占用也不高但内存就是在缓慢地、坚定地往上爬——典型的内存泄漏。GODService 是我们内部一个负责设备状态采集与转发的常驻服务跑在 ARM64 的嵌入式环境里通过 QEMU 模拟平台做日常的联调和回归测试。它的核心逻辑不复杂接收底层上报的事件做一层格式转换再通过 libdlt 这个日志与追踪库把关键链路信息落盘。问题就出在这条看似简单的链路上。因为服务是 7×24 常驻的任何一次微小的泄漏在长时间运行后都会被放大成致命问题——内存耗尽、被系统 OOM Killer 干掉或者更糟触发分页缓冲池异常导致整个模拟环境卡死。这篇报告不是那种发现问题—贴个补丁—收工的流水账。我想把整个排查链路完整地摊开从怎么确认是泄漏而不是正常的内存增长到怎么用工具把嫌疑范围从整个进程缩小到某一行strdup调用再到为什么 libdlt 的某个使用姿势会埋下这个雷。中间踩过的坑、走过的弯路、以及几个反直觉的结论我都会写清楚。如果你也在维护常驻服务或者正在用 QEMU 跑 ARM64 的模拟环境做测试这篇内容应该能帮你少熬几个夜。需要先说明的是内存泄漏的排查思路是通用的但具体到 GODService 这个案例它的特殊性在于泄漏点藏在一个第三方库的调用约定里而不是我们自己的业务代码。这也是为什么初期用常规手段怎么查都查不到——因为你的直觉会一直引导你去怀疑自己写的代码。2. 先搞清楚是不是真泄漏RSS 增长不等于内存泄漏很多人一看到进程内存涨就喊泄漏其实这是个误区。在动手改代码之前必须先做一个判断这到底是真泄漏分配了内存但永远不释放还是假泄漏内存被缓存或内存池持有但逻辑上可回收又或者是正常的内存增长比如服务启动后逐步加载缓存最终会稳定在一个水位。2.1 用三组数据区分真泄漏和内存水位我的判断方法很朴素就是看三条曲线的形态真泄漏内存曲线呈单调上升且斜率基本恒定。重启后归零运行时间越长越高没有平台期。GODService 就是这种每小时稳定涨 40MB 左右非常线性。内存水位曲线先快速上升然后趋于平缓在一个区间内小幅波动。这是正常的比如连接池、缓存预热。假泄漏曲线呈锯齿状涨上去又掉下来只是峰值在缓慢抬高。这通常是分配器如 glibc 的 malloc没有及时把空闲内存归还给操作系统属于看着吓人但无害。为了拿到可靠数据我写了个简单的采样脚本每 30 秒记录一次/proc/pid/status里的VmRSS同时记录VmSize和VmData。这里有个细节只看 RSS 容易被误导因为 RSS 包含了共享库和文件映射。真正反映堆内存的是VmData它对应进程的数据段堆分配基本都算在这里。#!/bin/bash PID$(pgrep -f GODService) while true; do echo $(date %s) $(grep -E VmRSS|VmData|VmSize /proc/$PID/status | tr \n ) sleep 30 done跑了一晚上VmData从 60MB 涨到 900MB斜率稳定基本可以定性为真泄漏。到这一步结论是有内存被分配后从未释放。2.2 为什么 QEMU 模拟环境会让问题更隐蔽这里要单独说一下 QEMU 模拟 ARM64 这个背景。我们在 x86 的开发机上用 QEMU 跑 ARM64 的 Alpine Linux 来做联调GODService 就跑在这个模拟环境里。QEMU 的用户态模拟qemu-aarch64本身对内存的管理和真实硬件有差异尤其是**分页缓冲池Paged Pool和非分页缓冲池Non-Paged Pool**的行为在模拟层和宿主层之间会有一层映射。实际表现就是GODService 在 QEMU 里泄漏的速度比在真实 ARM64 板子上要快而且宿主机的分页缓冲池也会跟着涨。这曾经让我一度怀疑是 QEMU 本身的问题甚至去查了 Windows 11 下分页缓冲池泄漏的已知案例。后来才确认QEMU 只是放大器不是根因——它把泄漏暴露得更明显但泄漏本身在真实硬件上同样存在只是慢一些、不容易被发现。提示在模拟环境里排查内存问题一定要先确认泄漏是否在真实环境复现。如果只在 QEMU 里出现优先怀疑模拟层如果两边都有那就是业务代码或依赖库的问题。GODService 属于后者。3. 把嫌疑范围从整个进程缩小到一行 strdup定性之后接下来就是定位。这一步是整个排查里最耗时的也是最容易走弯路的。我前后试了四种手段前三种都没直接命中最后靠第四种才锁定。3.1 第一轮Valgrind 跑不动换 ASan 也没跑起来第一反应当然是上 Valgrind。结果很尴尬GODService 是 ARM64 的二进制跑在 QEMU 用户态模拟里Valgrind 对交叉架构的支持很差直接报无法识别指令集。换成 AddressSanitizer 重新编译倒是能跑但 ASan 需要进程退出时才能输出完整的泄漏报告而 GODService 是常驻服务正常情况根本不会退出。我试着让它跑一段时间后发信号触发退出结果 ASan 报了一堆still reachable噪音太大真正的问题被淹没了。这里有个经验ASan 更适合短生命周期的进程或单元测试对常驻服务的在线泄漏排查不太友好。如果你的服务能拆出可独立运行的模块把可疑模块单独跑 ASan 是最高效的。3.2 第二轮mtrace 和 malloc hook 的局限退而求其次我用mtrace和自定义的 malloc/free hook 来统计分配和释放的配对情况。思路是如果某个调用点的分配次数远大于释放次数那它就是嫌疑点。// 简化的 malloc hook 思路 static void* (*real_malloc)(size_t) NULL; void* malloc(size_t size) { if (!real_malloc) real_malloc dlsym(RTLD_NEXT, malloc); void* p real_malloc(size); record_alloc(p, size, __builtin_return_address(0)); return p; }这个方法确实能统计出净分配最多的调用栈但它有个致命问题它只能看到 malloc/free看不到 strdup。而 strdup 内部虽然调用了 malloc但返回地址会被内联和优化打乱hook 拿到的调用栈经常是错的。我盯着统计结果看了半天净分配最高的居然是 libdlt 内部的缓冲区但那其实是正常的内存池行为不是泄漏。3.3 第三轮盯住 libdlt但方向一度跑偏因为统计结果里 libdlt 的分配量最大我一度把矛头对准了它。libdlt 是 GENIVI 那套 DLTDiagnostic Log and Trace的实现负责日志的采集、缓冲和落盘。它的设计里有一个环形缓冲区会预先分配一大块内存这本身不是泄漏。我花了整整一天去读 libdlt 的源码确认它的缓冲区管理逻辑没问题。但就在读源码的过程中我注意到一个细节libdlt 的dlt_log_string系列接口在某些版本里会对传入的字符串做strdup然后把副本挂到内部的消息队列上等异步线程消费完再释放。如果消息队列因为某种原因积压或者消费失败这些 strdup 出来的副本就永远不会被释放。这个发现让我把方向从libdlt 本身泄漏转向了我们调用 libdlt 的方式导致它泄漏。3.4 第四轮用 gdb 抓堆快照锁定 strdup 调用点方向对了之后定位就快了。我用 gdb attach 到进程定期 dump 堆内存的分配情况。具体做法是结合 glibc 的malloc_info和 gdb 的call命令gdb -p $(pgrep -f GODService) (gdb) call malloc_info(0, fopen(/tmp/heap1.xml, w)) # 等待一段时间 (gdb) call malloc_info(0, fopen(/tmp/heap2.xml, w))对比两份堆快照找出增长最快的分配块。同时用info proc mappings确认堆的地址范围再配合x/命令查看可疑地址附近的内容——如果里面是明文的日志字符串那基本就实锤了。最终定位到GODService 在事件转发的热路径上对每一条事件都调用了 libdlt 的字符串日志接口而传入的字符串是我们自己strdup出来的。问题在于我们 strdup 之后libdlt 内部又 strdup 了一次而我们只释放了自己那一份libdlt 内部那一份在队列积压时被泄漏了。4. strdup 与 libdlt 的调用约定泄漏的真正根因定位到 strdup 之后还得把为什么讲透。不然改完代码下次换个场景还会踩同样的坑。4.1 strdup 的语义陷阱谁分配谁释放strdup的语义非常明确它分配一块新内存把源字符串拷贝进去返回新内存的指针。调用者负责释放这块内存。这是 POSIX 标准写死的约定。char* copy strdup(original); // ... 使用 copy free(copy); // 必须由调用者释放问题在于当 strdup 发生在库的内部时这个约定就变得模糊了。库的文档如果没写清楚我会不会拷贝你的字符串调用者就很容易做出错误假设。GODService 的代码里开发者假设 libdlt 会直接持有传入的指针不拷贝所以自己 strdup 了一份保证生命周期用完就 free 了。但实际上 libdlt 又拷贝了一份这一份的生命周期由库管理——当库的异步消费线程因为队列满而丢弃消息时这份拷贝就泄漏了。4.2 为什么队列积压会导致泄漏libdlt 的消息队列是有容量上限的。当上游事件产生速度超过下游落盘速度时队列会满。此时库的行为有两种可能阻塞等待调用线程被挂起直到队列有空位。这会导致服务卡顿但不泄漏。丢弃消息直接丢掉新消息或旧消息保证不阻塞。如果丢弃时没有释放已经 strdup 的副本就泄漏了。GODService 遇到的是第二种。而且更隐蔽的是队列积压不是持续发生的而是间歇性的——只在事件突发时出现。这就解释了为什么泄漏曲线是线性的平均下来每次突发都会泄漏一批字符串累积起来就是稳定的增长。我用一个简单的实验验证了这个推断人为制造事件突发一次性灌入 10 万条事件然后观察内存。果然内存瞬间跳涨而且这部分内存再也没回来。而正常速率下内存增长就慢得多。4.3 一张表看清三种调用姿势的对错为了把这个问题讲清楚我整理了一个对照表把常见的字符串传递姿势和它们的后果列出来调用姿势调用者是否 strdup库是否 strdup释放责任风险传栈上字符串否否无库异步使用时栈已失效野指针传栈上字符串否是库安全推荐传堆字符串是否调用者安全但需保证库同步消费传堆字符串是是双方各一份库那份易泄漏本案例传静态字符串否否无安全但无法动态生成GODService 踩的是第四行。正确的做法应该是明确约定由谁负责生命周期并且只 strdup 一次。要么调用者 strdup、库不拷贝但要求同步消费要么调用者不 strdup、库负责拷贝。两边都拷贝就是双份内存加双份释放责任极易出错。5. 修复方案与验证从止血到根治找到根因之后修复其实不复杂难的是怎么改才不会再犯。我分了三个层次来做。5.1 止血去掉多余的 strdup最直接的修复是把 GODService 里那次多余的 strdup 去掉改为传入栈上或静态的字符串让 libdlt 自己去拷贝和管理生命周期。// 修复前双重 strdup char* msg strdup(event_str); dlt_log_string(ctx, msg); // libdlt 内部又 strdup 一次 free(msg); // 只释放了自己这份 // 修复后只让 libdlt 拷贝 dlt_log_string(ctx, event_str); // event_str 是栈上或静态的libdlt 负责拷贝这个改动上线后内存曲线立刻变平了。跑满 24 小时VmData稳定在 70MB 左右不再增长。止血成功。但这里有个必须注意的点去掉自己的 strdup 之后传入的字符串必须在 libdlt 完成拷贝之前保持有效。如果 libdlt 是同步拷贝调用返回前就拷完那传栈上字符串没问题如果它是异步拷贝先入队稍后才拷那传栈上字符串就会出野指针。我专门去确认了 libdlt 的实现它是在dlt_log_string调用内部同步完成 strdup 的所以传栈上字符串是安全的。这个确认步骤不能省否则止血会变成引入新 bug。5.2 加固给日志调用加背压和限流止血只是解决了当前这个点。但 GODService 的架构里事件产生速度超过落盘速度这个根本矛盾还在。如果哪天 libdlt 的队列又满了即使不泄漏也可能丢日志。所以我加了一层背压机制在事件转发和日志调用之间加一个有界队列队列满时阻塞生产者而不是丢弃。给日志调用加限流同一类事件在单位时间内最多记录 N 条超出部分做聚合统计。// 简化的限流逻辑 if (rate_limiter_allow(limiter, event_type)) { dlt_log_string(ctx, event_str); } else { dropped_count[event_type]; }这样既避免了队列积压导致的泄漏风险也保证了关键日志不丢。实测下来突发场景下服务不再卡顿内存也稳。5.3 验证三种场景的回归测试修复不能只看跑一晚上没事。我设计了三个场景做回归稳态场景正常速率跑 24 小时确认内存曲线平直。突发场景一次性灌入 10 万条事件确认内存跳涨后能回落因为限流和背压不再无限积压。异常场景模拟落盘失败磁盘满确认队列满时生产者被正确阻塞且内存不泄漏。三个场景全部通过后才敢把修复推到生产。这里有个小技巧验证内存泄漏最好用VmData而不是 RSS因为 RSS 受页缓存和共享库影响容易误判。6. 排查内存泄漏时我踩过的坑和总结的套路这一节不讲具体代码讲方法论。因为 GODService 这个案例里真正值钱的不是那个 strdup而是怎么在信息不足的情况下一步步逼近真相。6.1 别一上来就怀疑自己的代码这是最大的坑。我前三天一直在翻 GODService 自己的业务代码把所有 malloc/free 都过了一遍结果一无所获。因为泄漏根本不在业务代码里而在调用第三方库的姿势上。后来我调整了策略先确认泄漏发生在哪个模块的边界上再决定往哪个方向深挖。具体做法是给不同模块的分配打标签看净增长集中在哪个标签上。6.2 工具是辅助理解调用约定才是关键Valgrind、ASan、mtrace、gdb 这些工具我都用了但没有一个能直接告诉我答案。真正让我锁定问题的是读 libdlt 的源码理解它对字符串生命周期的约定。工具能告诉你哪里在涨但为什么涨必须靠对代码和约定的理解。所以我的建议是工具用来缩小范围源码用来确认根因两者缺一不可。6.3 模拟环境是双刃剑QEMU 模拟 ARM64 让我们的联调效率高了很多不用每次都烧板子。但它也带来了干扰分页缓冲池的异常、模拟层的额外开销都会让内存问题看起来比实际严重。我的经验是在模拟环境发现问题后一定要在真实环境复现一次确认问题的性质。如果只在模拟环境出现那大概率是模拟层的问题如果两边都有才是业务问题。6.4 一个可复用的排查清单最后把我总结的排查清单分享出来遇到常驻服务内存泄漏时可以照着走定性采样VmData确认是真泄漏还是内存水位。缩小范围用 malloc hook 或堆快照找出净分配增长最快的模块。读源码针对嫌疑模块理解它的内存管理约定特别是字符串和缓冲区的生命周期。验证假设人为制造边界条件如队列满、突发流量看泄漏是否加剧。修复优先去掉多余的分配其次加背压和限流。回归稳态、突发、异常三个场景都要测用VmData判断。这套流程我在后来的两个项目里又用了一遍分别定位到一个文件描述符泄漏和一个线程栈泄漏都挺管用。内存泄漏这东西最怕的不是难而是方向错。方向对了剩下的就是耐心。
返回列表