1. 事故第一现场:内存只涨不降,而且涨的是缓冲池
1.1 凌晨收到的那条告警
GODService是我们内部一套用C++写的常驻后台服务,负责业务事件的分发和状态同步,部署形态是每台机器一个实例,7x24小时跑。它平时很稳,CPU占用常年个位数,内存也就是启动后稳定在200MB左右。所以当凌晨监控弹出“可用内存持续下降,GODService进程内存连续72小时只升不回”的时候,运维第一反应是“是不是某条业务死循环了”。
我登录上去看了一眼任务管理器,确实不对劲。进程的“内存(活动)”值已经涨到1.6GB,而且每分钟都在跳。更扎眼的是下面的内存区域——分页缓冲池和非分页缓冲池两个数字都在同步爬升,非分页缓冲池尤其夸张,从正常的300MB左右一路涨到了接近1.2GB。
这里先插一句基础知识:Windows内核里,内核态和驱动申请的内存分成两档,一个是分页缓冲池,可以被换出到磁盘;另一个是非分页缓冲池,任何时候都必须留在物理内存里,供中断、DPC这些不能等磁盘I/O的路径使用。任务管理器里那两项指标显示的就是这两类内核态内存的总用量。
很多人第一反应是“GODService是不是申请了很大一块堆没释放”,但堆内存大部分是用户态的私有字节,不会直接反映在分页和非分页缓冲池里。看到两个缓冲池同时涨,方向就完全变了——这更像是内核对象、句柄这类资源在持续堆积。这不是堆的问题,是资源句柄的问题。
1.2 为什么我第一反应不是“堆泄漏”
在有GC机制的环境里呆久的人,看到一个进程内存涨,第一个念头会去看托管堆有没有回收;但对于C++写的常驻服务,内存涨的原因是很多的:堆碎片、内存池膨胀、缓存无上限、句柄泄漏、线程对象残留,每一样的表现都不一样。
我能快速排除“堆泄漏”,靠的是一个细节:分页缓冲池和非分页缓冲池是内核态的概念,GODService作为一个普通用户态进程,它自己几乎不可能直接往这两个池里分配内存。它能影响这两个池的途径只有一个——不断创建内核对象、不断往进程句柄表里塞东西。每建一次Event、File、Mutex、Semaphore,对象管理器就要在非分页池里放对象头,在分页池里维护句柄表条目。句柄越多,两个池涨得越凶。
所以当时的判断是:这不是内存“变大”的问题,而是某段代码在不停“造东西”但没销毁。常见的嫌疑是事件对象、文件句柄、注册表键、线程内核对象、GDI/USER对象这几类。接下来要做的不是翻代码,而是先把“是哪一类句柄”确认下来。
1.3 分页缓冲池与非分页缓冲池在Win11上怎么看
顺带说下工具问题。Win11的任务管理器改版之后,性能页面的内存详情变得很简洁,“分页缓冲池/非分页缓冲池”这两个字段藏在很隐蔽的位置,普通用户基本看不到。我平时更习惯用性能监视器(perfmon.msc)直接加计数器,或者用Sysinternals RAMMap看“Pool”标签页。
要用到的核心计数器就四个:
| 计数器 | 作用 |
|---|---|
| \Process(GODService*)\Handle Count | 看进程句柄数是否只涨不降 |
| \Process(GODService*)\Private Bytes | 看用户态堆内存增长 |
| \Memory\Pool Nonpaged Bytes | 看非分页缓冲池总量 |
| \Memory\Pool Paged Bytes | 看分页缓冲池总量 |
注意,前两个是进程维度的,后两个是系统维度的。系统维度计数器会上涨的不只是我们这一个进程,但如果全机器其他进程都安静,就它一个在疯狂刷句柄,那么系统级池数值也能反推出来。
2. 缩小包围圈:句柄数和缓冲池一起爬升
2.1 三个计数器的走势图说明了一切
我在监控机上拉了两天的历史曲线,做了个对照:GODService的Private Bytes涨得不算快,但从启动开始就没有任何回落的迹象;而Handle Count从启动时的2000左右一路涨到接近15万,几乎是一条笔直的上升线。再叠加系统级的Pool Nonpaged Bytes,三根线在时间维度上完全同步。
这个现象基本能锁定方向:句柄泄漏。因为如果是单纯的内存泄漏,Private Bytes会有一个很陡的上升趋势,而Handle Count不会同步变化;现在句柄数同步上天,说明泄漏的粒度和“每次循环/每次请求造一个对象”高度相关。
这里有个判断口诀我用了很多年:内存涨,句柄不涨,优先查堆和缓存;内存涨,句柄也涨,基本就是资源没释放。反过来,句柄涨但内存不涨,其实更危险,因为它量级更隐蔽,等你发现时可能已经逼近系统上限了。
2.2 句柄表为什么会同时压到两个缓冲池
很多人不理解,为什么GODService一个用户态进程的句柄泄漏,能让Win11的“分页缓冲池”和“非分页缓冲池”同时上涨。这要回到Windows对象管理器的工作方式。
进程创建第一个内核对象时,系统会为它建立一张句柄表。句柄表本身是分页的,里面存放句柄条目,比如对象指针、访问掩码、属性标记,这些数据落在分页缓冲池里。而句柄对应的内核对象本体,比如一个Event对象,它的对象头和核心数据结构是需要常驻物理内存的,因为它们要参与等待调度和事件通知,不能被换出——所以从非分页缓冲池分配。
每次CreateEvent成功,等于同时向分页池和非分页池各借了一块地。CloseHandle就是还地。如果你光借不还,两个池都会涨。所以GODService在Win11上表现出来的“分页缓冲池和非分页缓冲池内存泄漏”,本质上是同一个句柄泄漏的一体两面。
2.3 当时做的两个排除试验
锁定方向后,我做了两个快速排除试验,目的是确认不是别的东西在捣乱。
第一个试验是把Process Explorer里的列调出来,加了User Objects、GDI Objects、Handles三列,观察GODService的GDI对象和用户对象是否同时增长。结果:GDI Objects稳定在一个值附近,User Objects也只有小幅波动,只有Handles在飞速上涨。这说明问题出在内核对象句柄,不是GUI相关资源。
第二个试验更狠一点:我在一台测试机上直接把GODService进程杀掉再重启。如果是堆缓存膨胀,重启后内存会回落到正常水平,但运行一段时间后又会慢慢涨;如果是文件映射或者磁盘缓存问题,DropCache之后可能会缓解。而试验结果是,重启后句柄数回到基线,运行一个小时后又开始一路向上,斜率基本和之前一致。这说明代码路径是每次运行时都在反复触发,不是偶发性的。
到这里,问题已经很明确:GODService进程内部存在规律性的句柄创建,并且没有对应释放。接下来要找出是哪一行代码。
3. 用windbg的htrace把泄漏代码揪出来
3.1 为什么选htrace而不是直接翻代码
团队里有人提了个建议:直接用代码平台搜索看一下有没有忘了CloseHandle的地方。这思路没错,但GODService是一个几万行代码的工程,涉及插件通信、超时调度、文件监听、网络连接池,手动扫一遍少说也得大半天,而且像句柄泄漏这种问题,单看代码不一定看得出来——因为“这块代码看着有释放,其实在一个异常分支里绕过去了”的情况太常见了。
我更推荐动态追踪:直接问系统,哪些句柄被创建了,创建时的调用栈是什么,最后有没有被关闭。Windows调试工具的!htrace命令就是干这个的。它会记录进程里句柄操作的调用栈,通过两次快照对比,能精确列出“这个时间段内创建但没关闭的句柄清单”。
3.2 完整操作路径
用任务管理器转储之类的方法都不够,我当时的操作是这样的,你可以在自己的环境里复现:
!gflag +htrace !htrace -enable这两条命令在windbg里对live进程执行即可。!gflag +htrace是打开当前进程的句柄追踪标志,!htrace -enable开始记录句柄操作。需要注意,开启追踪是有性能开销的,所以不要在全部生产机上都开,最好找一台流量较小的灰度机,确认问题存在但不会影响业务。
等GODService运行一段时间,比如15分钟或者一个小时,先做第一次快照:
!htrace -snap快照做完之后再等一段时间,让泄漏继续累积。然后再做第二次快照:
!htrace -snap !htrace -diff!htrace -diff会把两次快照之间新增的句柄操作列出来,包含创建句柄的完整调用栈。看到的就是这段窗口期内创建的、尚未关闭的句柄指纹。
3.3 快照对比中看到的调用栈
diff的结果出来时,数据非常明显。新句柄几乎都集中在一个模块里,调用栈反复出现这样的路径:
kernel32!CreateEventW godservice!CFsmTimer::CheckPluginHeartbeat godservice!CWorkerThread::ProcessTimeoutQueue也就是说,每一次心跳检测超时,代码都会创建一个Event,然后这个Event没有在等待结束后被关掉。反过来看那些正常的执行路径,在信号正常返回的case下是有CloseHandle的,只有超时分支漏了。这正好解释了为什么监测期间GPK包越多、超时越频繁,内存涨得越快;半夜业务低峰,超时少,但第二天高峰一到,又一轮快速爬升。
我后来还在dumps里用!handle扫过进程句柄列表,看到大量类型为Event的句柄,对象名基本都是空的,确认无误。
4. 根因:事件句柄在超时分支上没关
4.1 触发场景和当时的代码写法
把调用栈对应回代码,问题很快就暴露了。GODService里有一条插件心跳检查逻辑,负责定期确认外部组件的存活状态。组件的心跳信号是动态注册的一次性通知,所以代码不能直接复用一个全局事件,只能每次检测时创建一个临时事件对象,等插件上报或者超时,然后释放。
下面是精简过的原始写法,相信不少做过Windows服务开发的人看一眼就会有共鸣:
void CWorkerThread::CheckPluginHeartbeat() { HANDLE hEvent = ::CreateEventW(nullptr, TRUE, FALSE, nullptr); if (hEvent == nullptr) { return; } DWORD dwWait = ::WaitForSingleObject(hEvent, 3000); if (dwWait == WAIT_TIMEOUT) { // 插件没在3秒内上报心跳,跳过本次检查 continue; // 问题就出在这里,句柄没有关闭 } if (dwWait == WAIT_OBJECT_0) { ::ResetEvent(hEvent); // 这里处理插件上报的数据 } ::CloseHandle(hEvent); }这段代码写的人当时应该也认真考虑过资源释放,因为正常分支和信号到达分支都有关闭操作,唯独超时分支里写了个continue提前跳出。手一抖,一个CloseHandle就漏掉了。
4.2 修复前的资源生命周期分析
我后来又把问题代码的运行路径完整捋了一遍,发现这个泄漏的放大效应比单看代码更严重。
正常逻辑下,插件如果在3秒内上报心跳,WaitForSingleObject返回WAIT_OBJECT_0,代码会走处理流程,然后CloseHandle,万事大吉。但一旦插件响应慢或者网络抖动,WaitForSingleObject在3秒后返回WAIT_TIMEOUT,代码立刻continue,跳到循环的下一次迭代,重新CreateEvent。每次超时都创建一个新Event对象,但旧对象永远留在句柄表里。
这个场景有多频繁呢?心跳检查周期是5秒,而GODService同时管理几十个插件维度,每个维度都要做一次检查。如果一个周期里有10个插件超时,一分钟就是120个泄漏句柄,一天就是17万左右。你想象一下,任何一个Windows常驻服务,如果每天往非分页池里塞十几万个Event对象,内存不炸才怪。
而且这种泄漏还有个隐蔽性:它平时不会立刻暴露,服务刚启动的前几小时一切正常,内存增长也很平缓。可只要业务波动一次、超时频率上来,斜率立刻变陡。因为和业务高峰期强相关,很容易被误读成“业务量大了,内存占用高正常”,这也是很多内存泄漏线报一直没被重视的原因。
4.3 修复后的正确写法
修复本身并不复杂,重点不是补一行CloseHandle,而是改变资源管理的习惯。我的修复版本长这样:
void CWorkerThread::CheckPluginHeartbeat() { wil::unique_handle hEvent(::CreateEventW(nullptr, TRUE, FALSE, nullptr)); if (!hEvent) { return; } DWORD dwWait = ::WaitForSingleObject(hEvent.get(), 3000); if (dwWait == WAIT_TIMEOUT) { // 插件没心跳,跳过本次,资源由 RAII 自动释放 continue; } if (dwWait == WAIT_OBJECT_0) { ::ResetEvent(hEvent.get()); // 正常处理 } }核心改动是引入了RAII封装,让句柄的生命周期跟着局部作用域走。无论函数从哪里return,无论是continue还是break还是异常,只要出了作用域,析构函数会统一调用CloseHandle。这是C++资源管理最朴素也最有效的方案。
如果你用的是正规Windows C++开发环境,微软提供的wil库可以直接用wil::unique_handle;不想引库的话,自己封装一个SafeHandle模板类也没问题。总之原则只有一条:谁创建,谁负责,并且要在所有出口都保证释放。
顺带建议:这类事件检测逻辑其实也没必要每次循环都新建Event,更好的设计是复用一个常驻信号对象,配合ResetEvent和WaitForSingleObject,这样连“反复创建”这个成本都省了。不过这是事后重构话题了,先把泄漏堵住才是首要任务。
5. 回归验证与上线策略:观察哪几个指标
5.1 灰度期间的对照表格
修复完成后,我没有直接全量发布,而是选了两台机器做灰度对照:一台保留旧版本继续跑,一台部署新版本。然后对比24小时和48小时的关键指标。
下面这个表是灰度测试后的实际数据整理:
| 指标 | 旧版本(修复前48h) | 新版本(修复后48h) |
|---|---|---|
| 进程句柄数 | 稳定上升,约每秒新增2-3个 | 启动后爬升到3000左右,然后回落稳定 |
| Private Bytes | 以约200MB/天的速度增长 | 基本稳定,波动幅度在几十MB以内 |
| Pool Nonpaged Bytes | 线性上涨,48小时增加约660MB | 小范围波动,无持续上涨趋势 |
| Pool Paged Bytes | 同步上涨 | 略有波动,无异常爬升 |
| 插件处理队列 | 周期性堆积 | 无堆积,延迟恢复正常 |
最直观的差异就是句柄数。旧版本是新版本一晚涨好几万,始终不回落;新版本启动时会有一个正常预热的过程,句柄数涨到几千后开始出现明显的锯齿形——上涨一批、释放一批、再上涨。看到锯齿形,就说明释放动作已经恢复了。
5.2 “先涨后稳”是健康信号
这里想单独说一个观察结论:修复后的服务启动初期,内存和句柄数会有一个快速上升的阶段,不懂的人可能会误以为“还在泄漏”。其实这是正常的预热过程。
GODService启动时要建立插件连接池、读取配置、注册各种通知事件,这些对象都是需要创建的。只要上升曲线到达一个平台后开始震荡,而不是继续走高,就是健康的。判断泄漏与否,关键不在绝对值大小,而在斜率有没有归零。
我在灰度测试时专门看了第二天和第三天的数据,句柄数始终维持在同一区间上下波动,没有出现第二天比第一天高一截、第三天再高一截的情况。Pool Nonpaged Bytes也稳定在固定区间。到这里,才能说修复有效。
5.3 回滚预案和长稳验证
因为GODService涉及业务状态同步,我还定了两个保底措施。第一,灰度机保留旧版本镜像,一旦新版本出现异常,可以半小时内切回;第二,把修复版本压在一个模拟仿真环境里连续跑七天,模拟高峰流量和插件大量超时的场景。
长稳验证期间,我额外加了一个自动化监控脚本,用的是Windows自带的Performance Counter,好处是不用装额外agent:
Get-Counter @( '\Process(GODService*)\Handle Count', '\Process(GODService*)\Private Bytes', '\Memory\Pool Nonpaged Bytes', '\Memory\Pool Paged Bytes' ) -SampleInterval 30 -MaxSamples 100脚本每30秒采集一次,连续采集100次,输出到CSV后直接用Excel画趋势图,一眼就能看出有没有新的上涨斜率。这套脚本我现在还留在团队的运维工具箱里,后来排查别的问题也反复用到。
6. 复盘:Windows常驻服务还有哪些池泄漏伪装术
6.1 容易被误判的几种池泄漏
GODService这次是Event句柄泄漏,但Windows常驻服务里的池泄漏还远不止这一种。我借这个机会把过去踩过的坑一起复盘一下,方便你在排查时少走弯路。
第一种是GDI/USER对象泄漏。这类泄漏在任务管理器里通常表现为分页缓冲池上涨,但Handle Count不一定涨得凶,因为GDI句柄走的是另一套句柄表。排查工具要用Process Explorer单独看GDI Objects列,或者用!gditable命令检查GDI句柄表。
第二种是线程对象泄漏。每次创建线程,系统都会创建线程内核对象,保护结构落在非分页缓冲池。如果你发现非分页池上涨,但句柄数稳定,可以数一下进程里的线程数。很多隐藏的线程池泄漏就是这样,代码里_beginthreadex开了线程,却没有在所有出口都做WaitForSingleObject和CloseHandle。
第三种是Timer队列泄漏。Windows的Timer Queue Timer创建后没有调用DeleteTimerQueueEx,或者每次CreateThreadPoolTimer都新分配一个上下文,也会造成分页池上涨。这类问题在代码静态扫描里经常发现不了,因为创建和释放可能分布在不同的类里。
还有一种最容易搞的乌龙是.NET/P/Invoke场景。GODService虽然是C++,但团队里还有其他语言写的Windows服务。用C#调Win32 API时,如果返回的IntPtr没有包装成SafeHandle,GC是不知道你需要释放这个句柄的。结果是托管堆很干净,非分页池却一路狂飙。排查这类问题务必先看有没有SafeHandle,再看有没有漏掉的CloseHandle。
6.2 把句柄审计做成日常能力
修复GODService用到的这一整套排查链路,其实可以沉淀成团队日常能力。我现在到一个新环境处理Windows常驻服务内存问题的固定套路是:
- 先看三件事:Handle Count、GDI Objects、线程数。这三项定性快,60秒内就能判断是不是资源泄漏。
- 再看系统级内存:Pool Nonpaged Bytes和Pool Paged Bytes,重点观察斜率而不是绝对值。
- 最后用windbg的!htrace或其他穷举工具抓调用栈,定位具体代码位置。
同时强烈建议把“进程句柄数”加入监控告警项。内存使用率可以靠物理内存和业务负载兜底,但句柄数是硬指标,Windows对每个进程的句柄数限制是明确的,一旦接近上限,不只是内存爆掉的问题,整个服务都会处于卡死状态。
6.3 这次踩完坑,我留下的三条硬规矩
第一,所有涉及内核对象的代码,一律不写裸句柄。要么用RAII封装,要么用SafeHandle,严禁在业务函数里直接传递HANDLE变量。这次事件证明,人的记忆会出错,但作用域边界不会。
第二,超时分支是资源管理的重灾区。不管是CreateEvent、CreateFile还是WaitForSingleObject,凡是带超时参数的逻辑,写完之后就要立刻检查超时返回路径上有没有留下未释放的资源。可以把这段检查当成和空指针判断一样的必备动作,时间久了就养成肌肉记忆。
第三,定期拿生产环境的一个副本开启htrace跑一遍。不需要每个版本都跑,但任何涉及线程、事件、信号量、定时器的大版本变更,都应该做一次句柄级回归。成本不高,却能避免内存泄漏这种慢性病拖到线上暴发。
这次GODService的修复报告写到最后,我最想说的一句话是:Windows服务的“内存泄漏”四个字,很多时候是表象;真正的病灶藏在句柄、线程和内核对象里。多看一眼分页缓冲池和非分页缓冲池的斜率,比盯着任务管理器里那个不断翻新的MB数字要靠谱得多。至少对这个服务来说,从“看到缓冲池涨”到“找到那一行continue”,中间只隔了一条!htrace -diff的距离。