Windows 11 句柄审查与泄漏定位教程
纯 PowerShell 通用版
适用系统:Windows 10 / Windows 11
适用环境:Windows PowerShell 5.1、PowerShell 7
建议:使用“管理员身份”打开 PowerShell
一、句柄是什么
Windows 程序访问文件、注册表、进程、线程、设备等系统对象时,通常通过“句柄”完成访问。
常见句柄对象包括:
- 文件和目录
- 注册表项
- 进程和线程
- 事件对象 Event
- 互斥体 Mutex
- 信号量 Semaphore
- 管道 Pipe
- 共享内存 Section
- 访问令牌 Token
- 网络和设备对象
正常程序会打开对象、使用对象,再关闭句柄。程序持续创建句柄却没有正确关闭,就可能出现句柄泄漏。
典型表现:
10:00 Handles=3000
10:05 Handles=5000
10:10 Handles=8000
10:15 Handles=12000
判断句柄泄漏最重要的不是某一时刻的绝对值,而是:
在系统空闲或相同工作负载下,句柄是否持续单向增长,并且长时间不回落。
二、句柄多少才算异常
Windows 没有适用于所有电脑的固定异常阈值。
| 情况 | 参考判断 |
|---|---|
| 总句柄几万 | 轻度使用时常见 |
| 总句柄十几万 | 浏览器、IDE、虚拟机、后台软件较多时可能正常 |
| 总句柄二三十万 | 建议审查,但不代表一定泄漏 |
| 总句柄持续上涨 | 比绝对值更值得怀疑 |
| 单个普通程序几千句柄 | 需要结合程序类型判断 |
| 单进程数万句柄且持续上涨 | 高度可疑 |
| 同名进程数量不断增加 | 可能是进程增殖,不一定是单进程泄漏 |
| 重启后恢复低位,随后持续上涨 | 符合泄漏或后台任务堆积特征 |
浏览器、数据库、虚拟机、杀毒软件、IDE、WebView2 应用本来就可能持有较多句柄,因此不要仅凭“句柄很多”直接结束进程。
三、快速检查系统总量
$processes = Get-Process -ErrorAction SilentlyContinue
[PSCustomObject]@{
Time = Get-Date
Processes = $processes.Count
Handles = ($processes | Measure-Object HandleCount -Sum).Sum
Threads = (
$processes |
ForEach-Object {
try { $_.Threads.Count } catch { 0 }
} |
Measure-Object -Sum
).Sum
MemoryGB = [math]::Round(
($processes | Measure-Object WorkingSet64 -Sum).Sum / 1GB,
2
)
} | Format-List
最简总句柄命令:
(
Get-Process -ErrorAction SilentlyContinue |
Measure-Object HandleCount -Sum
).Sum
四、单进程句柄排行榜
Get-Process -ErrorAction SilentlyContinue |
ForEach-Object {
[PSCustomObject]@{
PID = $_.Id
Process = $_.ProcessName
Handles = $_.HandleCount
Threads = try { $_.Threads.Count } catch { 0 }
MemoryMB = [math]::Round($_.WorkingSet64 / 1MB, 1)
CPU = try { [math]::Round($_.CPU, 1) } catch { 0 }
}
} |
Sort-Object Handles -Descending |
Select-Object -First 30 |
Format-Table -AutoSize
重点观察:
- 是否有普通程序达到数万句柄;
- 是否有进程线程数异常高;
- 是否有进程内存、线程和句柄同步上涨;
- 是否存在大量同名进程。
五、按程序名称汇总多实例
浏览器、WebView2、Electron、QQ、IDE、网盘等通常会拆分为大量子进程。单个进程不高,不代表程序族合计不高。
Get-Process -ErrorAction SilentlyContinue |
Group-Object ProcessName |
ForEach-Object {
$group = $_.Group
[PSCustomObject]@{
Process = $_.Name
Instances = $group.Count
Handles = ($group | Measure-Object HandleCount -Sum).Sum
Threads = (
$group |
ForEach-Object {
try { $_.Threads.Count } catch { 0 }
} |
Measure-Object -Sum
).Sum
MemoryMB = [math]::Round(
($group | Measure-Object WorkingSet64 -Sum).Sum / 1MB,
1
)
}
} |
Sort-Object Handles -Descending |
Select-Object -First 30 |
Format-Table -AutoSize
六、判断句柄泄漏还是进程增殖
单进程句柄泄漏
特征:
同一个 PID
同一个启动时间
HandleCount 持续增长
进程增殖
特征:
单个进程句柄不高
同名进程数量持续增加
总句柄随之上涨
例如:
10:00 WebView2 实例=20
10:30 WebView2 实例=45
11:00 WebView2 实例=80
第二种情况应查明谁不断创建子进程,而不是只盯住某个 PID。
七、持续监控系统总句柄
按 Ctrl + C 停止。
while ($true) {
$processes = Get-Process -ErrorAction SilentlyContinue
$handles = ($processes | Measure-Object HandleCount -Sum).Sum
$threads = (
$processes |
ForEach-Object {
try { $_.Threads.Count } catch { 0 }
} |
Measure-Object -Sum
).Sum
"{0:yyyy-MM-dd HH:mm:ss} Processes={1} Threads={2} Handles={3}" -f `
(Get-Date),
$processes.Count,
$threads,
$handles
Start-Sleep -Seconds 10
}
正常情况通常会小幅上下波动。持续单向上涨且不回落才更可疑。
八、监控指定进程
假设目标 PID 为 12345:
$TargetProcessId = 12345
while ($true) {
$process = Get-Process -Id $TargetProcessId `
-ErrorAction SilentlyContinue
if ($null -eq $process) {
Write-Host "目标进程已经退出。"
break
}
[PSCustomObject]@{
Time = Get-Date
PID = $process.Id
Process = $process.ProcessName
Handles = $process.HandleCount
Threads = try { $process.Threads.Count } catch { 0 }
MemoryMB = [math]::Round($process.WorkingSet64 / 1MB, 1)
PrivateMB = [math]::Round($process.PrivateMemorySize64 / 1MB, 1)
} | Format-Table -AutoSize
Start-Sleep -Seconds 5
}
确认泄漏时要检查:
- PID 是否没有变化;
- 进程启动时间是否没有变化;
- 句柄是否持续增长;
- 工作完成后句柄是否仍不释放。
九、比较一分钟内谁增长最快
function Get-HandleSnapshot {
Get-Process -ErrorAction SilentlyContinue |
ForEach-Object {
$startTime = try {
$_.StartTime.ToString("o")
}
catch {
""
}
[PSCustomObject]@{
Key = "$($_.Id)|$startTime"
PID = $_.Id
Process = $_.ProcessName
StartTime = $startTime
Handles = [int64]$_.HandleCount
Threads = try { $_.Threads.Count } catch { 0 }
MemoryMB = [math]::Round($_.WorkingSet64 / 1MB, 1)
}
}
}
Write-Host "正在采集第一次快照……"
$before = Get-HandleSnapshot
Write-Host "等待 60 秒,请保持正常使用电脑……"
Start-Sleep -Seconds 60
Write-Host "正在采集第二次快照……"
$after = Get-HandleSnapshot
$beforeMap = @{}
foreach ($item in $before) {
$beforeMap[$item.Key] = $item
}
$after |
ForEach-Object {
if ($beforeMap.ContainsKey($_.Key)) {
$old = $beforeMap[$_.Key]
[PSCustomObject]@{
PID = $_.PID
Process = $_.Process
HandlesOld = $old.Handles
HandlesNow = $_.Handles
HandleDelta = $_.Handles - $old.Handles
ThreadsDelta = $_.Threads - $old.Threads
MemoryDeltaMB = [math]::Round(
$_.MemoryMB - $old.MemoryMB,
1
)
}
}
} |
Sort-Object HandleDelta -Descending |
Select-Object -First 30 |
Format-Table -AutoSize
经验判断:
| 一分钟句柄增量 | 说明 |
|---|---|
| 几个到几十个 | 常见正常波动 |
| 数百个 | 建议连续观察 |
| 数千个 | 高度可疑 |
| 连续多轮增长 | 很可能存在泄漏 |
| 增长后明显回落 | 可能是正常批量任务 |
建议连续执行三至五轮。
十、按程序族比较增长
function Get-GroupedHandleSnapshot {
Get-Process -ErrorAction SilentlyContinue |
Group-Object ProcessName |
ForEach-Object {
[PSCustomObject]@{
Process = $_.Name
Instances = $_.Count
Handles = ($_.Group | Measure-Object HandleCount -Sum).Sum
Threads = (
$_.Group |
ForEach-Object {
try { $_.Threads.Count } catch { 0 }
} |
Measure-Object -Sum
).Sum
}
}
}
$before = Get-GroupedHandleSnapshot
Start-Sleep -Seconds 60
$after = Get-GroupedHandleSnapshot
$beforeMap = @{}
foreach ($item in $before) {
$beforeMap[$item.Process] = $item
}
$after |
ForEach-Object {
$old = if ($beforeMap.ContainsKey($_.Process)) {
$beforeMap[$_.Process]
}
else {
$null
}
[PSCustomObject]@{
Process = $_.Process
InstancesNow = $_.Instances
InstanceDelta = if ($old) {
$_.Instances - $old.Instances
}
else {
$_.Instances
}
HandlesNow = $_.Handles
HandleDelta = if ($old) {
$_.Handles - $old.Handles
}
else {
$_.Handles
}
ThreadDelta = if ($old) {
$_.Threads - $old.Threads
}
else {
$_.Threads
}
}
} |
Sort-Object HandleDelta -Descending |
Select-Object -First 30 |
Format-Table -AutoSize
判断:
HandleDelta很高、InstanceDelta=0:更像现有进程内部泄漏;HandleDelta和InstanceDelta都很高:更像不断产生新进程。
十一、查看进程路径、命令行和父进程
$TargetProcessId = 12345
Get-CimInstance Win32_Process `
-Filter "ProcessId=$TargetProcessId" |
Select-Object `
ProcessId,
ParentProcessId,
Name,
ExecutablePath,
CreationDate,
CommandLine |
Format-List
父进程详情:
$TargetProcessId = 12345
$process = Get-CimInstance Win32_Process `
-Filter "ProcessId=$TargetProcessId"
if ($process) {
$parent = Get-CimInstance Win32_Process `
-Filter "ProcessId=$($process.ParentProcessId)"
[PSCustomObject]@{
ChildPID = $process.ProcessId
ChildName = $process.Name
ParentPID = $process.ParentProcessId
ParentName = $parent.Name
ParentPath = $parent.ExecutablePath
ParentCommand = $parent.CommandLine
} | Format-List
}
十二、查看完整进程祖先链
function Get-ProcessParentChain {
param(
[Parameter(Mandatory)]
[int]$ProcessId
)
$allProcesses = Get-CimInstance Win32_Process
$processMap = @{}
foreach ($process in $allProcesses) {
$processMap[[int]$process.ProcessId] = $process
}
$currentId = $ProcessId
$level = 0
while ($processMap.ContainsKey($currentId)) {
$current = $processMap[$currentId]
[PSCustomObject]@{
Level = $level
PID = $current.ProcessId
ParentPID = $current.ParentProcessId
Name = $current.Name
ExecutablePath = $current.ExecutablePath
CommandLine = $current.CommandLine
}
if ($current.ParentProcessId -eq 0) { break }
if ($current.ParentProcessId -eq $current.ProcessId) { break }
$currentId = [int]$current.ParentProcessId
$level++
}
}
Get-ProcessParentChain -ProcessId 12345 |
Format-Table -Wrap -AutoSize
十三、查看子进程
$TargetProcessId = 12345
Get-CimInstance Win32_Process |
Where-Object {
$_.ParentProcessId -eq $TargetProcessId
} |
Select-Object `
ProcessId,
ParentProcessId,
Name,
CreationDate,
ExecutablePath,
CommandLine |
Format-Table -Wrap -AutoSize
十四、分析 svchost.exe
svchost.exe 是 Windows 服务宿主,不要看到句柄高就直接结束。
查询指定 PID 承载的服务:
$TargetProcessId = 12345
Get-CimInstance Win32_Service |
Where-Object {
$_.ProcessId -eq $TargetProcessId
} |
Select-Object `
Name,
DisplayName,
State,
StartMode,
ProcessId,
PathName |
Format-Table -Wrap -AutoSize
查看所有运行中的服务和 PID:
Get-CimInstance Win32_Service |
Where-Object {
$_.State -eq "Running"
} |
Sort-Object ProcessId, Name |
Select-Object `
ProcessId,
Name,
DisplayName,
StartMode |
Format-Table -AutoSize
需要重启具体服务时:
Restart-Service -Name "服务名" -Force
不要随意停止关键系统服务。
十五、分析 System 进程
System 通常为 PID 4,代表大量内核和驱动活动。
Get-Process -Id 4 |
Select-Object `
Id,
ProcessName,
HandleCount,
Threads,
NonpagedSystemMemorySize64,
PagedSystemMemorySize64 |
Format-List
如果 System 句柄持续增长,常见嫌疑包括:
- 存储或 USB 驱动;
- 网卡、蓝牙、显卡驱动;
- 文件系统过滤驱动;
- 杀毒软件;
- VPN、虚拟网卡;
- 虚拟机;
- 磁盘或文件系统异常。
查看运行中的驱动:
Get-CimInstance Win32_SystemDriver |
Where-Object {
$_.State -eq "Running"
} |
Select-Object `
Name,
DisplayName,
StartMode,
State,
PathName |
Sort-Object DisplayName |
Format-Table -Wrap -AutoSize
纯 PowerShell 可以判断 PID 4 是否持续增长,但不能可靠显示每个内核句柄究竟属于哪个驱动对象。
十六、检查系统事件日志
查看最近两小时系统警告和错误:
$startTime = (Get-Date).AddHours(-2)
Get-WinEvent -FilterHashtable @{
LogName = "System"
StartTime = $startTime
Level = 2, 3
} -ErrorAction SilentlyContinue |
Select-Object `
TimeCreated,
ProviderName,
Id,
LevelDisplayName,
Message |
Format-Table -Wrap
筛选常见磁盘、USB 和驱动问题:
Get-WinEvent -FilterHashtable @{
LogName = "System"
StartTime = (Get-Date).AddHours(-6)
} -ErrorAction SilentlyContinue |
Where-Object {
$_.ProviderName -match `
"Disk|Ntfs|stor|USB|Kernel-PnP|DriverFrameworks|WHEA|volmgr"
} |
Select-Object `
TimeCreated,
ProviderName,
Id,
LevelDisplayName,
Message |
Format-Table -Wrap
十七、检查进程数字签名
$TargetProcessId = 12345
$process = Get-CimInstance Win32_Process `
-Filter "ProcessId=$TargetProcessId"
$process |
Select-Object `
ProcessId,
Name,
ExecutablePath,
CommandLine |
Format-List
if ($process.ExecutablePath) {
Get-AuthenticodeSignature `
-FilePath $process.ExecutablePath |
Select-Object `
Status,
StatusMessage,
SignerCertificate,
Path |
Format-List
}
未签名不等于恶意,有签名也不等于没有问题。更应关注异常路径、临时目录运行和名称伪装。
十八、检查 GDI 和 USER 对象泄漏
某些 GUI 程序会泄漏窗口、菜单、画刷、字体、设备上下文等资源。
先加载 API:
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class GuiResources
{
[DllImport("user32.dll")]
public static extern int GetGuiResources(
IntPtr hProcess,
int uiFlags
);
}
"@
查看排行榜:
Get-Process -ErrorAction SilentlyContinue |
ForEach-Object {
try {
[PSCustomObject]@{
PID = $_.Id
Process = $_.ProcessName
Handles = $_.HandleCount
GDIObjects = [GuiResources]::GetGuiResources($_.Handle, 0)
USERObjects = [GuiResources]::GetGuiResources($_.Handle, 1)
}
}
catch {
}
} |
Sort-Object GDIObjects -Descending |
Select-Object -First 30 |
Format-Table -AutoSize
GDI 或 USER 对象持续上涨时,可能出现窗口无法打开、菜单变黑、界面无法刷新、按钮和图标消失等现象。
十九、长期记录系统句柄
先创建目录:
$AuditRoot = Join-Path $env:USERPROFILE "Desktop\HandleAudit"
New-Item -ItemType Directory -Path $AuditRoot -Force | Out-Null
每 30 秒记录一次:
$LogPath = Join-Path `
$env:USERPROFILE `
"Desktop\HandleAudit\system_handle_log.csv"
while ($true) {
$processes = Get-Process -ErrorAction SilentlyContinue
$row = [PSCustomObject]@{
Time = Get-Date
Processes = $processes.Count
Handles = ($processes | Measure-Object HandleCount -Sum).Sum
Threads = (
$processes |
ForEach-Object {
try { $_.Threads.Count } catch { 0 }
} |
Measure-Object -Sum
).Sum
MemoryGB = [math]::Round(
($processes | Measure-Object WorkingSet64 -Sum).Sum / 1GB,
2
)
}
$row |
Export-Csv `
-Path $LogPath `
-NoTypeInformation `
-Encoding UTF8 `
-Append
$row | Format-Table -AutoSize
Start-Sleep -Seconds 30
}
查看最近记录:
Import-Csv $LogPath |
Select-Object -Last 20 |
Format-Table -AutoSize
二十、长期记录句柄最高的进程
$LogPath = Join-Path `
$env:USERPROFILE `
"Desktop\HandleAudit\top_process_handles.csv"
while ($true) {
$time = Get-Date
Get-Process -ErrorAction SilentlyContinue |
Sort-Object HandleCount -Descending |
Select-Object -First 30 |
ForEach-Object {
[PSCustomObject]@{
Time = $time
PID = $_.Id
Process = $_.ProcessName
Handles = $_.HandleCount
Threads = try { $_.Threads.Count } catch { 0 }
MemoryMB = [math]::Round($_.WorkingSet64 / 1MB, 1)
}
} |
Export-Csv `
-Path $LogPath `
-NoTypeInformation `
-Encoding UTF8 `
-Append
Write-Host "已记录:$time"
Start-Sleep -Seconds 30
}
二十一、检查开机启动项、服务和计划任务
开机启动项:
Get-CimInstance Win32_StartupCommand |
Select-Object `
Name,
Command,
Location,
User |
Sort-Object Name |
Format-Table -Wrap
运行中的服务:
Get-Service |
Where-Object {
$_.Status -eq "Running"
} |
Sort-Object DisplayName |
Format-Table Status, Name, DisplayName -AutoSize
运行中的计划任务:
Get-ScheduledTask |
Where-Object {
$_.State -eq "Running"
} |
Select-Object TaskPath, TaskName, State |
Format-Table -AutoSize
二十二、安全验证可疑程序
记录关闭前总句柄:
$before = (
Get-Process -ErrorAction SilentlyContinue |
Measure-Object HandleCount -Sum
).Sum
优先尝试正常关闭:
$TargetProcessId = 12345
$process = Get-Process -Id $TargetProcessId `
-ErrorAction SilentlyContinue
if ($process) {
$null = $process.CloseMainWindow()
}
必要时按 PID 强制结束:
Stop-Process -Id $TargetProcessId -Force
重新统计:
$after = (
Get-Process -ErrorAction SilentlyContinue |
Measure-Object HandleCount -Sum
).Sum
[PSCustomObject]@{
Before = $before
After = $after
Reduced = $before - $after
} | Format-List
关闭程序后句柄下降很多,说明它贡献较大,但不一定代表它存在泄漏,也可能只是正常占用较高。
二十三、不能随意结束的系统进程
一般不要直接结束:
System
Registry
smss.exe
csrss.exe
wininit.exe
winlogon.exe
services.exe
lsass.exe
dwm.exe
对 svchost.exe,应先查 PID 对应服务,不要批量结束。
explorer.exe 可以重启:
Stop-Process -Name explorer -Force
Start-Process explorer.exe
重启 Explorer 只能验证资源管理器或 Shell 扩展问题,不能修复内核驱动泄漏。
二十四、常见异常模式
模式一:单个 PID 句柄持续上涨
可能原因:
- 文件句柄未关闭;
- 注册表句柄未关闭;
- 同步对象泄漏;
- 插件或扩展泄漏;
- 程序自身缺陷。
模式二:同名进程越来越多
可能原因:
- 父程序不断拉起子进程;
- 子进程超时后未退出;
- 更新器反复启动;
- IDE、终端或脚本反复创建工具进程;
- 浏览器或 WebView2 宿主异常。
模式三:System 句柄上涨
重点怀疑:
- USB、磁盘或存储驱动;
- VPN、虚拟网卡;
- 杀毒软件;
- 虚拟机;
- 文件系统过滤驱动。
模式四:svchost 某个 PID 上涨
先查询该 PID 承载的具体服务,再针对服务排查。
模式五:Explorer 句柄上涨
重点怀疑:
- 右键菜单扩展;
- 网盘 Shell 扩展;
- 搜索工具;
- 压缩软件;
- 缩略图生成;
- 无响应的外接磁盘或网络路径。
模式六:浏览器或 WebView2 总量高
重点检查:
- 标签页和扩展;
- 后台运行设置;
- 使用 WebView2 的桌面应用;
- 是否有子进程残留;
- 同名进程实例是否持续增长。
二十五、一键审查脚本
将以下脚本保存为 Handle-Audit.ps1。
param(
[int]$ObserveSeconds = 60,
[int]$Top = 30,
[string]$OutputDirectory = (
Join-Path $env:USERPROFILE "Desktop\HandleAudit"
)
)
Set-StrictMode -Version Latest
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
function Get-FullProcessSnapshot {
$time = Get-Date
$cimProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue
$cimMap = @{}
foreach ($item in $cimProcesses) {
$cimMap[[int]$item.ProcessId] = $item
}
Get-Process -ErrorAction SilentlyContinue |
ForEach-Object {
$cim = $null
if ($cimMap.ContainsKey([int]$_.Id)) {
$cim = $cimMap[[int]$_.Id]
}
$creationTime = ""
if ($cim -and $cim.CreationDate) {
try {
$creationTime = ([datetime]$cim.CreationDate).ToString("o")
}
catch {
$creationTime = ""
}
}
[PSCustomObject]@{
SnapshotTime = $time
Key = "$($_.Id)|$creationTime"
PID = $_.Id
ParentPID = if ($cim) { $cim.ParentProcessId } else { 0 }
Process = $_.ProcessName
CreationTime = $creationTime
Handles = [int64]$_.HandleCount
Threads = try { [int64]$_.Threads.Count } catch { 0 }
WorkingSetMB = [math]::Round($_.WorkingSet64 / 1MB, 2)
PrivateMB = [math]::Round($_.PrivateMemorySize64 / 1MB, 2)
ExecutablePath = if ($cim) { $cim.ExecutablePath } else { $null }
CommandLine = if ($cim) { $cim.CommandLine } else { $null }
}
}
}
function Get-GroupedSnapshot {
param([Parameter(Mandatory)][array]$Snapshot)
$Snapshot |
Group-Object Process |
ForEach-Object {
[PSCustomObject]@{
Process = $_.Name
Instances = $_.Count
Handles = ($_.Group | Measure-Object Handles -Sum).Sum
Threads = ($_.Group | Measure-Object Threads -Sum).Sum
WorkingSetMB = [math]::Round(
($_.Group | Measure-Object WorkingSetMB -Sum).Sum,
2
)
PrivateMB = [math]::Round(
($_.Group | Measure-Object PrivateMB -Sum).Sum,
2
)
}
}
}
function Get-SystemSummary {
param([Parameter(Mandatory)][array]$Snapshot)
[PSCustomObject]@{
Time = Get-Date
Processes = $Snapshot.Count
Handles = ($Snapshot | Measure-Object Handles -Sum).Sum
Threads = ($Snapshot | Measure-Object Threads -Sum).Sum
WorkingSetGB = [math]::Round(
($Snapshot | Measure-Object WorkingSetMB -Sum).Sum / 1024,
2
)
PrivateGB = [math]::Round(
($Snapshot | Measure-Object PrivateMB -Sum).Sum / 1024,
2
)
}
}
Write-Host "正在采集基线快照……" -ForegroundColor Cyan
$before = Get-FullProcessSnapshot
$beforeGrouped = Get-GroupedSnapshot -Snapshot $before
$beforeSummary = Get-SystemSummary -Snapshot $before
$before | Export-Csv `
(Join-Path $OutputDirectory "01_processes_before.csv") `
-NoTypeInformation -Encoding UTF8
$before |
Sort-Object Handles -Descending |
Select-Object -First $Top |
Export-Csv `
(Join-Path $OutputDirectory "02_top_processes_before.csv") `
-NoTypeInformation -Encoding UTF8
$beforeGrouped |
Sort-Object Handles -Descending |
Export-Csv `
(Join-Path $OutputDirectory "03_grouped_processes_before.csv") `
-NoTypeInformation -Encoding UTF8
$beforeSummary |
Export-Csv `
(Join-Path $OutputDirectory "04_system_summary_before.csv") `
-NoTypeInformation -Encoding UTF8
$beforeSummary | Format-List
Write-Host "观察 $ObserveSeconds 秒……" -ForegroundColor Yellow
Start-Sleep -Seconds $ObserveSeconds
Write-Host "正在采集第二次快照……" -ForegroundColor Cyan
$after = Get-FullProcessSnapshot
$afterGrouped = Get-GroupedSnapshot -Snapshot $after
$afterSummary = Get-SystemSummary -Snapshot $after
$after | Export-Csv `
(Join-Path $OutputDirectory "05_processes_after.csv") `
-NoTypeInformation -Encoding UTF8
$afterGrouped |
Sort-Object Handles -Descending |
Export-Csv `
(Join-Path $OutputDirectory "06_grouped_processes_after.csv") `
-NoTypeInformation -Encoding UTF8
$afterSummary |
Export-Csv `
(Join-Path $OutputDirectory "07_system_summary_after.csv") `
-NoTypeInformation -Encoding UTF8
$beforeMap = @{}
foreach ($item in $before) {
$beforeMap[$item.Key] = $item
}
$processGrowth = $after |
ForEach-Object {
if ($beforeMap.ContainsKey($_.Key)) {
$old = $beforeMap[$_.Key]
[PSCustomObject]@{
PID = $_.PID
Process = $_.Process
ParentPID = $_.ParentPID
HandlesBefore = $old.Handles
HandlesAfter = $_.Handles
HandleDelta = $_.Handles - $old.Handles
ThreadsBefore = $old.Threads
ThreadsAfter = $_.Threads
ThreadDelta = $_.Threads - $old.Threads
PrivateBeforeMB = $old.PrivateMB
PrivateAfterMB = $_.PrivateMB
PrivateDeltaMB = [math]::Round(
$_.PrivateMB - $old.PrivateMB,
2
)
ExecutablePath = $_.ExecutablePath
CommandLine = $_.CommandLine
}
}
} |
Sort-Object HandleDelta -Descending
$processGrowth |
Export-Csv `
(Join-Path $OutputDirectory "08_process_handle_growth.csv") `
-NoTypeInformation -Encoding UTF8
$beforeGroupMap = @{}
foreach ($item in $beforeGrouped) {
$beforeGroupMap[$item.Process] = $item
}
$groupGrowth = $afterGrouped |
ForEach-Object {
$old = if ($beforeGroupMap.ContainsKey($_.Process)) {
$beforeGroupMap[$_.Process]
}
else {
$null
}
[PSCustomObject]@{
Process = $_.Process
InstancesBefore = if ($old) { $old.Instances } else { 0 }
InstancesAfter = $_.Instances
InstanceDelta = if ($old) {
$_.Instances - $old.Instances
}
else {
$_.Instances
}
HandlesBefore = if ($old) { $old.Handles } else { 0 }
HandlesAfter = $_.Handles
HandleDelta = if ($old) {
$_.Handles - $old.Handles
}
else {
$_.Handles
}
ThreadDelta = if ($old) {
$_.Threads - $old.Threads
}
else {
$_.Threads
}
}
} |
Sort-Object HandleDelta -Descending
$groupGrowth |
Export-Csv `
(Join-Path $OutputDirectory "09_group_handle_growth.csv") `
-NoTypeInformation -Encoding UTF8
Write-Host "系统变化:" -ForegroundColor Green
[PSCustomObject]@{
ProcessesBefore = $beforeSummary.Processes
ProcessesAfter = $afterSummary.Processes
ProcessDelta = $afterSummary.Processes - $beforeSummary.Processes
HandlesBefore = $beforeSummary.Handles
HandlesAfter = $afterSummary.Handles
HandleDelta = $afterSummary.Handles - $beforeSummary.Handles
ThreadsBefore = $beforeSummary.Threads
ThreadsAfter = $afterSummary.Threads
ThreadDelta = $afterSummary.Threads - $beforeSummary.Threads
} | Format-List
Write-Host "同一进程句柄增长排行榜:" -ForegroundColor Green
$processGrowth |
Select-Object -First $Top `
PID,
Process,
HandleDelta,
ThreadDelta,
PrivateDeltaMB |
Format-Table -AutoSize
Write-Host "程序族句柄增长排行榜:" -ForegroundColor Green
$groupGrowth |
Select-Object -First $Top |
Format-Table -AutoSize
Write-Host "审查完成。报告目录:" -ForegroundColor Cyan
Write-Host $OutputDirectory
运行:
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\Handle-Audit.ps1 -ObserveSeconds 120 -Top 40
默认报告目录:
桌面\HandleAudit
主要文件:
01_processes_before.csv
02_top_processes_before.csv
03_grouped_processes_before.csv
04_system_summary_before.csv
05_processes_after.csv
06_grouped_processes_after.csv
07_system_summary_after.csv
08_process_handle_growth.csv
09_group_handle_growth.csv
其中最重要的是:
08_process_handle_growth.csv
09_group_handle_growth.csv
二十六、标准排查流程
第一阶段:确认问题
- 记录总句柄;
- 记录进程数;
- 记录线程数;
- 观察总句柄是否持续上涨;
- 确认是否同时出现卡顿、窗口无法打开等症状。
第二阶段:定位范围
- 查看单进程句柄排行;
- 查看同名进程汇总;
- 比较一分钟句柄增量;
- 比较实例数量增量;
- 区分句柄泄漏和进程增殖。
第三阶段:追踪来源
- 查看命令行;
- 查看可执行文件路径;
- 查看父进程;
- 查看完整祖先链;
- 查看子进程;
- 对 svchost 查询具体服务;
- 对 System 检查驱动和设备。
第四阶段:隔离验证
- 正常退出可疑程序;
- 记录句柄下降数量;
- 暂停插件或扩展;
- 暂停后台服务;
- 断开非必要设备;
- 重复相同操作验证是否复现。
第五阶段:形成证据
建议保留:
- 系统总量日志;
- 进程增长 CSV;
- 命令行;
- 父进程链;
- 程序版本;
- 触发步骤;
- 开始和结束时间;
- 关闭程序前后的句柄差值。
二十七、最终判断标准
通常满足以下多个条件,才能较有把握地判断为句柄泄漏:
- 同一个 PID 持续存在;
- 句柄数量连续多轮增长;
- 负载停止后仍然不回落;
- 重复执行某个操作时稳定复现;
- 关闭目标程序后总句柄明显下降;
- 重启目标程序后句柄恢复低位;
- 再次操作后重新开始增长;
- 进程实例数量没有同步增加;
- 不是正常浏览器标签页、虚拟机或批量任务造成;
- 问题与特定插件、版本、设备或服务高度相关。
不够可靠的判断:
它现在有 10000 个句柄。
更可靠的证据链:
同一个 PID 在空闲状态下每分钟稳定增加 1500 个句柄,
停止业务后不释放,
关闭该程序后系统总句柄立即下降 80000,
重新启动该程序后句柄恢复低位,
再次执行相同操作后重新持续上涨。
这才是一条完整、可信的句柄泄漏证据链。