/2,299 字/阅读约 8 分钟

不杀,先让路:内存守护的降级档是怎么长出来的

一台 32GB 的开发机长期在 10% 可用内存附近徘徊——"临界才杀进程"意味着天天在悬崖边保持不动。这一轮改版给守护脚本加了降级档:警告线以下不杀,先把最大的非前台进程降优先级、交还工作集;再加"你正在看的应用全家豁免"、和一次真实搁浅事故修出来的状态落盘。三个决策都不是设计阶段想出来的,而是上线后逐次事故长出来的。

眠月眠月
joker1point

项目: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%立即击杀(不受每小时刹车束缚)
三档动作:低于 12% 降级最大的非前台进程;低于 7% 连续 3 次击杀;低于 3.5% 紧急档立即击杀;恢复后还原优先级
图 1 · 三档分工:警告线以上不动手,警告线下降级让路,临界才杀

三个约束把"降级"限定成一个温和动作:

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

改完上线,日志里很快出现了真实的降级与还原记录:

守护日志:启动行参数、两次 DEMOTE(WeMeetApp、WorkBuddy)与随之而来的 RESTORE 还原行
图 2 · 降级与还原实录(本机真实日志):降级发生在 21:08 与 22:19,都在十几秒后随内存恢复自动还原

注意 ws=1136MB->1136MB 这个看起来"没生效"的数字。EmptyWorkingSet 交还的是 standby 页,工作集计数不会即时反映;真正起作用的是优先级的下降、以及那部分页变成随时可回收。这条也直接写进了文档口径——不把"看起来没变"讲成"已经释放"。另外,还原行显示的优先级是进程原本的优先级:有些应用自己就把自己设成了 BelowNormal 或 Idle,那就还原成它们原本的值,而不是强行改成 Normal。

二、前台应用"全家豁免":为什么按名字,而不是 pid

第一版豁免是"按 pid 排除前台窗口的进程"。上线当天就被日志打脸:候选名单里第一个就是浏览器,而它的主进程正把窗口交给用户——你正在读的那一页跑在同名的 renderer 子进程里,pid 不同,名字相同。只按 pid 判定,第一刀就会落在你眼前。

改判定很简单,想清楚却花了点时间:拿前台窗口所属进程的名字,去比对候选进程名。

豁免判定:取前台窗口所属进程,按进程名比对候选;同名则整支豁免(不杀也不降级)
图 3 · 豁免按进程名而不是 pid:浏览器把窗口交给主进程,而那一页在同名子进程里

这条豁免现在是双向的:既不杀,也不降级。理由一致——"我正在看的那个应用"挨任何一刀,感知上的代价都远大于换回的几百 MB。它是这一轮改版里最容易被低估的一条:它把"自动化工具打扰用户"的概率压到了接近零。

三、一次真实的"搁浅":恢复类状态必须落盘

降级档上线当天傍晚,收尾检查时发现有两个进程停在 BelowNormal 上没有被还原:一个办公协作工具、一个输入法云服务。时间线对出来是当天 15:07 的一次守护重启——降级记录只存在守护的内存里,重启就丢了,于是那两个进程再也不会被"还原"这条路径看到。

这类问题的可怕之处在于它是静默的:没有报错、没有日志、进程还能正常跑,只是永远低人一等地等着 CPU。修法是给恢复类状态一个落盘的家:

防搁浅流程:降级时写 demote-state.txt;守护重启读回,只还原仍停在 BelowNormal 的进程,随后清空文件
图 4 · 防搁浅:降级动作落盘,重启时接手还原——但只还原「仍在下沉状态」的那些

落盘格式是一行一条:pid | 原优先级 | 时间 | 名字。守护启动时读回,只还原那些仍然停在 BelowNormal 的——如果进程自己已经退出、或用户手动把优先级调回来了,就跳过,不越权。这个细节是刻意的:状态文件只是"我降过它"的证据,不是"它现在该是什么样"的指令。

顺带修掉的一个测试坑也很典型:受控沙包被 EmptyWorkingSet 回收后,工作集会掉到候选门槛之下,于是"重启接手"这个场景静默失败——连一行 DEMOTE 都没有,测试却看起来正常。修法是让沙包持续触碰页面,保住它的候选资格。这类"只有真跑才暴露"的问题,是自动化测试里最值得较真的一类。

四、紧急档松绑,与一次"明确拒绝"

临界档的每小时击杀次数有一道刹车(默认 6 次/小时),用于防止"杀了又生"的拉锯战。但紧急档(低于 3.5%)是本机的雪崩场景,实测多次触发,刹车会让它在该动手时举着手不动。

这一轮按明确的口径松绑:紧急档不受小时刹车束缚,冷却压到 3 秒、小时上限放宽到 3 倍(默认 18 次)。同时明确拒绝了"完全解除次数限制"的方案——那会让守护与"被杀又重生"的进程变成零间隔的拉锯战,两边都在空转。松绑有边界,边界写在参数里(-EmergencyCooldownSec / -EmergencyMaxKills)。

配套的测试也补上了:降级/还原/无击杀的断言,加上"守护被杀、新实例接手还原"的场景;预览模式(-DryRun)会把"本来会杀"的次数也计入刹车——预览必须与真实行为同一套账。

五、它现在仍然不会做的事

三条边界值得写清楚,避免把它想得比实际聪明:

  1. 判据仍然只有"可用物理内存"。 更接近"卡死"的信号其实是提交内存(commit)压力与硬错误页速率——本机高压时段 commit 一度到 77%。目前这些数字只记录进日志,没有纳入判定。这是 roadmap,不是已完成特性。
  2. 不碰受保护进程。 桌面窗口管理器这类受保护进程的优先级连读都读不出来(读出为空),提优先级那条路在 Windows 上不通——这也是"降级档"存在的另一半理由:与其想办法给前台加速,不如先把后台让开。
  3. 降级不保证工作集数字下降。 如前所述,ws 不变是正常的;判断降级是否生效要看优先级与页面回收状态,不要看那个数字。

把三个决策放在一起看,它们各自对应的"问题"比"方案"更值得记住:

三个问题与三个决策:长期徘徊催生降级档;砍到眼前那一页催生前台全家豁免;重启搁浅催生状态落盘
图 5 · 三个问题 → 三个决策:这一轮改版的全部内容

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