怎么判断一个 Windows 进程已经没用了:父链、活跃树和一次差点误杀
先说一个让我停下来数了两遍的数字:任务管理器里有 11 个同名的 WorkBuddy 进程,加起来 4.2 GB。旁边还挂着 19 个 node.exe,其中一批的父进程早就Sign out了,还稳稳占着内存。
问题很明确——收拾掉那些"已经没人管"的进程。但"没人管"是个非常模糊的说法,而我要写的是一个会真的调 Kill() 的功能。判断错了就是关掉别人正在用的东西。
这篇文章讲我怎么把"没人管"翻译成一个可执行的判据,以及中途两次把判断做错的过程。
一、第一版逻辑:爬到父链尽头就算孤儿
最初的想法很直接:Windows 进程有父子关系,从目标进程沿父链往上走,如果走到某个 PID 已不存在的地方,说明这条链断了,也就是孤儿。
代码大概长这样(CreateToolhelp32Snapshot 枚举进程,拿到 th32ParentProcessID):
private static List<RawProc> WalkUp(Dictionary<int, RawProc> snapshot, int pid, int maxDepth)
{
var list = new List<RawProc>();
int cur = pid;
for (int i = 0; i < maxDepth; i++)
{
if (!snapshot.TryGetValue(cur, out var node)) break; // 断了
list.Add(node);
cur = node.ParentPid;
}
return list;
}
判定:整条链没有"正常宿主"(explorer、services)→ 孤儿。
第一次运行,扫出 293 个进程,全部标记为「父进程已Sign out · 可安全清理」,合计 18 GB。
这明显不对。列表里躺着 ShellExperienceHost、ctfmon、NVDisplay.Container、Feishu……全是系统和自己天天在用的东西。如果当时手快点了"全部清理",这台机器基本就废了。
二、第一个 bug:.exe 后缀
293 这个数字其实就是系统进程总数——说明所有进程都被判成了孤儿。
原因是 PROCESSENTRY32W.szExeFile 返回的名字带扩展名:explorer.exe、svchost.exe。而我的白名单里写的是 explorer、svchost,用 HashSet.Contains 比永远匹配不上。
于是"正常宿主"名单形同虚设,每条链都被认为没有正常宿主,全部判成孤儿。
// 错:白名单里是 "explorer",实际值是 "explorer.exe"
if (ValidHosts.Contains(node.Name)) ...
// 对
private static string BaseName(string name)
{
var n = (name ?? "").Trim();
int dot = n.LastIndexOf('.');
return dot > 0 ? n[..dot] : n;
}
顺手把 NeverTouch(系统关键进程黑名单)也一起修了——同样的问题,csrss 匹配不到 csrss.exe。
教训:拿到一个 API 返回的字符串,先打印出来看真实格式,别按自己想当然的格式写比较逻辑。
修完之后从 293 个降到 32 个。数字对了,但判定依然是错的。
三、第二个 bug:飞书的启动器Sign out了,但它活得好好的
32 个里最扎眼的一条:
Feishu.exe(14332) ppid=19100[已Sign out(19100)] 278.4 MB
飞书明明开着窗口在用。但它的父进程(某个启动器/更新器)确实Sign out了。
这类情况在 Windows 上极其常见:启动器拉起主程序后自己Sign out。父进程消失不代表子进程失活,只代表"当初启动它的那个东西没了"。
如果按"父链断了就是孤儿"这条路走下去,会误杀一大批正常软件。这是设计层面的错误,不是实现细节的问题。
于是整个判据必须重做。
四、正确的判据:两步
第一步:只看直接父进程
"孤儿"严格来说只有一个含义——当它Sign out时,没有任何进程会收到通知去处理它。这个定义只跟直接父进程有关,跟祖父、曾祖父无关。
bool parentAlive = snapshot.ContainsKey(raw.ParentPid);
父进程还在 → 不是孤儿,不管它祖父死了多少年。
这一条直接排掉了飞书那类"启动器Sign out"的情况吗?没有——飞书的直接父进程也确实没了。所以还需要第二步。
第二步:活跃树
关键洞察是:判断一个进程是不是"没用",不应该看它的父链,而应该看它是否还连着某个"有用户界面"的东西。
具体做法:以所有有可见窗口的进程为种子,向外扩张两圈——
- 往上:整条祖先链(谁拉起它的)
- 往下:整棵子孙树(它拉起了谁)
得到的集合叫"活跃树"。树里的进程一律保护。
// 反向索引:父 PID → 子 PID 列表
var children = new Dictionary<int, List<int>>();
foreach (var node in snapshot.Values)
{
if (node.ParentPid <= 0) continue;
if (!children.TryGetValue(node.ParentPid, out var list))
children[node.ParentPid] = list = new List<int>();
list.Add(node.Pid);
}
foreach (int seed in seeds) // seeds = 有 MainWindowHandle 的进程 + 目标应用自身
{
foreach (var anc in WalkUp(snapshot, seed, 24)) active.Add(anc.Pid); // 往上的祖先链
var queue = new Queue<int>();
queue.Enqueue(seed);
while (queue.Count > 0) // 往下的子孙树
{
int cur = queue.Dequeue();
if (!active.Add(cur)) continue;
if (children.TryGetValue(cur, out var kids))
foreach (int kid in kids) queue.Enqueue(kid);
}
}
这一步刚好解决了飞书的问题:飞书主进程没有窗口,但它的 renderer 子进程有窗口。以 renderer 为种子往上走一棵祖先链,就包含了主进程 14332 → 它被正确保护了。
判断"有窗口"用 Process.MainWindowHandle != IntPtr.Zero。Electron 应用的进程模型正好落在这个规则的射程里。
最终判据收敛成一句:
直接父进程已不存在 且 不属于任何"有窗口进程的祖先链或子孙树" → 才算残留。
五、还有一个陷阱:杀一棵树,不是杀一个进程
.NET 里 Process.Kill(entireProcessTree: true) 会连带整棵子树。这带来两个后果。
第一,统计不能只看进程自己。 扫描结果里有一条:
node.exe(22356) 自身 46.8 MB
看起来不值得清理。但把它的子树展开:
node.exe(21740) 92.5 MB
node.exe(5168) 55.9 MB
python.exe(13016) 938.1 MB ← 这个才是大头
conhost.exe... 各 10 MB 左右
合计 1.17 GB
一个 47 MB 的进程背后挂着 1.17 GB。 如果界面上只显示 47 MB,用户会直接跳过它,永远清不掉真正吃内存的东西。
所以每一项都要算 子树进程数 + 子树总内存,确认框里把子进程逐个列出来:
● node.exe (22356) 46.8 MB
└ python.exe(13016) 938.1 MB
└ node.exe(21740) 92.5 MB
└ node.exe(5168) 55.9 MB
└ … 还有 6 个
第二,"受保护"这个判断必须在执行层再做一次。 UI 上把受保护的行设成灰色、用 ItemCheck 事件阻止勾选,这只是一层。真正执行前还得拦一道:
foreach (var item in items)
{
if (item.IsProtected)
{
errors.Add($"{item.Name}({item.Pid}) 属于运行中的程序,已跳过");
continue;
}
...
}
因为 UI 可以被绕过(批量勾选、代码调用、命令行参数),而 Kill() 是不可逆的。
实测验证:拿一个 1.46 GB 的 WorkBuddy 进程去调精确清理接口,
[WARN] [kill] PID 14388 属于运行中的程序(WorkBuddy 进程(受保护)),拒绝结束
进程原地存活。
六、几个实现细节
进程枚举用 CreateToolhelp32Snapshot 而不是 WMI。 WMI 的 Win32_Process 字段更全(能拿 CommandLine),但慢得多,而且 .NET 8 要额外引 System.Management 包。而这里只需要 PID / 父 PID / 名字三个字段,Toolhelp 一次快照就够,几十毫秒。
代价是拿不到命令行,所以列表里区分 node.exe 只能靠路径最后两级:
nodejs\node.exe
.workbuddy\node.exe
不够精确,但对"要不要杀"这个判断够用了。
会话隔离。 只处理当前登录会话的进程,避免碰到别的 Windows 用户账户跑的东西:
int selfSession = SafeSessionId(selfPid);
if (session != selfSession && session != 0) continue;
排除自己的整条祖先链。 一个清理工具最丢人的事就是把自己干掉,或者把自己的调用方(终端)干掉。
var selfChain = WalkUp(snapshot, selfPid, 24).Select(x => x.Pid).ToHashSet();
默认范围收窄。 全盘扫描会扫出各种软件的残留,噪音太大。默认只扫目标应用和 node.exe,真要清别的得手动打开开关——用户明确知道自己在干什么时才扩大范围。
七、这个方案的天花板
必须说清楚它解决不了什么:
纯Admin、无窗口、父进程又Sign out的服务型进程,会被判成孤儿。 比如一个常驻的同步工具,如果它的启动器Sign out且自己没有可见窗口,就会被列进"可清理"。即使它其实还在正常工作。
没有通用办法区分"安静地服务"和"安静地僵死"。能加的信号只有 CPU 时间是否增长、是否有网络连接、是否有文件句柄活动——都不够可靠,成本也不对。
所以界面上只勾选、不自动执行,把最后一公里留给人的判断。命令行的 --cleanup-auto 也建议先跑 --dry-run 看一眼。
这也是为什么最后交付的形态是"扫描 + 勾选 + 二次确认",而不是一个"一键优化"的按钮。不可逆的操作,多一点摩擦是对的。
最终效果:默认视图只列出真正的残留,带上子树规模;受保护的行灰掉点不动;确认框摊开每个子进程的名字和内存。
代码在 gitee.com/aowind/DeepSeekPulse,判定逻辑在 Core/ProcessScanner.cs,不到 500 行。如果你也在写类似的东西,希望那两个 bug 能帮你省掉一次"293 个进程全选"的惊吓。