拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Windows常驻服务内核池内存泄漏排查:从PoolMon到RAMMap定位ndu.sys

Windows常驻服务内核池内存泄漏排查:从PoolMon到RAMMap定位ndu.sys

1. 故障现场:一次被“常态化”的内存增长

先说结论:这次排查的不是那种一夜之间进程崩溃的急性故障,而是典型的“温吞水”式内存泄漏——GODService服务的内存占用每天涨一点,重启后回落,再过几天又涨上去。这种问题最容易被忽视,因为它不致命、不打断业务,但会随着时间慢慢侵蚀服务器性能,等到某个月发现物理内存快被打满时,已经是连续运行几十天的结果了。

GODService是我们这边一套终端管理平台的核心常驻服务,负责客户端注册、策略下发、健康状态上报这类基础通讯工作。理论上它应该是一个内存占用非常平稳的服务——启动后吃到几十MB,然后长期维持在一个区间内波动。但从七月底开始,监控平台上的内存趋势图就不太对劲了:进程工作集从不到100MB起步,以每天大约80MB~100MB的幅度稳定爬升,连续运行一周后能涨到接近1GB。

当时团队里的第一反应是“是不是哪块业务逻辑写了缓存没清”,或者“是不是终端上报的频率太高导致并发堆积”。我最初也顺着这个方向查——翻日志、查连接数、压测模拟终端上报,折腾了两天,什么有价值的东西都没发现。日志里没有任何异常报错,连接数和内存增长也没有直接关联,哪怕把上报频率调低到原来的十分之一,内存曲线依旧岿然不动地往上走。

真正让我改变方向的,是任务管理器里一个不太起眼的细节:GODService的内存类型里,“内核池”相关数值出现了明显异常。

这里先给基础薄一点的读者解释一下。Windows下进程的内存分为用户态和内核态两块,平时我们用任务管理器看的“内存(活动工作集)”大多指用户态部分,也就是进程自己malloc、new出来的堆内存。但一个服务如果频繁调用网络通讯、文件IO或注册表操作,内核态会同步分配对应的内核池内存——分页池和非分页池。很多内存泄漏排查之所以绕弯路,就是因为只盯着用户态工作集看,忽略了真正在漏的其实是内核池部分。GODService的“池非分页”数值以肉眼可见的速度在涨,这就说明泄漏大概率不在我们的托管代码或C++业务逻辑里,而在它依赖的某些底层内核态组件上。

这次故障的排查链路,就是从“进程内存涨”到“内核池涨”再到“某一个特定内核驱动”的逐步收窄过程。这篇文章我会把完整的定位思路、用到的工具组合和最终的修复验证方案都写下来,希望能给同样在做Windows常驻服务、又避不开内核交互的人一些参考。

2. 工具链组合拳:从PoolMon到RAMMap的定位路径

2.1 先用PoolMon锁定内核池标签

确认方向后,我做的第一件事是拉 PoolMon 出来。PoolMon是Windows Driver Kit里自带的一个命令行工具(也可以单独从WDK里提取),专门用来监控系统内核池的内存分配。它的工作方式很简单——系统里每一次内核池分配和释放都会带上一个四个字符的Pool Tag,PoolMon把这些Tag按分配量排序展示,一眼就能看出来到底是哪个类型的分配在疯涨。

操作上我推荐用“按分页池和非分页池分别排序”的方式跑:

poolmon /p /g # 按非分页池Tag聚合,按差值排序

跑起来之后,等待大约5分钟,然后按b键切换到“按字节数变化量排序”模式。这个变化量是关键,它像是给内存分配拍了一张延时摄影,能过滤掉那些“虽然总量大但是有进有出”的正常Tag,直接暴露出“只进不出”的异常Tag。

当时的结果让我愣了一下:排在变化量第一位的Tag是Ndu,变化率大约每秒增长37KB左右。这个Tag属于ndu.sys——Windows Network Data Usage Service的内核驱动。

等一下,这里面有个容易误解的点,我当初也差点被绕进去,先给你理清楚。

ndu.sys不是第三方驱动,它是Windows系统自身的组件,全称是Network Data Usage,负责统计每个进程、每个网络接口的流量使用数据。你在任务管理器“应用历史记录”里看到的“网络使用”数字,就是靠这个驱动收集的。从Windows 8开始它就一直存在,是一个标准的微软签名的系统驱动。

一个系统自带的驱动,为什么会在GODService运行期间出现内存泄漏?按常理说,系统驱动应该无条件正常工作才对。但实际排查下来,问题恰恰出自“系统驱动和一个常驻服务的交互方式”上。

2.2 用RAMMap验证内存去向

PoolMon给出的信号还只是“可疑”,我接着用了 RAMMap 做交叉验证。RAMMap是Sysinternals套件里的内存分析利器,可以按“进程”、“驱动”、“内核池”等维度对整个物理内存的使用做快照。

操作上没有太多技巧,分三步走:

  1. 打开RAMMap,等待左下角统计信息稳定。
  2. 点击顶部“进程”标签,找到GODService,记录它的“工作集”和“提交”数值。
  3. 切到“驱动”标签,按“非分页池”列从大到小排序,看ndu.sys的内存占用。

我第一次做这个快照时,ndu.sys的非分页池是大概120MB。过了两个小时再看,是大概180MB。又过了4小时,变成了接近280MB。这个增速和PoolMon观测到的Tag增长速率是能对上的,到此我基本确认:内存泄漏发生在ndu.sys驱动的非分页池分配里,而GODService只是触发因素,不是真正的Bug来源。

这里有一个通用经验值得记一下:当某个服务的用户态内存正常,但内核池数值异常上涨时,优先用PoolMon找Tag,再用RAMMap做快照交叉验证,基本可以一步到位锁定具体驱动,省去大量瞎猜的时间。比上来就写一大堆代码审查脚本要高效得多。

3. 元凶现身:ndu.sys和网络数据统计机制的内在缺陷

3.1 ndu.sys在GODService运行期间到底做了什么

锁定了ndu.sys之后,接下来的问题是:为什么GODService会让这个驱动持续泄漏?

我先解释一下ndu.sys的工作机制。这个驱动维护了一张全局的网络流量统计表,表里的每条记录对应一个“网络应用”的聚合数据,例如某个进程名+协议+远程地址的组合。每当一个进程有网络收发操作时,ndu.sys会更新对应的统计条目,同时维护一个到用户态WinRT API的接口,保证像GetNetworkUsageAsync这类API能实时读到数据。

问题出在它更新统计条目的算法上。ndu.sys内部一段核心逻辑是这样的简化流程:

  1. 新流量进来,驱动程序先检查“网络应用”记录是否已存在。
  2. 如果不存在,分配一个新的非分页池条目,把进程ID、进程名、协议、端口等信息填进去。
  3. 如果存在,更新字节数和包数。

这套逻辑本身没毛病,但它有一个隐含假设——正常的客户端进程会在通讯结束后主动断开连接、关闭句柄。当进程关闭所有与该网络流量统计相关的句柄时,ndu.sys会回收对应的统计条目,释放分配的非分页池。

GODService作为常驻服务,它的网络行为是“长连接+高频心跳+持续的数据上报”。服务进程一旦启动,Socket句柄几乎从不断开,连接状态长期保持。理论上,长连接也只是一条统计记录,不会导致内存持续增长。

但这里真正麻烦的是ndu.sys在统计“每个进程的流量”时,维护的是按进程ID + 服务ID组合拆分的多条记录。每当你更新一下网络流量统计订阅(我们服务有一个定时器,每隔一段时间向系统查询一次流量统计信息),系统会重新创建一条新的统计流上下文;旧的上下文如果因为异步回调没被及时释放,就会有一小段非分页池内存变成“孤儿内存”——没有任何句柄引用它,但也永远不会被释放。

GODService的运行模式恰好踩中了这个雷区:它每隔几秒就要查询一次网络使用情况用于状态上报,这个“订阅—查询—释放”的循环在ndu.sys内部触发了一次又一次的上下文创建。正常情况下,每一次循环的泄漏量只有几KB,但如果循环频率足够高、服务运行时间足够长,累积起来就是一个可观的数字。我们观测到的每天80~100MB增速,换算下来大约每秒增长1KB左右,和上面的机制完全吻合。

3.2 用Windbg验证泄漏点

理论归理论,要落地方案之前,还得在实体机上拿出证据。我用Windbg做了一次内核转储分析:

  1. 在内存增长到大约700MB的时候,给目标机器强制生成一个完整内核转储(Ctrl+Scroll Lock触发,或者用NotMyFault工具来触发)。
  2. 打开Windbg,加载转储文件,执行!pool命令查看非分页池的Tag分布。

关键的命令是:

!poolused 2

这条命令会把所有非分页池Tag按使用量从高到低排列。输出里NduTag的“Bytes”数值已经远超其他Tag,进一步确认了泄漏源。

然后我用!poolfind Ndu定位具体的内存块,再用!address看归属,能看到大量Ndu标记的内存块处于Internal状态——既没有关联到任何进程页表,也没有被标记为释放候选。

如果已经装了最新的WinDbg,也可以用!nduskill之类的不存在的命令——网上一度有人调侃过这个梗,但真实情况是,ndu.sys的内存块内部结构微软没有公开,分析到“驱动Tag+Ndu状态”这一步已经足够支撑修复决策了,不需要去逆向它的内部数据结构。

到这里,定位阶段结束。结论可以暂时固化为一句话:GODService的高频网络统计查询和ndu.sys的异步上下文管理缺陷组合在一起,产生了一个低速但持续的泄漏。接下来要做的,是找到可行的修复和绕行方案。

4. 修复与验证:代码整改、系统配置和监控确认

4.1 修复思路的选择

这类“系统驱动缺陷+应用层触发”的泄漏,修复手段通常有三条路,我逐一评估过:

  • 修改ndu.sys的行为:不现实,驱动是微软签名的系统组件,没有源码级修改的可能,也绝不应该用第三方工具去hook掉它。
  • 放弃使用网络统计API:最彻底,但也最浪费,等于因噎废食。
  • 调整GODService的调用频率和方式:可行且务实,在保留网络监控能力的前提下,把可能触发泄漏的循环次数降到一个足够安全的水平,同时把连接生命周期管理得更干净。

我选了第三条路,并结合了系统层面的一个额外调整。

4.2 代码层面的整改

先说说GODService原本的实现。伪代码大概是这样的:

// 修复前的简化逻辑:每5秒查询一次网络用量,并追加到状态缓存 private async Task ReportNetworkUsage() { while (!_cancellationToken.IsCancellationRequested) { var usage = await NetworkUsageManager.GetNetworkUsageAsync(); AppendStatusPayload(usage); await Task.Delay(5000); } }

问题就出在这个“每5秒查询一次”上。NetworkUsageManager.GetNetworkUsageAsync()底层会走到ndu.sys的上下文,每调用一次就会在驱动层产生一个新的流量统计上下文。正常的Windows API设计下这些上下文会被回收,但碰上高频调用,异步清理路径就会频繁出现来不及回收的情况。

修复后的实现做了三处改变:

// 修复后的简化逻辑:30秒查询一次,且使用缓存实例而不是反复创建新查询上下文 private NetworkUsageManager _usageManager = new NetworkUsageManager(); // 复用实例 private DateTime _lastUsageQueryTime = DateTime.MinValue; private async Task ReportNetworkUsage() { while (!_cancellationToken.IsCancellationRequested) { if ((DateTime.Now - _lastUsageQueryTime).TotalSeconds >= 30) { // 使用同一个查询句柄,避免反复创建/销毁统计上下文 var usage = await _usageManager.GetUsageSnapshot(); AppendStatusPayload(usage); _lastUsageQueryTime = DateTime.Now; } await Task.Delay(3000); } }

改动点拆开说:

  1. 查询频率从5秒改为30秒。这个频率对状态上报场景完全够用,终端的流量统计并不需要精确到秒级。仅仅这一项,就把ndu.sys上下文创建的频率降为原来的1/6。
  2. 复用NetworkUsageManager实例。原先的代码每次循环都new一个查询器,等于每次都要求系统创建一个新的统计流上下文;改成复用同一个查询实例后,Windows API可以正确地在同一个上下文里做增量更新,减少驱动层的条目创建和销毁。
  3. 把连续查询改为有状态的时间间隔控制。即使服务有多个业务线程同时触发了上报逻辑,也能把实际的驱动层查询请求合并成最少次数,尽量降低并发交错的概率。

这几行代码看起来不起眼,但针对性很强,因为内核池泄漏和“调用次数”强相关,而不是和“数据量大小”强相关。一个每次传输1MB数据的长连接并不会比10次只传1KB数据的短连接泄漏得更多——真正驱动泄漏速率的是上下文创建的频次,也就是调用次数。

4.3 系统层面的辅助调整

除了代码之外,我还顺手对系统服务做了一个调整,作为辅助措施,不解决根因但能降低风险。

Windows的网络数据使用服务叫“Network Usage Service”,对应的可执行文件是NetworkUsageService。这个服务并不总是启用,它取决于是否有应用实际调用流量统计API。我查了故障机器上的服务状态,发现它处于“手动启动”后的持续运行状态。为了确保这个统计链路不会被意外的第三方应用频繁唤醒和释放,我把它从“手动”启动调成“禁用”——前提是我们服务器上根本不依赖任何用户态流量统计应用,GODService是我们自己写的,我们可以控制它不去调用相关API。禁用掉这个服务后,sndu.sys的加载也会跟着不加载,相当于釜底抽薪。

不过得提醒一句:这个操作要慎重,不是在每台机器上都适合这么做。如果机器上有任何需要依赖任务管理器“应用历史记录”或UWP流量统计功能的程序,禁用服务会导致那些数据全部不可用。我们这批服务器是纯后台服务用途,没有这类需求,所以才能这么操作。

最终生效的配置组合是:

措施影响范围效果
查询频率改30秒仅GODService驱动上下文创建频率降低为原来的1/6
复用查询实例仅GODService避免重复创建/销毁统计流上下文
禁用Network Usage Service整机直接遏制ndu.sys的加载与活动

4.4 验证过程和数值对比

修复上线后,我没有直接压完就说“修复成功”,而是按小时级和天级两个维度做了长期观察:

  • 4小时观测:修复后第4小时,从RAMMap看ndu.sys非分页池维持在约85MB,没有继续上涨,且偶有回落。
  • 12小时观测:非分页池曲线呈现正常波动(增长和释放交替出现),不再是一条直线上扬。
  • 48小时观测:GODService整体内存占用从高峰期的700多MB回落到约120MB,且48小时内没有超过130MB,基本恢复到了泄漏前的正常水位。

同时我注意到PoolMon里NduTag的增长速率从每秒37KB降到了每秒不到1KB,偶尔还会出现负值——也就是释放多于分配。这说明泄漏闭环被打破了。

为了更严谨,我特意保留了修复前一个晚高峰时段的转储数据作为对比,用!poolused 2命令统计了当时的Ndu占比。修复后的相同命令输出中,Ndu已经从非分页池榜首掉出了前五。数据摆在那里,比任何嘴上的“应该修好了”都更有说服力。

5. 举一反三:还有哪些“系统驱动+服务交互”的组合容易踩同样的坑

写完这篇修复报告,我更想说的是,这种故障模式并不罕见。Windows环境下,类似ndu.sys这样“看起来人畜无害的系统组件 + 某个高频调用的业务服务”的组合,坑过很多人。我盘点了几个同类场景,方便你在未来排查时少走弯路:

  • TCP/IP协议栈的非分页池增长:当服务频繁创建短连接、尤其是带TLS握手的那种,tcpip.sys驱动可能会因为连接处于TIME_WAIT状态堆积而增加非分页池占用。常见的处理手段不是改驱动,而是调整注册表里的TcpTimedWaitDelay和MaxUserPort,把TIME_WAIT状态的连接尽快回收。

  • HTTP.sys的内核缓存增长:如果你的服务基于http.sys(比如Kestrel运行在Windows上时),复用HTTP连接、调整Http503Verbose之类的配置,都能影响内核缓存。某些版本下,HTTP.sys的URI缓存如果不做主动清理,也会表现为服务进程“间接”吃内存。

  • WFP(Windows Filtering Platform)相关驱动的内存占用:装了安全软件、流量过滤工具的机器上,WfpConnectionTracking相关的池Tag经常成为内存泄漏大户。这时候的排查思路就不是改业务代码了,而是要找安全软件厂商更新驱动版本。

共通的做法可以总结成一句话:当应用层内存正常、内核池上涨时,先区分池类型,再用PoolMon定Tag、RAMMap交叉验证、Windbg取证据——这条链路在大多数“系统驱动交互型”泄漏面前都适用。

这类问题往往不是“修一行代码”那么简单,也不会像崩溃那种故障有一声巨响。它更像一个安静的漏水点,白天看不出来,放一个月才水漫金山。但好处是,只要盯住正确的指标维度,找到泄漏的方向并不难。起码这次的GODService,从发现到修复再到验证,整个链路走完之后,我对“慢速内存泄漏”这类问题的容忍度已经比以前低了很多——趋势图只要出现持续两周以上的单调递增,不管多慢,都值得坐下来认真查一轮。

返回列表