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 那套 DLT(Diagnostic 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判断。
这套流程我在后来的两个项目里又用了一遍,分别定位到一个文件描述符泄漏和一个线程栈泄漏,都挺管用。内存泄漏这东西,最怕的不是难,而是方向错。方向对了,剩下的就是耐心。