
1. 问题现象与服务现状先交代一下背景。我手里维护着一个叫GODService的Windows后台服务这个服务本身不算复杂核心工作就是长期驻留、定时采集系统运行数据、按需监听一些事件并写日志。部署环境大多数是Windows 11专业版工作站和少量Windows Server 2022。服务跑起来之后日常CPU占用率很低接口响应也算正常一直没觉得会出大问题。直到有一天运维反馈某个终端连续运行三周后系统变得明显卡顿打开任务管理器发现已提交内存已经接近物理内存的两倍而且分页缓冲池和非分页缓冲池两个计数器都在持续上涨。更直观的现象是只要GODService一重启这两个数值就会出现明显下降系统立刻恢复正常。这个规律一出现基本就锁定问题了——GODService存在内存泄漏而且泄漏的内存类型集中在系统池里而不是普通的进程私有内存。这里先解释一下分页缓冲池和非分页缓冲池的区别方便没有系统级排查经验的朋友理解。Windows内核把驱动程序和服务申请的内存放在两种池里分页缓冲池Paged Pool表示可以写入页面文件的内存非分页缓冲池Nonpaged Pool则表示必须常驻物理内存、不能换出的部分。很多内核操作比如处理中断、访问分页机制以外的关键数据结构只能用非分页池。所以一旦非分页池出现泄漏物理内存消耗得特别快系统可能直接触发资源紧张。GODService这类普通用户态服务通常不会主动分配内核池内存但它会通过某些系统调用间接在内核里创建对象或缓存如果这些对象没有释放池内存就会持续增长。这次问题之所以值得单独写一份修复报告是因为它涉及的泄漏路径非常隐蔽常规的性能分析工具根本看不到进程私有内存的增长只有把目光放到系统池对象上才能抓到真相。本文会把从现象判断、工具选型、池标签定位、代码逐行审查到最终修复验证的完整过程记录下来给同样被系统缓存持续膨胀折磨的朋友一条可复用的排查路径。2. 内存类型拆解与泄漏方向判断很多人在排查内存泄漏时第一反应是打开任务管理器看工作集或专用内存这个思路在处理普通应用泄漏时没错但遇到GODService这种晨间服务、长时间不重启的应用就非常容易误判。因为Windows服务的泄漏经常发生在系统池而不是用户态堆里。整个排查过程我把它分成四个阶段先判断内存类型再定位池标签然后跟踪调用栈最后修代码验证。2.1 用户态内存与系统池内存的分野先理清一个概念一个进程能用的内存分为两块大头一块是进程私有的用户态内存包括堆、栈、托管堆、文件映射等这类内存增长可以用VMMap、任务管理器或性能监视器直接看到另一块是内核态的系统内存包括分页缓冲池、非分页缓冲池、系统PTE、页表项等。用户态服务如果调用了一个需要内核协助的API比如创建事件、注册回调、打开句柄内核里就会产生对应的对象和池分配。这些对象如果由服务的某个模块负责释放却没释放就形成了系统池泄漏。GODService的情况很典型私有内存从始至终纹丝不动但池非分页和池分页两个性能计数器稳步往上爬。任务管理器的性能页里看到这两个值每周增长几百MB这个量级对系统池来说已经非常明显。初步判断泄漏来源以后我用性能监视器新建了一个数据收集器集把Process、Memory、Pool相关的计数器全部拉上记录了24小时基线数据确认增长曲线和GODService进程的启动时间强相关而和其他进程无关。这一步是判断责任方的关键省去了逐个进程猜测的时间。2.2 为什么优先怀疑系统池而不是进程堆从经验上讲出现池内存持续上涨、私有内存不动的组合90%以上是因为某个服务模块反复调用系统接口却因为忘记关闭句柄或未释放对象导致内核引用计数无法归零。普通堆泄漏会有明显的_heap_alloc调用代码审查时比较容易发现而系统池泄漏往往藏在对Windows API的误用里尤其容易出现线程同步、定时器、注册表通知、设备通知这类接口上。GODService注册了两个事件回调一个用于感知系统电源状态变化一个用于监控终端网络连接状态变化。这两个回调都涉及等待事件和对象句柄操作是重点怀疑对象。我决定先从池标签入手看看泄漏的内存到底送了哪个组件。3. 抓取现场数据从性能监视器到PoolMon排查系统池泄漏最忌讳的就是凭空猜必须拿到第一手现场数据。我在一个复现环境里部署了最新版本GODService让它连续跑48小时采集泄漏数据。这个复现环境是配置了6GB内存的Windows 11虚拟机除了系统自带服务以外没有安装其他应用物理环境干净排查结果不会受干扰。3.1 用Perfmon确认增长曲线我新建了一个数据收集器集把以下计数器全部加进去Memory\Pool Nonpaged BytesMemory\Pool Paged BytesProcess\Private Bytes针对GODService进程Process\Working Set针对GODService进程Process\Handle Count针对GODService进程System\Pool Nonpaged Allocs采集间隔设成60秒日志存到独立磁盘分区。跑了一天半以后导出数据结果很明显非分页池稳定上涨斜率大概是每小时3MB到5MB分页池上涨得更猛一些每小时接近8MB。GODService的私有内存和工作集基本没有变化但句柄数从启动时的3200个涨到了将近9000个。句柄数只在Windows服务上才会这么夸张——普通应用即使泄漏句柄也很难涨到这种量级因为GODService作为服务进程长期运行句柄表不会自动压缩内核通过句柄表给每个对象维护引用时句柄不关对象就一直在。这个数据直接引导我下一步去看系统池的具体分配标签。3.2 PoolMon池标签的照妖镜系统池里的每个分配都带一个四字符标签相当于给内核内存使用者贴的姓名牌。微软Sysinternals套件里的PoolMon就是用来按标签统计池分配的专业工具比任务管理器精确得多。我在复现环境里以管理员身份运行了PoolMon /p /b这个参数会把非分页池和分页池分开显示并按累计分配次数排序。命令基本是这么写的poolmon /p /b C:\poolmon_base.txt先抓一份基线数据再让它跑24小时抓第二份然后对比两份数据里增长量大的标签。这里有个很容易踩的坑PoolMon默认统计的是当前时刻的当前分配字节数你要对比两个时间点的差值而不是看某一瞬间的绝对值否则很容易被某个间歇性分配的大块内存误导。我写了一个小脚本把两个文件按标签合并、相减并排序结果很快浮出水面——排名前几的标签里有LSwx、Perf、MmSt、Toke其中增长最猛的是Perf和LSwx。Toke标签通常和令牌对象相关MmSt是内存管理结构Perf则和Windows性能计数器相关。LSwx这个标签比较少见查了一下它通常来自等待链遍历相关的内核结构。这几个标签没有一个直接叫GODService这很正常因为用户态服务的分配最终会出现在别的内核组件名下。关键思路是拿到增长标签之后逆向跟踪到底谁在谁创建这些对象。4. 从池标签追踪到GODService的罪魁祸首这一步是整个排查中最花时间的环节因为它不能靠工具一步到位必须结合分析工具和代码审查。我用了两套组合拳WPAWindows Performance Analyzer做内核池追踪然后直接进GODService源码定位对应的API调用链。4.1 用WPR/Analyzer做池分配追踪我先以管理员身份开启了Windows性能记录器用Pool和FileIO两个Profile记录了一个小时的数据。命令是这样wpr -start Pool -filemode然后让GODService跑一段触发事件的操作切换电源模式、插拔虚拟网卡制造更多的池分配活动。采集完执行wpr -stop C:\trace.etl把生成的ETL文件拖进Windows Performance Analyzer在找到Pool相关的跟踪表筛选Perf和LSwx标签的分配记录按堆栈维度分组展开。这里的执行效果非常直接FETL里能看到每一次Perf标签分配时的调用栈最上层清一色指向advapi32.dll的PerfStartProvider相关调用往下跟就到ntdll.dll的NtCreateEvent、NtQuerySystemInformation这类内核接口。把这两个函数名和GODService代码里的引用一匹配立刻就发现GODService的PerformanceMonitor模块——它每隔一段时间会调用一个第三方性能采集库去获取系统负载、磁盘占用和网络流量而这个库在每次获取数据时都会调用PerfStartProvider创建新的性能提供者句柄但释放时机却滞后到服务停止时才统一处理。这等于每次采样都新建一个内核性能对象采样完了句柄却一直留在系统池里。所以问题根因已经清楚了性能采集库内部对PerfStartProvider返回的句柄管理不当导致内核里Perf标签的内存只增不减。为了让问题彻底闭环我还在代码里用WaitForSingleObject相关的地方做了排查确认事件对象也存在类似问题——虽然标签数量少一些但一旦积少成多同样不可忽视。4.2 不良代码模式的还原与修复为了让大家理解得清楚我在这里把问题代码简化为最容易看懂的伪代码形态。原库在每次采样时都做了类似这样的调用void CollectPerformanceData() { // 每次采样都创建一个新的提供者句柄 PERF_PROVIDER_HANDLE hProvider PerfStartProvider(ProviderGuid, callback, context); // 通过这个句柄读取性能数据... QueryPerfData(hProvider, ...); // 但这里没有调用 PerfStopProvider 释放句柄 }修复方式并不复杂把创建句柄的动作挪到服务启动阶段采样时复用同一个句柄服务停止时统一释放PERF_PROVIDER_HANDLE g_hPerfProvider NULL; BOOL InitPerformanceMonitor() { return PerfStartProvider(ProviderGuid, callback, context, g_hPerfProvider) ERROR_SUCCESS; } void CollectPerformanceData() { if (g_hPerfProvider NULL) { return; } QueryPerfData(g_hPerfProvider, ...); } void UninitPerformanceMonitor() { if (g_hPerfProvider ! NULL) { PerfStopProvider(g_hPerfProvider); g_hPerfProvider NULL; } }另外还有一个老版本GODService在事件回调里用CreateEvent创建临时事件等异步操作完成后既不CloseHandle也没有把事件状态复位导致大量事件对象占据非分页池。这一步的修复是在回调的每个分支末尾统一CloseHandle并加了一个RAII风格的句柄包装类避免后续新增代码时再次遗漏。5. 修复验证与长期回归观察代码改完以后第一步不是在真实环境里直接升级而是先在复现虚拟机里做了灰度验证。把修复后的GODService部署到跑了两天的复现环境观察48小时然后对比修复前后的池内存增长曲线确认大概率恢复正常。随后才把修复版本发布到一台真实终端上持续运行两周收集长期数据。5.1 短期验证结果我采集修复后48小时的数据和之前泄漏状态下的增长数据放到同一张图里对比差距非常明显泄漏版本的非分页池48小时涨了大约240MB分页池涨了超过380MB修复版本的非分页池48小时基本上走平最大浮动在2MB以内分页池也在基线附近小幅波动。句柄数方面泄漏版本的句柄数从3200涨到9000修复版本稳定在3400左右服务的句柄表不再持续扩张。这里有一个很容易忽略的问题服务安装方式不同修复生效的时机也不同。如果在服务还在运行时直接替换DLL那旧模块可能被加载到进程空间后无法完全卸载导致泄漏不再增加但原有句柄也不会自动释放。我的建议是更新GODService前先sc stop GODService等进程完全退出后替换文件再sc start GODService。这样能保证所有旧句柄都被系统回收修复结果数据干净可靠。5.2 长期运行观察修复版本在真实终端上连续运行两周后我又看了三次性能监视器数据第一周结束时非分页池和基线相比增长了不到5MB第二周结束依然没有明显上升。同时事件日志里也没再出现资源不足或事件写入延迟类告警。至此整个GODService内存泄漏修复项目才算正式收官。还是要再提醒一句系统池内存泄漏有时候不会立刻导致系统崩溃而是慢慢侵蚀可用内存到某个临界点后触发连锁故障。如果你也遇到Win11或Windows Server环境中系统内存慢慢被耗尽、任务管理器里分页缓冲池和非分页缓冲池持续上涨一定要尽早用PoolMon抓池标签然后追调用栈这样才能准确锁定到具体的服务和驱动模块而不是盲目重启或重装系统。6. 复盘这次排查留下的几条实操教训第一次用PoolMon时我以为标签多的就是泄漏大户结果最大的Pool标签其实是网络驱动的正常缓存差点误导方向。正确做法是必须做前后时间点对比增量大的标签才有嫌疑。后来我把这个”基线对比“的习惯也带到了其他性能排查里强烈建议长期维护的服务都用日志记录启动时的池统计基线。排查系统池泄漏时性能监视器和PoolMon这类工具输出的结果往往指向某个内核组件而不是直接指向你的服务这时候不要慌用WPA去追踪具体调用栈把内核函数名和你的源码匹配上就能找到根因。整个排查过程中最忌讳的就是看到某个标签变大就急着改代码一定要跑完现场数据、比对、验证再动手。GODService这次修复让我真正体会到服务类程序的内存管理重点不在启动阶段而在长时间运行后的资源回收路径。性能采集、事件回调、异步操作这三类代码是系统池泄漏的高发区域写的时候就要及时清理临时句柄而不是依赖服务停止时的统一释放逻辑。以后每次提交类似模块的代码我都会要求自己在代码评审清单里加一项所有内核对象句柄是否有明确的释放路径是否有作用域包装。