ARTICLE DETAIL

资讯详情

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

Windows常驻服务内存泄漏:分页缓冲池与非分页缓冲池同步上涨的句柄泄漏排查

Windows常驻服务内存泄漏:分页缓冲池与非分页缓冲池同步上涨的句柄泄漏排查 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好处是不用装额外agentGet-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没有包装成SafeHandleGC是不知道你需要释放这个句柄的。结果是托管堆很干净非分页池却一路狂飙。排查这类问题务必先看有没有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的距离。
返回列表