Windows 内存占用超 95%:百万级文件句柄泄漏的排查与修复
概述
Windows 内存占用过高,通常会先从进程内存排行入手。但进程列表中的内存数值,只反映了部分资源使用情况。面对持续出现的系统内存压力,还需要结合句柄数量、资源增长趋势和进程关系继续分析。
本文记录一次客户笔记本内存异常的排查与修复过程:从用户反馈出发,通过 PowerShell 筛选异常进程,再使用连续采样、Handle 和 Process Monitor 逐步缩小问题范围,最后通过结束相关异常进程恢复设备使用,并整理后续资源管理与回归验证建议。
文中的用户目录已统一脱敏为 CustomerUser。PID、时间和统计数值来自本次现场记录;复用命令时,应替换为当前设备上的实际 PID 和工具路径。
1. 问题描述
2026 年 8 月,有用户反馈,在公司生产销售的笔记本设备上,使用过程中出现了异常卡顿。用户随后打开任务管理器,发现系统内存占用持续超过 95%。
2. 运行环境
与本次排查有关的环境信息如下:
| 项目 | 情况 |
|---|---|
| 设备 | 客户正在使用的 Windows 笔记本 |
| 处理器 | Intel Core Ultra 5 226V(Lunar Lake) |
| 内存 | 16 GB |
| 排查方式 | 远程连接问题设备 |
| 应用环境 | 安装了 WorkBuddy,并运行多种日常办公应用 |
| 安全软件 | 安装了 McAfee DLP 数据防泄漏软件 |
3. 初步排查与问题推断
为确认持续高占用的来源,远程连接设备并检查系统与进程状态。本次排查使用 PowerShell 获取进程信息,使用 Sysinternals Handle 统计句柄类型,使用 Process Monitor(下文简称 ProcMon)观察文件和进程活动。涉及目标进程路径、模块和句柄的检查时,使用管理员权限运行工具。
3.1 结束高内存占用应用后的观察
初步排查时,发现某个应用的内存占用超过 1 GB,随后结束了该应用的进程。在确认其进程已经退出、占用的内存已经释放后,任务管理器显示的系统总体内存占用仍未下降。
这说明本次释放尚未缓解整机内存压力,需要继续检查其他进程及系统层面的资源使用情况。因此,后续排查扩大了观察范围,在进程内存之外,同时检查句柄数、线程数及其变化趋势。
3.2 查看进程内存和资源占用
通过 PowerShell 按进程私有内存排序,同时保留工作集、句柄数和线程数,观察是否存在突出的资源占用:
Get-Process |
Sort-Object PrivateMemorySize64 -Descending |
Select-Object -First 30 Name, Id,
@{Name='WorkingSet_GB';Expression={[math]::Round($_.WorkingSet64 / 1GB, 2)}},
@{Name='Private_GB';Expression={[math]::Round($_.PrivateMemorySize64 / 1GB, 2)}},
Handles,
@{Name='Threads';Expression={$_.Threads.Count}}
现场输出中,与后续分析有关的几行如下:
| 进程 | PID | 私有内存(GB) | 工作集(GB) | 句柄数 | 线程数 |
|---|---|---|---|---|---|
MsMpEng |
5464 | 1.33 | 1.23 | 7,191 | 52 |
Quicker |
20388 | 0.86 | 0.13 | 1,546 | 52 |
dwm |
1020 | 0.82 | 0.20 | 2,264 | 25 |
python |
19172 | 0.29 | 0.01 | 1,181,710 | 13 |
本文沿用现场输出的 MB、GB 标记,数值按 PowerShell 的 1MB、1GB 换算,分别对应 2²⁰、2³⁰ 字节。
这几项指标需要分开理解:
- 私有内存:
PrivateMemorySize64对应 Private Bytes,表示进程分配、不能与其他进程共享的内存,不等于当前驻留在物理内存中的容量。[1] - 工作集:反映进程当前驻留在物理内存中的可分页内存页,其中可以包含共享页。[2]
- 句柄数:表示进程持有的对象句柄数量,用于观察文件、事件、进程等资源的使用情况。
因此,仅凭上述私有内存排行,还不能确定整机物理内存占用的来源,也不能直接将各进程私有内存相加后与任务管理器的系统内存百分比比较。
3.3 从句柄数量提出排查假设
查看同一份输出时,注意到 PID 19172 的 python 进程虽然私有内存约为 0.29 GB,却持有超过 118 万个句柄,明显高于表中其他进程。
句柄数量与内存排查有关,是因为 Windows 需要为句柄表及其引用的内核对象维护资源。句柄长期未释放,可能使这些资源持续占用内存;这部分压力未必表现为该进程的私有内存同步增长。[3]
结合这个数量差异,初步考虑该 Python 进程可能存在资源未及时释放的问题。此时还需要回答三个问题:它属于哪个应用,句柄是否仍在增长,以及大量句柄主要指向哪类对象。
4. 验证与定位
4.1 确认进程归属和父子关系
python.exe 是通用进程名,仅凭名称无法判断它承担的任务。进一步查询可执行文件路径、父进程和创建时间:
$targetProcessId = 19172
$targetProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$targetProcessId"
$targetProcess |
Select-Object ProcessId, ParentProcessId, Name, ExecutablePath, CreationDate
if ($targetProcess) {
Get-CimInstance Win32_Process -Filter "ProcessId=$($targetProcess.ParentProcessId)" |
Select-Object ProcessId, ParentProcessId, Name, ExecutablePath, CreationDate
}
查询得到以下关系:
父进程:PID 43808
C:\Users\CustomerUser\.workbuddy\binaries\python\envs\pdfconv\Scripts\python.exe
└─ 子进程:PID 19172
C:\Users\CustomerUser\.workbuddy\binaries\python\versions\3.13.12\python.exe
两个进程记录的创建时间均为 2026-08-06 10:14:44。本次检查发生在次日下午,说明该进程已持续运行约一天。
父进程位于 WorkBuddy 的 pdfconv 环境,子进程位于其 Python 3.13.12 运行时目录。由此,确认异常进程属于 WorkBuddy pdfconv 环境下的 Python 进程链,后续检查继续针对 PID 19172 展开。
这里的路径用于确认进程所属环境,具体执行的业务任务还需结合命令行或业务日志核对。Windows 的 Python 虚拟环境使用重定向启动机制,这种父子 Python 进程结构可能属于正常启动行为。[4]
4.2 连续采样观察句柄变化
单次快照中的高值,只能说明当时持有的资源较多。为判断是否仍在积累,现场每隔约 5 秒采样一次,同时记录句柄数、私有内存和工作集。
以下为整理后的可复用采样命令:
$targetProcessId = 19172
for ($sampleIndex = 0; $sampleIndex -lt 12; $sampleIndex++) {
if ($sampleIndex -gt 0) {
Start-Sleep -Seconds 5
}
try {
$process = Get-Process -Id $targetProcessId -ErrorAction Stop
}
catch {
Write-Warning '无法读取目标进程,请确认进程是否已退出或 PID 是否变化。'
break
}
[pscustomobject]@{
Time = Get-Date -Format 'HH:mm:ss'
Handles = $process.HandleCount
Private_MB = [math]::Round($process.PrivateMemorySize64 / 1MB, 0)
Working_MB = [math]::Round($process.WorkingSet64 / 1MB, 0)
Threads = $process.Threads.Count
}
}
现场的 12 次采样结果如下:
| 时间 | 句柄数 | 私有内存(MB) | 工作集(MB) |
|---|---|---|---|
| 14:47:07 | 1,198,932 | 293 | 6 |
| 14:47:12 | 1,199,112 | 293 | 6 |
| 14:47:17 | 1,199,320 | 293 | 6 |
| 14:47:22 | 1,199,499 | 293 | 6 |
| 14:47:27 | 1,199,686 | 293 | 6 |
| 14:47:32 | 1,199,898 | 293 | 6 |
| 14:47:37 | 1,200,120 | 293 | 6 |
| 14:47:42 | 1,200,302 | 293 | 6 |
| 14:47:47 | 1,200,496 | 293 | 6 |
| 14:47:52 | 1,200,716 | 293 | 6 |
| 14:47:57 | 1,200,916 | 293 | 6 |
| 14:48:02 | 1,201,108 | 293 | 6 |
根据首尾时间和数值计算:
句柄净增 = 1,201,108 - 1,198,932 = 2,176
采样跨度 = 55 秒
平均增速 = 2,176 / 55 ≈ 39.56 个/秒
11 个相邻采样区间的句柄数全部上涨,而私有内存和工作集在当前取整精度下保持不变。由此确认,该进程在观察期间仍在持续积累句柄。
百万级存量与连续增长的现象支持资源泄漏方向。不过,判断资源是否泄漏,还应结合业务负载、任务结束后的回落情况及重复测试,不能仅以某个固定增速作为所有程序的通用判定标准。
4.3 使用 Handle 统计句柄类型
接下来需要确认该进程主要持有哪些类型的句柄,避免笼统地将所有句柄都理解为磁盘文件。
Handle 的 -s 参数用于按类型汇总句柄,-p 参数用于指定目标进程。[5]
以管理员身份打开 PowerShell,将工具路径替换为实际路径后执行:
$targetProcessId = 19172
& 'C:\Tools\Handle\handle64.exe' -s -p $targetProcessId
现场输出的关键统计为:
File : 1220152
Total handles : 1220414
File 类型占比为:
1,220,152 / 1,220,414 × 100% ≈ 99.9785%
Handle 统计发生在前述连续采样之后,此时总句柄数进一步增加,与此前观察到的持续增长趋势一致。这份统计说明,当时持有的句柄几乎全部属于 File 类型;前一组采样记录的是总句柄变化,两者共同将排查重点收敛到文件类资源的异常积累。
这里的 File 不只包括普通磁盘文件,还可能涉及管道和设备等 I/O 对象。Windows 的文件访问接口也用于打开这些对象,因此后续需要查看对象名称,才能进一步区分资源来源。[6]
此外,句柄数不等于不同文件数。重复打开同一文件,或复制、继承已有句柄,都可能增加句柄数量。[3]
5. 原因分析
5.1 继续检查文件活动和第三方模块
在明确文件类资源异常后,使用 ProcMon 查看目标进程的文件活动。现场记录中可以看到,该进程访问了 C:\Program Files\McAfee\DLP\Agent\ 目录下的 DLL,并出现 CreateFile、CloseFile 等操作。
这些记录说明目标进程访问了相关文件。为确认 DLP 模块是否实际加载到该进程中,继续枚举进程模块:
$targetProcessId = 19172
(Get-Process -Id $targetProcessId).Modules |
Where-Object {
$_.FileName -like '*\McAfee\DLP\Agent\*'
} |
Select-Object ModuleName, FileName,
@{Name='Version';Expression={$_.FileVersionInfo.FileVersion}}
结果如下:
ModuleName : fcagpph64.dll
FileName : C:\Program Files\McAfee\DLP\Agent\fcagpph64.dll
Version : 11.11.2.117
由此确认,McAfee DLP 模块确实加载在目标 Python 进程中,后续需要将它与该环境中的业务代码和依赖组件一起纳入分析。但模块加载不等于模块泄漏,访问某个 DLL 文件也不等于调用栈经过该模块。仅凭这些记录,还不能确定资源未释放发生在哪一层。
5.2 检查进程创建与资源回收的关系
ProcMon 记录中还出现了 WorkBuddy Python 相关的重复进程创建活动。结合异常进程来自 pdfconv 环境、句柄以 File 类型为主的情况,需要进一步检查子进程调度及其配套资源的生命周期。
例如,Python 使用 subprocess 创建子进程时,如果将 stdin、stdout 或 stderr 设置为 subprocess.PIPE,就会创建相应管道。如果任务结束、异常或取消时没有完成资源清理,就可能留下仍被持有的资源。[7]
这提供了一个具体的代码检查方向,但重复创建进程本身也可能属于正常任务调度。还需要结合每次创建对应的业务任务、退出情况及句柄变化,判断是否存在异常循环或回收遗漏。
5.3 根据现有证据缩小原因范围
截至这一阶段,已经确认了异常进程的归属、句柄持续增长的趋势,以及存量句柄以 File 类型为主的特征。异常集中在 WorkBuddy pdfconv 环境下的 Python 进程链中的 PID 19172,现有证据指向文件类句柄泄漏。
仍需继续区分的是具体的资源创建与释放路径:
| 排查方向 | 当前依据 | 进一步需要确认的内容 |
|---|---|---|
| 业务代码的资源生命周期 | 文件类句柄异常积累,并有进程创建活动 | 文件、管道和子进程相关资源是否在成功、失败、取消及超时路径中正确释放 |
| 业务组件 | 异常进程位于 WorkBuddy 的 pdfconv 环境 |
结合命令行和日志确认实际任务,再检查能否复现及是否与组件版本有关 |
| DLP 兼容性 | 目标进程加载了 DLP 模块 | 相同应用和输入下,受控调整 DLP 版本或策略是否改变句柄增长趋势 |
句柄统计也不能直接换算出泄漏占用了多少 GB 内存:不同对象的开销不同,多个句柄还可能引用同一对象。要解释其对整机内存的具体影响,需要同时记录系统可用内存、提交量和内核池在异常积累及进程退出前后的变化。
6. 修复过程与验证
排查至此,已初步定位到存在句柄异常积累的进程。由于客户需要继续使用设备,现场暂停了对具体泄漏原因的深入排查,优先恢复设备正常使用。
6.1 结束异常进程
在征得客户同意后,结束了第 4.1 节确认的两个进程:
| PID | 进程角色 |
|---|---|
| 43808 | 位于 WorkBuddy pdfconv 环境中的父进程 |
| 19172 | 持有百万级句柄、句柄数仍在持续增长的 Python 子进程 |
Windows 会在进程退出时关闭其持有的句柄,这为释放该进程持续持有的资源提供了处理途径。[3]
6.2 现场修复结果
结束两个进程后,通过任务管理器和后续使用情况核对处理效果:
| 观察项 | 处理前 | 处理后 |
|---|---|---|
| 系统内存占用 | 持续超过 95% | 立即降至约 80% |
| 后续占用趋势 | 持续处于高占用状态 | 未再出现持续高占用 |
| 使用体验 | 设备异常卡顿 | 卡顿消失 |
初步排查时,另一个高内存占用应用已退出且内存已释放,但系统总体占用未下降;此次结束已定位的父子进程后,系统内存占用和使用体验均得到改善。结合句柄持续增长和类型统计结果,进一步确认了本次故障与该进程链的异常资源占用有关,现场问题得到修复。
6.3 后续分析与改进建议
现场故障修复后,针对程序实现的改进可以沿以下方向继续开展:
- 定位资源来源:核对进程命令行和业务日志,将实际任务、句柄对象名称及调用栈对应起来,再通过受控对照区分业务代码、依赖组件和 DLP 的影响。
- 完善资源回收:检查文件、管道及子进程资源在成功、失败、取消和超时路径中的释放逻辑,根据定位结果实施代码修改或组件调整。
- 验证修改效果:在相同负载下观察任务结束后的资源基线是否稳定,同时确认持续高内存占用和卡顿不再复现。
具体采集步骤、对照测试、资源管理检查项及回归方法集中列于附录 A,供后续开发和复测使用。
7. 总结
本次排查将设备持续高内存占用和异常卡顿的问题,定位到 WorkBuddy pdfconv 环境下的 Python 进程链中的异常资源积累。PID 19172 持有百万级句柄,55 秒内总句柄净增 2,176 个,后续统计中的 File 类句柄占比约为 99.9785%。
在征得客户同意后,结束 PID 43808 和 PID 19172,系统内存占用立即由持续超过 95% 降至约 80%。后续持续高占用未再出现,卡顿消失,现场故障得到修复。
这次排查的可复用方法,是将进程内存、句柄数量和系统内存指标结合起来,逐步确认资源由谁持有、是否持续积累以及属于哪类对象,再通过处理后的资源变化和用户体验验证效果。
附录 A:进一步定位与回归验证
以下内容用于后续代码或组件改进,不属于本次现场已完成的操作。
A.1 对象与调用栈采集
在可复现环境中,可以按以下步骤补充对象和调用栈证据:
- 在 ProcMon 中按当前目标 PID 过滤;如果需要观察其新建子进程,同时结合进程树确认相关 PID。
- 从一次任务开始前采集到任务结束后的清理阶段,记录相应的句柄采样时间,保留文件系统及进程活动。
- 将 Handle 对象明细中的可疑文件、管道或设备名称,与 ProcMon 中的路径和操作结果对应起来。
- 查看相关事件的 Stack;必要时配置符号,结合业务日志和源码分析资源创建及清理路径。
- 保存原始 PML 和采样数据,便于重复比较。
ProcMon 支持事件调用栈、进程信息及过滤功能,适合将文件活动与执行上下文联系起来。[8]
分析时不能仅靠 CreateFile 与 CloseFile 的事件数量是否相等判断泄漏。采集窗口、操作是否成功、句柄继承或复制等都会影响观察结果;同样,某个模块出现在调用栈中,也不能单独证明它遗漏了资源释放。
A.2 对照测试
先结合命令行和业务日志确认目标进程承担的实际任务,再使用对应负载进行对照,尽量一次只改变一个条件:
| 对照项目 | 保持一致的条件 | 重点比较 |
|---|---|---|
| 空闲与执行业务任务 | 应用版本、设备、DLP 策略 | 增长是否由该任务触发 |
| 重复执行与任务结束后等待 | 输入文件、操作步骤、并发数 | 每轮任务后的句柄基线是否抬升,清理后是否回落 |
| 问题设备与正常设备 | 尽量统一应用、运行时、输入及策略,并记录系统差异 | 相同操作下的增长曲线是否存在差异 |
| 组件版本对照 | 设备、输入、负载和安全策略 | 组件版本变化是否影响资源积累 |
| DLP 版本或策略对照 | 应用、运行时、输入和负载 | DLP 条件变化是否稳定影响结果 |
涉及 DLP 策略调整的对照,应在安全团队认可的测试环境中进行。所有对照均需记录应用、Python、转换组件、Windows 和 DLP 版本,避免把环境差异误当成单一因素的影响。
A.3 资源管理检查
如果定位到应用或转换组件,应沿资源生命周期检查,而不只检查正常执行路径:
- 文件、PDF 文档、图片和临时文件对象,在使用结束后及时关闭或释放。
- 异常、取消、超时和部分初始化失败时,执行对应的清理逻辑。
- 使用子进程时,同时管理进程生命周期及标准输入、输出、错误流等资源。
- 检查资源是否被重复打开、复制或继承,以及原持有者和新持有者是否各自完成清理。
对于 Python subprocess.Popen,需要特别留意管道和等待方式。使用 stdout=PIPE 或 stderr=PIPE 时,如果输出填满管道而父进程只调用 wait(),可能发生阻塞;应根据输出规模选择 communicate() 或持续读取的方式,并处理超时后的进程与管道清理。Popen 支持上下文管理,但业务仍需明确超时和取消策略。[7]
如果后续对照确认与特定转换组件或 DLP 版本有关,再针对该组合评估升级、补丁或兼容性调整,并用相同负载复测。不能仅凭加载了 fcagpph64.dll 就直接认定应卸载或关闭 DLP。
对于暂时无法修复的第三方组件,可以评估将任务放入短生命周期 Worker,通过受控退出限制累计资源规模。句柄监控与 Worker 重建可作为过渡措施,阈值应根据正常业务基线确定。
A.4 系统指标与回归验证
除复用第 4 节的进程采样外,可以在任务开始前、执行期间、结束后及组件重启前后记录系统指标:
$os = Get-CimInstance Win32_OperatingSystem
$memory = Get-CimInstance Win32_PerfFormattedData_PerfOS_Memory
[pscustomobject]@{
Time = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
TotalRAM_GB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
Available_GB = [math]::Round($memory.AvailableMBytes / 1024, 2)
Committed_GB = [math]::Round($memory.CommittedBytes / 1GB, 2)
CommitLimit_GB = [math]::Round($memory.CommitLimit / 1GB, 2)
PagedPool_GB = [math]::Round($memory.PoolPagedBytes / 1GB, 2)
NonPagedPool_GB = [math]::Round($memory.PoolNonpagedBytes / 1GB, 2)
}
其中,TotalVisibleMemorySize 的原始单位为 KB,所以代码除以 1MB 后得到 GB。
提交量与物理内存占用采用不同的统计口径。分页池和非分页池应分别观察其变化,不宜将这些指标与进程私有内存简单相加,用来还原整机物理内存占用。两类内核池在内存驻留方式上有所不同。[9]
建议按以下条件验收:
| 验证项目 | 方法 | 预期结果 |
|---|---|---|
| 单次任务资源回收 | 比较任务前、执行中和清理后的句柄数 | 临时资源按预期释放 |
| 重复任务稳定性 | 相同输入与并发条件下反复执行 | 任务结束后的资源基线趋于稳定,无持续累计 |
| 异常路径 | 测试失败、取消、超时及异常输入 | 清理逻辑同样生效,无持续遗留资源 |
| 持续运行 | 覆盖原故障出现所需的时间和任务规模 | 不再复现原有积累趋势 |
| 整机内存 | 相同业务负载下,同步观察可用内存、提交量和内核池 | 原先的持续高内存占用不再复现 |
| 使用体验 | 按客户原有方式使用设备 | 原先的异常卡顿不再复现 |
| 业务功能 | 检查业务输出、文件释放和安全策略 | 功能正常,无新增文件锁定或兼容性问题 |
资源曲线不必完全水平,正常任务和缓存也会产生波动。验收重点是相同负载下,资源能否按生命周期回收,长期基线是否稳定,以及用户原先的问题是否消失。
附录 B:参考文献
- Microsoft Learn:Process.PrivateMemorySize64:用于区分私有内存与其他内存指标。
- Microsoft Learn:Working Set:说明工作集与物理内存驻留的关系。
- Microsoft Learn:Kernel Objects:说明句柄、对象、内存及对象生命周期之间的关系。
- Python 3.13 文档:venv — Creation of virtual environments:Windows 虚拟环境的重定向启动机制。
- Microsoft Sysinternals:Handle:句柄枚举与类型统计工具说明。
- Microsoft Learn:CreateFileW:说明文件接口支持的文件、管道和设备等对象。
- Python 3.13 文档:subprocess — Subprocess management:子进程、管道、等待和超时处理说明。
- Microsoft Sysinternals:Process Monitor:事件过滤、进程信息及调用栈功能说明。
- Microsoft Learn:Memory Pools:分页池与非分页池的定义。

浙公网安备 33010602011771号