☰
Windows后台服务内存泄漏实战:从池内存暴涨到句柄泄漏修复
2026/10/7 7:24:39 网站建设 项目流程

凌晨两点半,监控平台连发三条告警:GODService 内存占用突破 4GB,分页缓冲池和非分页缓冲池双双持续走高。我心里一沉——这个跑了大半年的 Windows 后台服务,终于露出了内存泄漏的獠牙。当时我盯着任务管理器里那条近乎垂直向上的曲线,脑子里只有一个念头:这次不把根因挖出来,GODService 就别想安稳撑过下一次业务高峰了。

所谓 GODService,是我们内部负责服务状态巡检和数据中转的常驻 Windows 服务。它每隔一段时间就要去查询一批系统的运行状态,把结果写入共享内存供其他进程读取。名字虽然带个 GOD,干的全是脏活累活,也因此最容易在"每个周期都做一点、每个周期都忘一点"的循环里埋下内存泄漏的隐患。

这篇文章我会把整个排查、定位、修复的过程完整记录下来。如果你也在维护常驻后台服务,或者正在被"内存看着看着就涨上去"的问题折磨,这篇文章应该能帮你省下不少弯路。

1. 事故现场:GODService 内存从 300MB 涨到 8GB 的过程还原

1.1 监控告警最早暴露出的异常曲线

事情发生在一次常规发版后的第五天。白天一切正常,到了晚上内存占用开始以肉眼可见的速度往上蹿。监控平台每五分钟采集一次数据,我把那天的曲线调出来看,规律特别明显:内存增长不是突然的,而是一段平台期加一段陡增,像是有什么东西在周期性累积。

前四天 GODService 的提交内存基本稳稳地维持在 300MB 左右,第五天下午开始变成每半小时涨 200MB 上下,到了凌晨已经逼近 8GB。系统开始频繁提示"内存不足",部分依赖 GODService 的下游服务也出现了超时。

任务管理器里最有意思的细节是:不光是 GODService 这个进程的"内存(活动私有工作集)"在涨,整个系统的"池非分页"和"池分页"两项也在同步上涨。这说明泄漏不只是发生在进程的用户态堆里,更可能涉及内核态的对象和系统内存池。

1.2 为什么池内存上涨比进程内存上涨更危险

很多做业务开发的朋友看到"进程内存涨了"第一反应就是查托管堆、查对象引用。但如果你维护的是一个 Windows 常驻服务,真正难缠的往往不是托管堆,而是系统层面的分页缓冲池(Paged Pool)和非分页缓冲池(NonPaged Pool)。

这里有个容易混淆的概念:任务管理器里显示的"池非分页"和"池分页"是操作系统全局的内核内存池,不是某个进程的私有内存。任何进程在内核态分配的对象,只要没释放,最终都会反映在这两个池子上。

  • 非分页缓冲池:常驻物理内存,不能被换出到磁盘。句柄对象、设备对象、内核线程栈等都在这里分配。一旦泄漏,物理内存被持续吞噬,系统可能直接卡死或者触发崩溃。
  • 分页缓冲池:允许换出到页面文件。注册表键对象、文件对象、Section 对象等多在这里分配。泄漏速度慢,但同样会拖垮系统。
  • 进程私有内存:包括托管堆和原生堆,相对容易被任务管理器、dotMemory、ANTS Memory Profiler 这类工具抓个正着。

我把三组计数器拉出来对比,发现 GODService 的私有内存和系统池内存是同步增长的。这个特征给了我两个判断:第一,泄漏大概率发生在线程周期执行的某个操作上;第二,这个操作不仅吃进程内存,还动了内核对象。

1.3 从 CLR 的角度预判:托管 GC 为什么没兜住

GODService 跑在 .NET Framework 4.8 上,CLR 自带垃圾回收,按理说托管对象不该这样无休止地累积。如果托管堆在涨,那可能是某个静态集合或者事件订阅把对象牢牢攥住了。但从这次的表现来看,进程私有内存涨得并不夸张,池内存才是主角。

.NET 里最容易造成池内存增长的场景有两类:一是 P/Invoke 调用 Win32 API 后拿到了 IntPtr 句柄,代码里忘了 CloseHandle / CloseServiceHandle;二是创建了MemoryMappedFile、SafeHandle派生对象却没能及时 Dispose。这类资源的内核对象引用计数不归零,GC 再怎么跑也清不掉。

事后来看,GODService 恰好把这两类问题都踩中了。我一度以为 GC 能兜底,事实证明对内核对象的泄漏,GC 毫无办法。

2. 先分清敌人:进程私有内存、分页缓冲池、非分页缓冲池的定性排查

2.1 用性能计数器把三个增长源分开统计

排查一开始,我没有急着上 windbg,而是先用 PerfMon 把数据拆开看。打开perfmon.msc,添加了以下几个计数器:

  • \Process(GODService)\Private Bytes
  • \Process(GODService)\Working Set
  • \Process(GODService)\Handle Count
  • \Memory\Pool Paged Bytes
  • \Memory\Pool Nonpaged Bytes
  • \Memory\Committed Bytes

这里要提醒一点:别只看"工作集",工作集会因为操作系统换页抖动出现假象。Private Bytes 和 Handle Count 才是判断进程是否有句柄泄漏的硬指标。

我把 GODService 单独跑了一个 Collector Set,每隔五秒采样一次,连续采集了三个小时。出来的趋势很清晰:

指标24 小时后的基线第 5 天峰值增长形态
Private Bytes约 320MB约 2.1GB阶梯式上升
Handle Count约 900约 18500每轮任务稳定 +2
Pool Paged Bytes约 28MB约 1.4GB持续斜线上升
Pool Nonpaged Bytes约 24MB约 1.1GB持续斜线上升

Handle Count 从 900 涨到 18500 这个数字让我心里咯噔一下:GODService 大概每秒跑一轮巡检任务,每轮任务如果泄漏 1 到 2 个句柄,5 天下来就是这个量级。句柄不关,内核对象不释放,池内存自然跟着涨。

2.2 用 RAMMap 确认物理内存都被谁吃掉了

PerfMon 告诉我"池在涨",但没告诉我"池里涨的是什么东西"。我打开 Sysinternals 的 RAMMap,在Use Counts标签页里直接把"Paged Pool"和"NonPaged Pool"两栏展开,再结合Processes标签对比,基本能确认:

  • 物理内存里增长最猛的是 NonPaged Pool 和 Page Table;
  • GODService 的进程私有内存虽然有增长,但远没有池内存那么夸张;
  • 没有发现某个第三方驱动或者系统进程在抢内存。

RAMMap 的价值在于定性——它能帮你排除"是不是别的进程/驱动在搞鬼",把火力集中到目标进程身上。但要说它能不能直接定位到代码里的某一行,那就别指望了。它最多告诉你:"问题就在 GODService 的内核对象上,去查句柄吧。"

2.3 手动跑一次监控脚本验证泄漏节奏

为了更快锁定触发条件,我写了个临时脚本,在 GODService 正常运行的同时每十秒记录一次句柄数。

while ($true) { $p = Get-Process -Name GODService -ErrorAction SilentlyContinue if ($p) { "{0:HH:mm:ss} Handles={1} PrivateMB={2:N1}" -f (Get-Date), $p.HandleCount, ($p.PrivateMemorySize64 / 1MB) } Start-Sleep -Seconds 10 }

日志显示,只要 GODService 的核心巡检线程有任务在执行,句柄数就会稳定增长;一旦把任务源停掉,增长立刻停住。这说明泄漏和特定业务逻辑强相关,和外部干扰无关。范围一下子缩小了很多——问题就在巡检任务的执行链路上。

3. 定位过程:PerfMon 锁定节奏,Process Explorer 揪出句柄,PoolMon 印证池增长

3.1 用 Process Explorer 按句柄类型排序

到了这一步,我开始打开 Process Explorer,双击 GODService 进程,切到Handles标签页。

平时排查句柄泄漏,我习惯按Type列排序,看看哪种类型的句柄数量异常。这次打开后结果非常扎眼:Service类型的句柄占了绝大多数,数量还在实时跳动。

Windows 的 Service 句柄是怎么来的?主要是通过 SCM(服务控制管理器)相关 API 打开的,比如:

  • OpenSCManager:打开服务控制管理器数据库;
  • OpenService:打开指定的服务对象;
  • QueryServiceStatus/QueryServiceStatusEx:查询服务状态;
  • CloseServiceHandle:关闭上面打开的句柄。

Process Explorer 里 Service 句柄每个几十秒就增加一批,基本就把怀疑对象锁定到了"服务状态查询"这段逻辑上。

3.2 PoolMon 看池标签,印证非分页池增长来源

句柄类型锁定了嫌疑方向,我再用 Windows SDK 自带的 PoolMon 去印证系统池的增长来源。

PoolMon 的用法很简单:以管理员身份运行命令行,进入 Windows SDK 的Tools目录(或者通过 WDK 环境),执行:

poolmon /p /b

/p表示只显示分页池,/b表示按字节数排序。如果要看非分页池,可以用poolmon /n /b,或者不传参数从头看起。

PoolMon 会按池标签(Tag)把内核内存池的分配情况列出来。我看了一圈增长最快的几个标签,确实明显集中在与对象管理、服务管理相关的分配上。这里我不打算把标签名背成"标准答案"——不同环境、不同驱动、不同系统版本下标签会不一样,但思路是一致的:

  • 先在 PoolMon 里确认 NonPaged Pool 总量在涨;
  • 再通过 Process Explorer 的句柄类型定位到具体是 Service 句柄;
  • 最后回到源码里搜 OpenService / OpenSCManager / CreateFile / RegOpenKey 这些调用点。

这条链路比我一开始直接翻代码高效得多。工具的价值不是替代人,而是帮你把不可能逐个尝试的代码点快速缩小到一两个嫌疑函数。

3.3 代码审查的切入点:凡是拿到 IntPtr 的地方都要问一句"谁负责关闭"

当 Process Explorer 把矛头指向 Service 句柄后,我再回到 GODService 的代码里搜索。搜出来三个可疑点:

  1. 巡检任务里有一处 P/Invoke 调用OpenSCManager和OpenService,然后调用QueryServiceStatus查询目标服务状态;
  2. 巡检任务里还有一个创建MemoryMappedFile的逻辑,用于把巡检结果写入共享内存;
  3. 日志组件里有一个FileStream初始化的分支,异常时可能跳过关闭。

从 Handle Count 的涨幅看,第三个点贡献不大,最主要还是前两个。我当时的判断是:Service 句柄的泄漏解释了 NonPaged Pool 的暴涨,MemoryMappedFile未释放则解释了 Paged Pool 的持续上升。

4. 根因直击:GODService 中两处漏掉释放的代码点

4.1 泄漏点一:OpenService 之后忘了 CloseServiceHandle

先看最严重的这处。GODService 的巡检任务核心逻辑简化后大概是这样的:

[DllImport("advapi32.dll", SetLastError = true)] static extern IntPtr OpenSCManager(string machineName, string databaseName, uint access); [DllImport("advapi32.dll", SetLastError = true)] static extern IntPtr OpenService(IntPtr hSCManager, string serviceName, uint access); [DllImport("advapi32.dll", SetLastError = true)] static extern bool QueryServiceStatus(IntPtr hService, out SERVICE_STATUS status); [DllImport("advapi32.dll", SetLastError = true)] static extern bool CloseServiceHandle(IntPtr hObject); private void CheckServiceStatus(string serviceName) { var scm = OpenSCManager(null, null, SC_MANAGER_CONNECT); if (scm == IntPtr.Zero) { return; } var svc = OpenService(scm, serviceName, SERVICE_QUERY_STATUS); if (svc == IntPtr.Zero) { return; } QueryServiceStatus(svc, out var status); // 查询完了,直接返回,svc 和 scm 都没有关闭 }

看到问题了吗?scm和svc都是IntPtr,不是SafeHandle派生的托管对象。CLR 对裸IntPtr没有任何自动回收能力。每执行一次巡检,就会在内核里多出两个 Service 对象的引用;只要进程不退出,这两个句柄就永远泄漏。

更恶心的是,这类泄漏不会立刻在托管堆上体现出来。GC 堆平静得像什么都没发生,但句柄数在疯涨,NonPaged Pool 被一点点吃光。等到系统物理内存告急,可能已经积累了上万个未关闭的句柄。

修复时我做了两件事:第一,用一个SafeServiceHandle类型封装句柄,让它继承SafeHandleZeroOrMinusOneIsInvalid,这样即使代码中途抛异常,终结器也有机会把句柄关掉;第二,在调用处用using或者try/finally保证关闭。这里给出最稳妥的写法:

private bool TryQueryServiceStatus(string serviceName, out SERVICE_STATUS status) { status = default; using (var scmHandle = SafeServiceHandle.OpenSCManager()) { if (scmHandle.IsInvalid) { return false; } using (var svcHandle = SafeServiceHandle.OpenService(scmHandle, serviceName)) { if (svcHandle.IsInvalid) { return false; } return QueryServiceStatus(svcHandle.DangerousGetHandle(), out status); } } }

SafeHandle 的好处在于:即使你在QueryServiceStatus之后忘记显式关闭,句柄也会在 SafeHandle 被 GC 回收时释放。这给代码加了一层安全网。不过别因为有安全网就不写using——依赖终结器是最后的底线,不是常规手段。

4.2 泄漏点二:共享内存映射文件创建了却没释放

另一处泄漏来自 GODService 用来和其他进程共享巡检结果的MemoryMappedFile。

这处问题比句柄泄漏更隐蔽。原代码大概长这样:

private void WriteStateToSharedMemory(StatePacket packet) { // 每轮巡检都创建一份新的映射文件 var mmf = MemoryMappedFile.CreateOrOpen("GODService_State", 4096); var view = mmf.CreateViewAccessor(); view.Write(0, packet); // 只释放了视图访问器,但没有释放 mmf view.Dispose(); }

看着好像释放了,其实只释放了CreateViewAccessor创建的视图访问器。MemoryMappedFile本身关联的内核 Section 对象一直没有关闭。每次巡检创建一个新的 Section,旧的内核对象没有被解除引用,内核里的 Paged Pool 就持续增长。

这里有个非常常见的误解:很多人觉得"我调用了一个对象的 Dispose 方法,它内部的东西就都释放了"。但MemoryMappedFile和CreateViewAccessor是两个独立的内核对象,必须分别管理。

正确的做法是把整个生命周期圈在一个using里:

private void WriteStateToSharedMemory(StatePacket packet) { using (var mmf = MemoryMappedFile.CreateOrOpen("GODService_State", 4096)) using (var view = mmf.CreateViewAccessor()) { view.Write(0, packet); } }

这两处修复看起来都像"一行代码的事",但定位它们花了大半天。真正的困难不在改代码,而在把"池在涨"这个宏观现象,一路追到"某一行忘了释放"这个微观代码点。

4.3 为什么 GC 在这里救不了你

看到这里可能有人会问:.NET不是有终结器吗?就算忘了 Dispose,Finalizer 不也会兜底吗?

答案是:终结器只对托管到非托管的包装对象有效,比如SafeHandle、FileStream、MemoryMappedFile。如果代码里用的是裸IntPtr,CLR 根本不知道它抱着一个内核句柄,自然不会触发任何清理逻辑。

就算你用了MemoryMappedFile这种封装了 SafeHandle 的类型,终结器也只会在 GC 认为这个对象已经不可达时才运行。如果有一个静态缓存、事件订阅、或者其他长期存活的对象一直引用着这个MemoryMappedFile,它就永远不会被 GC 标记为垃圾,终结器也就永远不触发。

对常驻服务来说,"长期存活的对象持有未释放内核资源"是最危险的内存泄漏形态。它既不体现在托管堆的明显膨胀上,也不触发 OOM,只会让系统池一点点被蚕食。

5. 修复落地方案:从异常安全改造到 24 小时压力验证

5.1 统一封装服务句柄,消除裸 IntPtr 隐患

修复第一处泄漏时,我没有只针对CheckServiceStatus这一处做 try/finally,而是把所有涉及 SCM 句柄的地方都统一收口到一个类里。

原因很简单:像"打开句柄却不关闭"这种问题,靠人的记忆力去保证是不可靠的。你修了 A 函数,下周又有人写了一个 B 函数调OpenService,同样的坑可能再踩一次。最好的修法是从类型层面消灭裸句柄。

我定义了一个SafeServiceHandle:

internal sealed class SafeServiceHandle : SafeHandleZeroOrMinusOneIsInvalid { private SafeServiceHandle() : base(true) { } protected override bool ReleaseHandle() { return CloseServiceHandle(handle); } internal static SafeServiceHandle OpenSCManager() { var raw = NativeMethods.OpenSCManager(null, null, NativeMethods.SC_MANAGER_CONNECT); return new SafeServiceHandle { handle = raw }; } internal static SafeServiceHandle OpenService(SafeServiceHandle scm, string serviceName) { var raw = NativeMethods.OpenService(scm.DangerousGetHandle(), serviceName, NativeMethods.SERVICE_QUERY_STATUS); return new SafeServiceHandle { handle = raw }; } }

后面对QueryServiceStatus的调用,统一改成拿到SafeServiceHandle后使用using块处理。这样至少保证了一句铁律:句柄要么显式关闭,要么在 GC 回收时由 SafeHandle 兜底关闭,不存在第三条没人管理的路。

5.2 为共享内存写入加上独立的资源容器

修复第二处泄漏时,我不满足于简单地套using。因为 GODService 里不止一个方法会写共享内存,有些还带异常分支,有的会在写入后继续做一些后处理。我把"创建映射文件 -> 写入 -> 释放"封装成一个统一入口:

private static void WriteSharedState(Action<MemoryMappedViewAccessor> writeAction) { using (var mmf = MemoryMappedFile.CreateOrOpen("GODService_State", 4096)) { using (var view = mmf.CreateViewAccessor()) { writeAction(view); } } }

以后所有写共享状态的代码都走这一个方法。不管业务逻辑怎么加,using块保证了MemoryMappedFile和CreateViewAccessor必定成对释放。再配合代码评审规则:任何MemoryMappedFile的创建必须出现在同一个方法或同一个资源容器里,禁止把生命周期散落到其他方法。这样就把泄漏从结构上堵死了。

5.3 24 小时压力验证和修复前后指标对比

修复完成后,我在测试环境跑了一整天的压力测试。巡检任务从每秒一轮加快到每秒五轮,连续跑 24 小时。我每小时记录一次句柄数和池内存,重点看曲线形态。

修复前 12 小时的数据我留着做对照:

时间点修复前 Handle Count修复后 Handle Count修复前 NonPaged Pool修复后 NonPaged Pool
第 0 小时82081225MB24MB
第 4 小时4210840190MB26MB
第 12 小时10200866580MB27MB
第 24 小时198008551.15GB28MB

表格数据我做了脱敏和近似处理,但趋势是真实的:句柄数从持续上涨变成一条水平直线,池内存也一样。最直观的验证方式是停止 GODService,观察句柄数是否归零;再启动 GODService,观察句柄数是否稳定在一个常数附近。

上线后我盯着监控屏幕看了整整一个下午,确认 GODService 的内存占用连续过了四个小时基本不动,才把心放回肚子里。

5.4 上线后的长期监控:让下一次泄漏在发生前暴露

这次事故之后,我给 GODService 加了一套专门的监控规则,不再依赖人工发现:

  • 每 5 分钟采集一次Handle Count,如果连续 10 个采样点的增长斜率为正,触发预警;
  • \Memory\Pool Nonpaged Bytes超过基线 1.5 倍且持续上升,触发预警;
  • 每天凌晨对比 GODService 的Private Bytes基线和峰值,峰值偏离超过 30% 报警。

监控的阈值不能设得太绝对,因为不同机器负载差异很大。更好的做法是通过历史数据算基线,比如取最近 14 天的 P95 作为参考,超过参考值的持续增长告警才有意义。否则很容易被 Windows 自身的缓存管理、文件系统缓存波动引入大量误报,最后变成"狼来了"没人看。

6. 复盘提炼:Windows 常驻服务防内存泄漏的三条实操底线

6.1 现象分层的排查顺序:先看句柄,再看池,最后才看堆

这次排错让我彻底改变了内存泄漏的排查习惯。以前遇到内存涨,我第一个打开的就是 dotMemory 看托管堆。现在我会先按这个顺序走一遍:

  1. Handle Count是否随业务周期增长——如果涨,直接 Process Explorer 看句柄类型;
  2. 系统Pool Paged/Pool Nonpaged是否增长——如果涨,用 RAMMap + PoolMon 确认方向和来源;
  3. 最后才看进程的Private Bytes和托管堆。

这套顺序在 Windows 服务场景里特别管用,因为常驻服务的内核对象泄漏概率远高于纯托管堆泄漏。托管堆涨通常是对象引用问题,池内存涨通常是句柄问题。这两种问题的修法完全不同,先分层再定位,能省掉大量无效翻代码的时间。

6.2 代码层面的三条铁律

经过这次 GODService 的排错,我给自己的代码立下几条规矩,也给团队写进了编码规范:

  • 能不用裸 IntPtr 就不用。Win32 句柄一律封装成 SafeHandle 派生类型,让终结器兜底。
  • 创建了 IDisposable 对象的方法,必须用 using 或者 try/finally 保证释放,禁止把释放责任交给调用方。"谁申请谁释放"在服务进程里是底线。
  • 周期执行的业务链路,每次迭代结束后检查一次资源回调。最耗时的不是修复泄漏,而是不知道哪里漏了;如果能在每轮任务结束做一个资源自检,泄漏会在早期暴露。

6.3 一点工具使用的个人心得

工具方面,我再补几句实在话:

Process Explorer 是查句柄最快的手段,但别只盯着总数,要按类型排序。Service 句柄、File 句柄、Key 句柄、Etl 句柄对应的泄漏源完全不一样。看到数量异常的类型,再回源码里搜对应的 API 调用,命中率极高。

PoolMon 不适合当第一排查工具,适合当第二验证工具。它展示的是全局池标签,几十个标签里找异常,经验不足很容易懵。先通过 Process Explorer 锁定方向,再用 PoolMon 去验证池的增长来源,效率会高很多。

Windows 任务管理器在 Win11 上显示"池非分页"和"池分页",是个很有用的快速参考。但它仅有全局聚合值,没法告诉你哪个进程在漏。它只适合用来判断"系统是不是真的池泄漏了",不适合用来定位具体进程。

修复 GODService 这两处泄漏,代码改动量不到 30 行,但排查链路走了整整两天。回过头看,最值钱的不是那 30 行代码,而是把"现象"翻译成"问题"的过程——内存在涨只是现象,句柄数在涨才是问题,OpenService 后没有关闭才是根因。如果你下次也遇到常驻服务内存持续上涨的怪事,先别急着怀疑 GC,从句柄和系统池查起,大概率能少走一整天弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询