不杀,先让路:内存守护的降级档是怎么长出来的
一台 32GB 的开发机长期在 10% 可用内存附近徘徊——"临界才杀进程"意味着天天在悬崖边保持不动。这一轮改版给守护脚本加了降级档:警告线以下不杀,先把最大的非前台进程降优先级、交还工作集;再加"你正在看的应用全家豁免"、和一次真实搁浅事故修出来的状态落盘。三个决策都不是设计阶段想出来的,而是上线后逐次事故长出来的。
项目:joker1point/memfuse(MIT)。本文对应 v1.4.x 这一轮改版,环境:Windows 11 + PowerShell 5.1。 前情:这脚本最初的设计取舍与战报在《最后一道内存防线》里,这里接着写它后来的三次演进。
上一版守护脚本的设计是"两级阈值 + 临界才动手":可用内存低于 12% 只记日志,低于 7% 连续三次采样才杀,低于 3.5% 走紧急通道。它救过几次场,我以为这套设计稳了。
用了三周才发现真正的问题不在"悬崖边",而在悬崖边以外的那一大片区域:这台机器并不是偶尔掉到 3%,而是长期挂在 10~12%。在这个区间,脚本什么都做不了——按设计它就不该动手。可体感是持续存在的:切窗口要等半秒、输入法偶尔慢半拍。而在没到临界时就去杀进程,是过度处置:丢掉一个后台程序的几十分钟工作,换回来的内存可能几分钟就被吃回去。
缺的是中间那一档:临界之前,先让路。
一、降级档:把"杀"之前的那一档留给让路
新的档位表把动作分成了三段:
| 级别 | 条件 | 动作 |
|---|---|---|
| WARN | 可用内存低于 12% | 降级,不杀:最大的非前台进程降到 BelowNormal + 回收工作集 |
| CRITICAL | 低于 7% 且连续 3 次采样(5 秒一次) | 击杀最大的候选进程 |
| EMERGENCY | 低于 3.5% | 立即击杀(不受每小时刹车束缚) |

三个约束把"降级"限定成一个温和动作:
- 同一时刻只降一个。 本机长期在 10~12% 徘徊,如果一轮把前十名都降了,会累积一堆变慢的应用,而内存并不会因此变好——只降当前最大的那一个,够用就行。
- 永不碰前台。 前台窗口所属进程直接排除在候选之外(下一节还会再加一条更强的豁免)。
- 自动还原。 内存回到警告线以上(连续 3 次采样)或满 10 分钟(
-DemoteHoldSec),优先级还回去;整个功能可用-NoDemote关掉,节奏由-DemoteCooldownSec(默认 120 秒)控制。
改完上线,日志里很快出现了真实的降级与还原记录:

注意 ws=1136MB->1136MB 这个看起来"没生效"的数字。EmptyWorkingSet 交还的是 standby 页,工作集计数不会即时反映;真正起作用的是优先级的下降、以及那部分页变成随时可回收。这条也直接写进了文档口径——不把"看起来没变"讲成"已经释放"。另外,还原行显示的优先级是进程原本的优先级:有些应用自己就把自己设成了 BelowNormal 或 Idle,那就还原成它们原本的值,而不是强行改成 Normal。
二、前台应用"全家豁免":为什么按名字,而不是 pid
第一版豁免是"按 pid 排除前台窗口的进程"。上线当天就被日志打脸:候选名单里第一个就是浏览器,而它的主进程正把窗口交给用户——你正在读的那一页跑在同名的 renderer 子进程里,pid 不同,名字相同。只按 pid 判定,第一刀就会落在你眼前。
改判定很简单,想清楚却花了点时间:拿前台窗口所属进程的名字,去比对候选进程名。

这条豁免现在是双向的:既不杀,也不降级。理由一致——"我正在看的那个应用"挨任何一刀,感知上的代价都远大于换回的几百 MB。它是这一轮改版里最容易被低估的一条:它把"自动化工具打扰用户"的概率压到了接近零。
三、一次真实的"搁浅":恢复类状态必须落盘
降级档上线当天傍晚,收尾检查时发现有两个进程停在 BelowNormal 上没有被还原:一个办公协作工具、一个输入法云服务。时间线对出来是当天 15:07 的一次守护重启——降级记录只存在守护的内存里,重启就丢了,于是那两个进程再也不会被"还原"这条路径看到。
这类问题的可怕之处在于它是静默的:没有报错、没有日志、进程还能正常跑,只是永远低人一等地等着 CPU。修法是给恢复类状态一个落盘的家:

落盘格式是一行一条:pid | 原优先级 | 时间 | 名字。守护启动时读回,只还原那些仍然停在 BelowNormal 的——如果进程自己已经退出、或用户手动把优先级调回来了,就跳过,不越权。这个细节是刻意的:状态文件只是"我降过它"的证据,不是"它现在该是什么样"的指令。
顺带修掉的一个测试坑也很典型:受控沙包被 EmptyWorkingSet 回收后,工作集会掉到候选门槛之下,于是"重启接手"这个场景静默失败——连一行 DEMOTE 都没有,测试却看起来正常。修法是让沙包持续触碰页面,保住它的候选资格。这类"只有真跑才暴露"的问题,是自动化测试里最值得较真的一类。
四、紧急档松绑,与一次"明确拒绝"
临界档的每小时击杀次数有一道刹车(默认 6 次/小时),用于防止"杀了又生"的拉锯战。但紧急档(低于 3.5%)是本机的雪崩场景,实测多次触发,刹车会让它在该动手时举着手不动。
这一轮按明确的口径松绑:紧急档不受小时刹车束缚,冷却压到 3 秒、小时上限放宽到 3 倍(默认 18 次)。同时明确拒绝了"完全解除次数限制"的方案——那会让守护与"被杀又重生"的进程变成零间隔的拉锯战,两边都在空转。松绑有边界,边界写在参数里(-EmergencyCooldownSec / -EmergencyMaxKills)。
配套的测试也补上了:降级/还原/无击杀的断言,加上"守护被杀、新实例接手还原"的场景;预览模式(-DryRun)会把"本来会杀"的次数也计入刹车——预览必须与真实行为同一套账。
五、它现在仍然不会做的事
三条边界值得写清楚,避免把它想得比实际聪明:
- 判据仍然只有"可用物理内存"。 更接近"卡死"的信号其实是提交内存(commit)压力与硬错误页速率——本机高压时段 commit 一度到 77%。目前这些数字只记录进日志,没有纳入判定。这是 roadmap,不是已完成特性。
- 不碰受保护进程。 桌面窗口管理器这类受保护进程的优先级连读都读不出来(读出为空),提优先级那条路在 Windows 上不通——这也是"降级档"存在的另一半理由:与其想办法给前台加速,不如先把后台让开。
- 降级不保证工作集数字下降。 如前所述,
ws不变是正常的;判断降级是否生效要看优先级与页面回收状态,不要看那个数字。
把三个决策放在一起看,它们各自对应的"问题"比"方案"更值得记住:

这一轮改版的三个决策——降级档、全家豁免、状态落盘——都不是设计阶段想出来的。它们分别来自"长期徘徊但不动手"的观测、一次上线当天的日志、和一次重启后搁浅的收尾检查。这类工具的演进路线,基本就是它遇到过的所有事故的清单。