项目:[GameBoost](https://github.com/GzxingR/gameboost)(MIT,纯 PowerShell,Windows 10/11)
本文记录真实的设计取舍、实测数据与踩坑过程。所有数字都来自本机实测,不是估算。
打游戏时最烦的不是显卡不够,而是后台一堆进程在抢三样东西:CPU 时间片、物理内存、磁盘 I/O。
浏览器几十个进程占着 4~5 GB,聊天工具和同步盘时不时唤醒,厂商控制台还在后台跑自己的定时任务。
市面上的"游戏加速器"大多做三件事:改注册表、停服务、清缓存。收益含糊,而且很难回退。
我想要的是另一个思路:把后台应用"冻住"——像冻住一样,CPU 占用归零,但标签页、会话、文档状态全都保留;
玩完一键原样还原,而且每一步都有日志。
| 目的 | API | 要点 |
|---|---|---|
| 冻结 / 恢复 | ntdll!NtSuspendProcess / NtResumeProcess | 需要句柄具备 PROCESS_SUSPEND_RESUME (0x0800);必须成对调用(挂起计数) |
| 回收内存 | psapi!EmptyWorkingSet | 需要 PROCESS_QUERY_INFORMATION (0x0400) + PROCESS_SET_QUOTA (0x0100) |
| 降 QoS | SetProcessInformation | ProcessMemoryPriority = 0、ProcessPowerThrottling = 4、EXECUTION_SPEED = 0x1 |
为什么"冻结后立刻回收工作集"才有效?因为被挂起的进程不会再触碰自己的页面,此时把它们移出工作集,
内存管理器才真的能把这些物理页交出来。如果不冻结就回收,进程会立刻把页读回来,等于白干。
为什么不用优先级调节(像 Process Lasso 那样)? 优先级只影响调度倾向,后台进程照样在跑;
而冻结是"直接不给它 CPU"。两者不冲突,但目标不同。
这是整个项目我最坚持的一点。挂起反作弊进程会导致游戏崩溃,甚至被判定为异常行为;
挂起杀毒软件则是 EDR 产品明确监控的恶意行为特征(同一套 API 历史上被多个勒索软件用过)。
实测发现两件事,让我决定名单不可被配置覆盖:
MsMpEng),用完整权限掩码 0x0800|0x0400|0x0200|0x0100 调 OpenProcess 直接返回 err=5(拒绝访问),
而只请求 SUSPEND_RESUME|QUERY_LIMITED 却成功。也就是说:**如果代码不做降级重试,
它会安静地跳过一个它能"看见"的进程;如果做了降级却不检查权限,它又会报告"回收工作集成功"的假结果。**
最后实现是:掩码降级 + 降级后明确跳过需要写权限的操作。
IsProcessCritical 还不够。它对本机 csrss / services / wininit 返回 True, 但对 System 和 lsass 返回 False —— 所以"名字黑名单"不能省。
最终是四层保护:关键进程判定 → 名字黑名单(含反作弊与杀毒,硬编码)→ 自身进程链 → 冻结瞬间的前台全屏窗口兜底。
另外默认不碰会话 0 的服务,且只恢复自己冻过的进程(pid + 进程名 + 启动时间戳 指纹校验)。
先用一个自己编译的合成进程(tools/MemBalloon.cs,分配 2048 MB 并逐页触碰)测"到底能腾出多少内存":
| 观测 | 数值 |
|---|---|
| 合成进程工作集 | 2067 MB |
冻结 + EmptyWorkingSet 后工作集 | 0 MB |
| 5 秒后可用物理内存净增 | +1017 MB(约 50%) |
| 页文件使用量 | +3.6 GB(脏页写回的直接证据) |
结论:工作集清零是确定的,但物理内存的释放是异步的、约一半即时可见。
脏匿名页必须先写回页文件。所以工具里"预计回收 N MB"是工作集估算,不是"立刻省下 N GB"。
这个认知直接改变了推荐用法:在启动游戏之前就冻结,否则写回流量会和游戏加载抢同一块磁盘。
性能方面,原本"每个目标冻结后等 400 ms 校验"的实现,18 个目标要 8.63 秒,按 146 个目标推算要 ~70 秒 ——
对"开局前按一下"完全不可接受。改成流水线(读快照 → 批量下发 → 只等一次 → 只快照一次 → 批量写日志)后:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 冻结 18 个目标 | 8.63 s | 1.60 s |
| 恢复 18 个目标 | 3.30 s | 1.08 s |
| 单进程成本 | 0.48 s | 0.097 s |
@($array) + @($genericList) 会抛异常给"连带冻结子进程"写递归展开时,用 @($roots) + @($extra) 相加,其中 $extra 是 List[object]。
结果整个展开逻辑抛 Argument types do not match,被外层 catch 吞掉,只打了一行警告 ——
功能静默降级成"只处理匹配到的进程"。修法:显式转换 [object[]]($roots) + [object[]]($extra.ToArray())。
powershell 当基准目标,被自己的保护逻辑拦下做内存基准时我用一个分配 2 GB 的 PowerShell 进程当目标,结果"冻结成功"但工作集纹丝不动。
原因是脚本默认保护整个终端家族(powershell/cmd/conhost…)——保护是对的,
错的是我的基准。后来改成编译一个独立命名的 gbmemballoon.exe,问题消失。
教训:基准测试本身也会被安全逻辑影响,验证目标必须避开保护面。
"游戏退出后自动恢复"的守护进程最初这样写:只要还有同名进程就继续等。实测发现本机常驻 6 个 cmd.exe,
于是条件永远成立,永远不恢复。改成两阶段:先等目标出现 → 锁定当时那批 PID → 等它们全部退出。
修完之后我写了自动化测试,测试又抓到同一处的一个竞态:守护进程要 1~2 秒才启动,
如果目标在这之前就退出了,它会把"从未出现"当成条件,空等 60 分钟。
最终修法是在挂起时刻就把已观察到的 PID 一并传给守护进程,彻底消除竞态。
$pid 是只读自动变量 —— 测试骨架静默吞掉了 7 个用例测试辅助函数我写成了 function SuspendedCount([int]$pid)。$pid 与 PowerShell 的只读自动变量 $PID 冲突,
赋值时抛终止性异常;而我的 finally 块里又写了 exit,于是异常被掩盖、退出码 0,
报告显示"通过 11 / 失败 0",实际上后面 7 个用例一个都没跑。
修法有两处:参数改名,以及最关键的一点 —— finally 之前必须有 catch,把"套件异常中断"记为失败并打印行号。
Remove-Item -Recurse -Force = 整个项目被删准备发布到 GitHub 时,我先清理一个暂存目录:C:\Users\Administrator\gameboost。
而项目目录是 C:\Users\Administrator\GameBoost —— Windows 路径大小写不敏感,这是同一个目录。
一条 Remove-Item -Recurse -Force 之后,脚本、配置、日志、归档、第三方工具全部消失,而且不进回收站。
抢救过程(也是最有价值的部分):
vssadmin list shadows → 无;回收站 → 无。 ~/.pi-desktop/sessions/<sessionId>.jsonl,里面保存了每一次文件写入的完整内容与每一次补丁操作的操作串。
之后重新编译基准工具、重新下载第三方 exe 并核对 SHA256,功能自测通过。
教训:① 删除前先确认目标路径不是某个既有目录的另一种大小写写法;② Remove-Item 不进回收站,
重要目录要留 VSS 或版本控制;③ 会话记录是可靠的最后一道恢复源,但要靠字节数之类的外部基线来校验还原是否精确。
前面五个坑解决的是"冻得住、恢复得干净"。但真正每天拦着我用的,是每次都要手点几十个进程:
Chrome 有几十个助手进程、微信 QQ 要留软模式、Steam 又绝对不能冻。于是补了三件事(对外就叫「表单化自动选择」):
游戏前 / 极简 / 只降QoS / 办公放宽), 命令行 -Profile '游戏前' 或 GUI 下拉框一键套用;档案里还能带 include / exclude / minMemoryMB / rules。
suspend)/ 只降 QoS(idle,微信不断消息)/ 不处理(skip)。内存门槛按进程名聚合(同名字下所有进程的工作集求和),否则 Chrome 那堆几 MB 的小助手会被逐个漏掉。
resume -Only 'Weixin' 只恢复点名的进程名,其余条目继续留在会话文件里保持冻结, 全部处理完才归档到 history\。粒度是进程名——只恢复半个浏览器没有意义。
坑 6:规则门槛的语义被我写反了。 我想表达"小工具不处理",于是写了
{ "match": "*", "action": "skip", "minMemoryMB": 60 }——而 minMemoryMB 在实现里的含义是"合计达到 60MB 才命中"。
于是这条规则的语义变成"≥60MB 的统统跳过",真正的大户反而被放过。它是被自测套件里一条"小进程应被跳过"的断言顶出来的。
修法不是补注释,而是把方向写进字段名:拆成 minMemoryMB(下限)/ maxMemoryMB(上限)。
坑 7:CLI 提前退出,让 GUI 照着旧名单勾选。 当规则表把所有名字都判为 skip 时,一个冻结目标都没有,
脚本在输出 JSON 之前就 exit 0 了——scan.json 还是上一次扫描的内容,GUI 于是照着陈旧清单勾选。
这类 bug 不报错、不崩,只会让你怀疑"它是不是冻错了东西"。修法是双保险:-EmitJson 时不再提前退出;
GUI 扫描前先删除旧清单。
同一章还留下两个"只有端到端自测才暴露"的细节:无窗口(headless)跑 GUI 时 ItemChecked 事件不会触发,
程序化勾选不同步到勾选集合,Do-Apply 会认为"什么都没勾"(修法:动手前从列表状态同步一次);
以及 -NamesFile 明确点名的进程先被规则表 skip 命中——"我要求冻它"变成什么都不做
(修法:显式名单不参与规则筛选,但硬保护与档案 exclude 照常生效)。
上面 7 个坑里,坑 3 的竞态、坑 4 的静默失败、坑 6 的门槛语义写反,都是只有自动化测试才能稳定抓出来的问题
(手工测试时它们表现为"偶尔不灵"或"总觉得哪里不对")。所以这个项目我最后投入最多的是
tests/Test-GameBoost.ps1:86 条断言,覆盖冻结/恢复闭环、幂等性(重复冻结不得双重挂起)、
跨会话同名进程的逐进程过滤、PID 复用与已退出进程的拒绝恢复、Idle 模式的 QoS 精确还原、
规则表与档案(同名内存聚合、maxMemoryMB 上限、显式名单优先)、选择性恢复的"剩余条目保留"语义,以及 GUI 自检。
再配上 GitHub Actions(PSScriptAnalyzer + 自测),每次推送都能验证。
如果你也在做"会改动系统状态"的工具,我的建议是:先把可验证的断言写出来,再写功能。
它会比你更早发现那些"看起来成功了"的失败。