1. 从一次线上告警说起:GODService 到底出了什么问题
GODService 是我们内部一个常驻型的后台服务,跑在 Windows 平台上,负责设备状态采集、指令下发和日志回传这一整套链路。它本身不算大,代码量也就几万行,但胜在稳定,上线两年多基本没怎么动过。直到上个月,运维那边开始频繁报同一个问题:机器跑个三五天,任务管理器里的分页缓冲池和非分页缓冲池就一路往上涨,最后要么服务自己卡死,要么直接把整台机器的内存吃满,只能重启了事。
一开始大家的第一反应都是“业务代码里有泄漏”,毕竟这是最直觉的判断。但排查了一圈下来,业务层的对象创建释放看着都挺正常,用 Performance Monitor 抓Pool Nonpaged Bytes和Pool Paged Bytes两个计数器,发现涨得最凶的恰恰是内核池这一块,而不是普通的进程私有内存。这就有点意思了——内核池的泄漏,通常意味着有驱动或者底层库在反复申请没释放,跟业务逻辑关系不大。
顺着这条线往下挖,最后定位到了一个叫libdlt的第三方日志库。这个库负责把 GODService 的运行日志落盘,内部用了strdup来复制字符串。问题就出在这儿:strdup 每次调用都会 malloc 一块内存,而 libdlt 在某些分支里复制完字符串之后,压根没调 free。更麻烦的是,这个库还做了一层符号拦截,把系统里原本的 strdup 给 hook 掉了,导致我们自己的代码里哪怕老老实实配对 free,走的也是它那套有问题的实现。
这篇文章就把整个排查和修复过程完整复盘一遍。如果你也在 Windows 上做后台服务,尤其是碰到过分页缓冲池和非分页缓冲池内存泄漏这类问题,或者你的项目里也用了带符号拦截的第三方库,那这篇内容应该能帮你少走不少弯路。我会从问题现象、定位思路、libdlt 和 strdup 的坑、符号拦截的机制,一直讲到最终的修复方案和验证方法,尽量把每一步的“为什么”都说清楚。
2. 内存泄漏的定位思路:别一上来就盯着业务代码
2.1 先分清是私有内存泄漏还是内核池泄漏
很多人一看到内存涨,第一反应就是打开任务管理器看进程的“内存”那一列。但那个数字其实只是进程的工作集(Working Set),它受系统调度影响很大,涨涨跌跌很正常,不能直接当成泄漏的证据。真正要看的,是私有字节(Private Bytes)和内核池这两块。
我一般的做法是分三步走:
- 第一步,用 Performance Monitor 同时抓Process\Private Bytes、Process\Handle Count、Memory\Pool Paged Bytes、Memory\Pool Nonpaged Bytes这四个计数器,采样间隔设成 10 秒,跑上至少两三个小时。
- 第二步,看趋势。如果 Private Bytes 稳定但 Pool 两个计数器持续上涨,那基本可以判定是内核态或者底层库的问题,业务代码的嫌疑可以往后放。
- 第三步,如果 Private Bytes 也在涨,那就得结合 Handle Count 一起看。句柄数同步上涨,往往是资源没释放;句柄稳定但内存涨,才更可能是纯内存泄漏。
这次 GODService 的情况很典型:Private Bytes 涨得不算夸张,但Pool Nonpaged Bytes几乎是一条斜向上的直线,几天下来涨了好几百 MB。非分页池是内核里不能被换出到磁盘的内存,它涨成这样,说明有内核对象或者驱动在反复申请没释放,优先级一下子就排到最前面了。
提示:分页池和非分页池的区别,可以简单理解成“能不能被换到硬盘”。非分页池必须常驻物理内存,所以它泄漏的后果比普通内存泄漏严重得多,系统会更快进入不稳定状态。
2.2 用 Poolmon 锁定泄漏的 Tag
确定了是内核池的问题,下一步就是找出到底是哪个组件在申请内存。Windows 自带一个叫Poolmon的工具(在 WDK 里),它能按 Tag 统计内核池的分配和释放情况。每个内核池分配都会带一个 4 字节的 Tag,驱动和系统组件一般都有自己的固定 Tag。
操作上,先跑poolmon -b按字节数排序,然后隔一段时间再跑一次,对比两次的差值。哪个 Tag 的差值持续为正,哪个就是嫌疑对象。我们当时跑下来,有一个 Tag 的计数一直在涨,查了一下对应的驱动模块,正好指向 libdlt 加载的那个动态库。
这里有个小坑:Poolmon 需要管理员权限,而且不同 Windows 版本上 Tag 的命名规则略有差异。如果拿到的 Tag 查不到对应模块,可以试试用findstr在驱动目录里搜,或者直接看 libdlt 的文档里有没有声明它用的 Tag。我们那次是直接在 libdlt 的源码里搜到了它注册的 Tag 名,省了不少事。
2.3 从 Tag 反查到 libdlt 的调用链
锁定 Tag 之后,接下来就是确认调用链。因为 libdlt 是被 GODService 静态加载的,所以最直接的办法是在它申请内存的那几个函数上下断点,看是谁调进来的。我们用 WinDbg 附加到进程,在 libdlt 内部疑似泄漏的函数上打了断点,然后让服务跑一会儿,断点命中之后看调用栈,很快就定位到了日志落盘那条路径。
具体来说,libdlt 在写日志的时候,会先把日志内容用 strdup 复制一份,然后交给一个异步队列去处理。问题在于,如果队列满了或者写盘失败,它会走一个错误分支,在这个分支里直接 return,把之前 strdup 出来的那块内存给忘了。这个分支平时很少走到,所以测试环境一直没暴露,只有线上高负载、磁盘 IO 偶尔抖动的时候才会触发。
3. libdlt 与 strdup:一个被忽视的经典陷阱
3.1 strdup 到底做了什么,为什么容易漏 free
strdup 这个函数,标准定义是“复制一个字符串并返回指向新内存的指针”。它内部等价于先 malloc 一段长度为 strlen(s)+1 的内存,然后把原字符串拷进去,最后返回新指针。关键点在于:这块内存是调用方负责释放的,strdup 自己不会帮你 free。
这就带来一个很常见的误区:很多人把 strdup 当成一个“纯函数”来用,觉得它只是复制字符串,用完就不管了。实际上它每次调用都是一次堆分配,如果调用频率高、又没配对 free,泄漏速度会非常快。libdlt 的问题就在这儿——它在错误分支里漏掉了 free,而这个分支在特定条件下会被反复触发。
我见过不少项目在代码审查时对 malloc/free 很敏感,但对 strdup 却放松了警惕,觉得它“看起来不像分配内存”。这是个很危险的直觉。只要函数名里带 dup、copy、clone 这类字眼,基本都要留个心眼,确认返回的内存谁来释放。
3.2 libdlt 的错误分支为什么这么隐蔽
libdlt 的日志写入流程大致是这样的:先判断队列是否可写,可写就把日志内容 strdup 一份塞进队列,然后唤醒写盘线程;如果队列满了,就进入一个降级分支,直接尝试同步写盘。问题出在同步写盘失败的时候——它会打印一条错误日志,然后直接返回,而之前 strdup 的那块内存既没入队也没释放。
这个分支隐蔽在几个地方:
- 触发条件苛刻,需要队列满 + 磁盘写失败同时发生,测试环境很难复现。
- 错误日志本身也走 libdlt,形成了一种“错误处理里再出错”的嵌套,容易让人看花眼。
- 代码里没有明显的 goto 或者多层嵌套,就是一条直线走下来,肉眼审查很容易滑过去。
我们后来是靠着在 strdup 的返回值上做标记,配合内存分配追踪工具,才把这个分支揪出来的。如果你也在排查类似的泄漏,建议对 strdup 的每一次调用都做配对检查,尤其是那些带错误处理的路径。
3.3 符号拦截:strdup 被 hook 之后发生了什么
libdlt 为了统一管理内存,做了一层符号拦截,把系统里的 strdup 替换成了自己的实现。它的本意是好的——想在所有 strdup 调用上加上统计和追踪,方便定位问题。但问题在于,它自己的实现里同样有那个漏 free 的分支,而且因为拦截是全局的,连我们业务代码里正常的 strdup/free 配对也被它接管了。
符号拦截在 Windows 上一般通过修改导入地址表(IAT)或者用 Detours 这类库来实现。它的风险在于:
- 拦截之后,调用方看到的还是 strdup 这个名字,但实际执行的是另一套逻辑,行为可能和标准实现有差异。
- 如果拦截实现本身有 bug,影响面是全局的,所有调用 strdup 的模块都会中招。
- 调试的时候容易懵,因为断点打在系统 strdup 上可能根本不命中,实际走的是拦截后的版本。
我们当时的应对办法是,先在 libdlt 的拦截实现里加上分配计数和释放计数,跑一段时间看差值。差值持续为正,就说明确实有漏释放。然后再结合调用栈,定位到具体是哪个分支漏了。
4. 修复方案:从临时规避到彻底解决
4.1 临时方案:先止血,把泄漏速度降下来
线上问题不能等,所以第一步是临时规避。我们做了两件事:
- 把 libdlt 的日志级别从 DEBUG 调到 WARN,减少 strdup 的调用频率。日志少了,泄漏速度自然就慢了,能给彻底修复争取时间。
- 在 GODService 的启动脚本里加了一个定时重启逻辑,比如每 24 小时重启一次服务。这虽然治标不治本,但能保证服务在修复上线前不至于把机器拖垮。
这两个措施都是权宜之计,但实际效果还不错。调整日志级别之后,非分页池的增长速度大概降到了原来的三分之一,配合定时重启,基本能稳住。
注意:临时方案一定要有明确的退出条件,比如“修复版本上线后立即移除”。否则很容易变成技术债,后面没人记得清理。
4.2 彻底修复:给 libdlt 打补丁,补上漏掉的 free
临时方案只能撑一阵子,真正的修复还是得改 libdlt 的代码。因为 libdlt 是第三方库,我们没法直接改它的源码,所以走了两条路:
- 第一条,联系库的维护方,把我们的分析结果和复现步骤发过去,请他们出官方补丁。这条路比较慢,但最正规。
- 第二条,在本地做一个 patch,用 LD_PRELOAD 类似的机制(Windows 上可以用 Detours 或者直接改 IAT)把 libdlt 里那个有问题的函数替换成我们自己的实现。这个实现里,strdup 之后无论走哪个分支,都保证 free 被调用。
我们最终采用的是第二条路,因为等不起。具体做法是写一个 wrapper 函数,内部先调用原始的 strdup,然后用一个 RAII 风格的 guard 对象管理这块内存,确保函数退出时一定会释放。这样即使中间有 return,guard 的析构也会兜底。
4.3 修复代码示例与关键点说明
下面是我们 wrapper 的核心逻辑,用 C++ 写的,简化之后大概是这样:
char* safe_strdup(const char* src) { if (src == nullptr) return nullptr; char* copy = original_strdup(src); if (copy == nullptr) return nullptr; // 用 unique_ptr 管理,确保异常或提前 return 时也能释放 std::unique_ptr<char, decltype(&free)> guard(copy, &free); // 这里做原本 libdlt 要做的入队、写盘等操作 bool ok = enqueue_or_write(copy); if (ok) { // 成功入队,所有权转移给队列,释放 guard 的持有 guard.release(); } // 如果失败,guard 析构时自动 free return ok ? copy : nullptr; }关键点有三个:
- 用
unique_ptr加自定义 deleter 来管理 strdup 返回的内存,这样无论中间怎么 return,析构都会执行。 - 成功入队时调用
release()把所有权交出去,避免双重释放。 - 失败路径不需要显式写 free,guard 会兜底,代码更干净,也不容易再漏。
这个模式其实可以推广到所有“申请资源后可能提前返回”的场景。核心思想就是用 RAII 把资源的生命周期绑定到作用域上,而不是靠人肉在每个分支里写释放代码。
4.4 符号拦截的收尾处理
补丁打完之后,还有一个收尾问题:libdlt 的符号拦截还在,它依然会接管全局的 strdup。我们的 wrapper 是在拦截之后再加一层,所以调用链变成了“业务代码 -> libdlt 拦截 -> 我们的 wrapper -> 原始 strdup”。这能工作,但多了一层,性能上有轻微损耗。
更干净的做法是,在 libdlt 的拦截实现里直接修掉那个 bug,然后去掉我们的 wrapper。我们后来跟库的维护方同步了补丁,他们在新版本里修了这个问题,我们升级之后就把 wrapper 撤了。所以如果你也遇到类似情况,建议优先推动上游修复,本地 wrapper 只作为过渡。
5. 验证与回归:怎么确认泄漏真的被修好了
5.1 用 Poolmon 和 Performance Monitor 做长稳测试
修复上线之后,不能只看“感觉好像不涨了”,得有数据支撑。我们的验证方法是:
- 用 Poolmon 持续监控那个泄漏 Tag 的计数,跑 72 小时,看差值是否稳定在 0 附近。
- 用 Performance Monitor 抓 Pool Nonpaged Bytes 和 Pool Paged Bytes,确认两条曲线都趋于平稳。
- 同时观察 Private Bytes 和 Handle Count,确保没有引入新的泄漏点。
实测下来,修复版本跑满 72 小时之后,非分页池的波动范围在正负几 MB 以内,基本可以认为泄漏被堵住了。对比修复前动辄几百 MB 的增长,效果非常明显。
5.2 构造边界条件,复现原来的错误分支
光跑正常流程还不够,因为原来的 bug 是在错误分支里触发的。所以我们专门构造了边界条件:
- 把日志队列的容量调到很小,强制它频繁进入降级分支。
- 用工具模拟磁盘写失败,比如把日志目录设成只读,或者用磁盘限速工具制造 IO 抖动。
- 在这些条件下跑上几个小时,再用 Poolmon 看计数。
这一步很关键,因为很多泄漏修复在正常路径下看不出问题,只有把原来的触发条件复现出来,才能确认补丁真的生效了。我们当时跑下来,修复版本在同样的边界条件下,池计数保持稳定,说明那个漏 free 的分支确实被堵住了。
5.3 回归测试中容易忽略的几个点
回归测试的时候,有几个地方容易漏:
- 日志内容是否正确:修了内存问题,别把日志写坏了。我们对比了修复前后的日志文件,确认内容一致。
- 性能是否有回退:加了一层 wrapper 之后,strdup 的调用开销略有增加。我们用压测工具对比了 QPS,确认在可接受范围内。
- 符号拦截是否还有副作用:libdlt 的拦截会影响所有调用 strdup 的模块,我们检查了其他依赖库,确认没有因为拦截行为变化而出现异常。
6. 常见问题与排查技巧速查
6.1 内存泄漏排查常见问题对照表
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 非分页池持续上涨 | 驱动或底层库反复申请未释放 | Poolmon 按 Tag 排序,找持续为正的 Tag |
| 分页池持续上涨 | 内核对象或缓存未释放 | 结合 Poolmon 和 Performance Monitor 对比 |
| Private Bytes 上涨但句柄稳定 | 纯内存泄漏 | 用内存分配追踪工具,定位分配点 |
| 句柄数同步上涨 | 资源未释放 | 用 Handle 工具查看句柄类型和调用栈 |
| 断点打在系统函数上不命中 | 符号被拦截 | 检查是否有 IAT hook 或 Detours |
6.2 几个我踩过的坑
- 别只看任务管理器的内存列:那个数字受工作集影响,涨跌不代表泄漏。要看 Private Bytes 和内核池计数器。
- strdup 一定要配对 free:尤其是错误分支,最容易漏。建议用 RAII 包装,别靠人肉记。
- 符号拦截要留文档:如果项目里用了 hook,一定要在显眼的地方写清楚,否则后面调试的人会一脸懵。
- 临时方案要有退出条件:定时重启、降日志级别这些手段,一定要在修复上线后清理掉,不然会变成隐患。
- 验证要跑长稳:短时间看不出问题,至少跑 72 小时,最好覆盖边界条件。
6.3 给后来者的几条实操建议
如果你正在处理类似的内存泄漏问题,我的建议是:先把监控做起来,用数据说话,别靠猜。Poolmon 和 Performance Monitor 这两个工具虽然老,但真的管用。定位到具体模块之后,优先推动上游修复,本地补丁只作为过渡。修复之后一定要做长稳测试和边界条件复现,确认原来的触发路径被堵住了。
另外,符号拦截这个东西,用好了是利器,用不好就是坑。如果非要用,一定要在拦截实现里加上完善的日志和计数,方便出问题的时候快速定位。我们这次能这么快找到 libdlt 的问题,很大程度上就是因为拦截层里留了分配和释放的计数,差值一出来,方向就明确了。
最后再分享一个小技巧:在排查内存泄漏的时候,可以给可疑的分配点加上一个唯一的标记,比如在分配的内存头部写一个 magic number。这样用内存 dump 工具看的时候,能快速识别出这块内存是谁申请的,比单纯看地址高效得多。这个技巧在排查第三方库的泄漏时特别有用,因为你不一定能拿到它的源码,但可以通过内存特征反推它的行为。