← 返回项目主页 · GitHub 仓库 · 文章源码

项目:[GameBoost](https://github.com/GzxingR/gameboost)(MIT,纯 PowerShell,Windows 10/11)
本文记录真实的设计取舍、实测数据与踩坑过程。所有数字都来自本机实测,不是估算。

一、起因

打游戏时最烦的不是显卡不够,而是后台一堆进程在抢三样东西:CPU 时间片、物理内存、磁盘 I/O。

浏览器几十个进程占着 4~5 GB,聊天工具和同步盘时不时唤醒,厂商控制台还在后台跑自己的定时任务。

市面上的"游戏加速器"大多做三件事:改注册表、停服务、清缓存。收益含糊,而且很难回退。

我想要的是另一个思路:把后台应用"冻住"——像冻住一样,CPU 占用归零,但标签页、会话、文档状态全都保留;

玩完一键原样还原,而且每一步都有日志。

二、机制选择:三条 API 撑起全部功能

目的API要点
冻结 / 恢复ntdll!NtSuspendProcess / NtResumeProcess需要句柄具备 PROCESS_SUSPEND_RESUME (0x0800);必须成对调用(挂起计数)
回收内存psapi!EmptyWorkingSet需要 PROCESS_QUERY_INFORMATION (0x0400) + PROCESS_SET_QUOTA (0x0100)
降 QoSSetProcessInformationProcessMemoryPriority = 0、ProcessPowerThrottling = 4、EXECUTION_SPEED = 0x1

为什么"冻结后立刻回收工作集"才有效?因为被挂起的进程不会再触碰自己的页面,此时把它们移出工作集,

内存管理器才真的能把这些物理页交出来。如果不冻结就回收,进程会立刻把页读回来,等于白干。

为什么不用优先级调节(像 Process Lasso 那样)? 优先级只影响调度倾向,后台进程照样在跑;

而冻结是"直接不给它 CPU"。两者不冲突,但目标不同。

三、安全设计:为什么反作弊名单必须写死

这是整个项目我最坚持的一点。挂起反作弊进程会导致游戏崩溃,甚至被判定为异常行为;

挂起杀毒软件则是 EDR 产品明确监控的恶意行为特征(同一套 API 历史上被多个勒索软件用过)。

实测发现两件事,让我决定名单不可被配置覆盖:

  1. 受保护进程连句柄都拿不全。对装了篡改防护的杀毒主进程(MsMpEng),用完整权限掩码

0x0800|0x0400|0x0200|0x0100 调 OpenProcess 直接返回 err=5(拒绝访问),

而只请求 SUSPEND_RESUME|QUERY_LIMITED 却成功。也就是说:**如果代码不做降级重试,

它会安静地跳过一个它能"看见"的进程;如果做了降级却不检查权限,它又会报告"回收工作集成功"的假结果。**

最后实现是:掩码降级 + 降级后明确跳过需要写权限的操作。

  1. 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 s1.60 s
恢复 18 个目标3.30 s1.08 s
单进程成本0.48 s0.097 s

五、最初的五个坑(每个都真实付了代价)

坑 1:PowerShell 5.1 里 @($array) + @($genericList) 会抛异常

给"连带冻结子进程"写递归展开时,用 @($roots) + @($extra) 相加,其中 $extra 是 List[object]。

结果整个展开逻辑抛 Argument types do not match,被外层 catch 吞掉,只打了一行警告 ——

功能静默降级成"只处理匹配到的进程"。修法:显式转换 [object[]]($roots) + [object[]]($extra.ToArray())。

坑 2:拿 powershell 当基准目标,被自己的保护逻辑拦下

做内存基准时我用一个分配 2 GB 的 PowerShell 进程当目标,结果"冻结成功"但工作集纹丝不动。

原因是脚本默认保护整个终端家族(powershell/cmd/conhost…)——保护是对的,

错的是我的基准。后来改成编译一个独立命名的 gbmemballoon.exe,问题消失。

教训:基准测试本身也会被安全逻辑影响,验证目标必须避开保护面。

坑 3:守护进程按"进程名"判断目标是否退出 → 可能永不恢复

"游戏退出后自动恢复"的守护进程最初这样写:只要还有同名进程就继续等。实测发现本机常驻 6 个 cmd.exe,

于是条件永远成立,永远不恢复。改成两阶段:先等目标出现 → 锁定当时那批 PID → 等它们全部退出。

修完之后我写了自动化测试,测试又抓到同一处的一个竞态:守护进程要 1~2 秒才启动,

如果目标在这之前就退出了,它会把"从未出现"当成条件,空等 60 分钟。

最终修法是在挂起时刻就把已观察到的 PID 一并传给守护进程,彻底消除竞态。

坑 4:$pid 是只读自动变量 —— 测试骨架静默吞掉了 7 个用例

测试辅助函数我写成了 function SuspendedCount([int]$pid)。$pid 与 PowerShell 的只读自动变量 $PID 冲突,

赋值时抛终止性异常;而我的 finally 块里又写了 exit,于是异常被掩盖、退出码 0,

报告显示"通过 11 / 失败 0",实际上后面 7 个用例一个都没跑。

修法有两处:参数改名,以及最关键的一点 —— finally 之前必须有 catch,把"套件异常中断"记为失败并打印行号。

坑 5(最惨):Windows 路径大小写不敏感 + Remove-Item -Recurse -Force = 整个项目被删

准备发布到 GitHub 时,我先清理一个暂存目录:C:\Users\Administrator\gameboost。

而项目目录是 C:\Users\Administrator\GameBoost —— Windows 路径大小写不敏感,这是同一个目录。

一条 Remove-Item -Recurse -Force 之后,脚本、配置、日志、归档、第三方工具全部消失,而且不进回收站。

抢救过程(也是最有价值的部分):

  1. 先查卷影副本 vssadmin list shadows → 无;回收站 → 无。
  2. 意识到一个被忽视的恢复源:AI 编程工具的会话记录。文件是

~/.pi-desktop/sessions/<sessionId>.jsonl,里面保存了每一次文件写入的完整内容与每一次补丁操作的操作串。

  1. 写了个重放器:按时间顺序把 30 次写/改操作在干净目录里重放一遍。
  2. 用删除前记录的字节数当校验基线(25773 / 11487 / 21572 / …),结果 9 个文件逐个字节一致。

之后重新编译基准工具、重新下载第三方 exe 并核对 SHA256,功能自测通过。

教训:① 删除前先确认目标路径不是某个既有目录的另一种大小写写法;② Remove-Item 不进回收站,

重要目录要留 VSS 或版本控制;③ 会话记录是可靠的最后一道恢复源,但要靠字节数之类的外部基线来校验还原是否精确。

六、后来补的「表单」:规则表与选择性恢复(第 6、7 个坑)

前面五个坑解决的是"冻得住、恢复得干净"。但真正每天拦着我用的,是每次都要手点几十个进程:

Chrome 有几十个助手进程、微信 QQ 要留软模式、Steam 又绝对不能冻。于是补了三件事(对外就叫「表单化自动选择」):

命令行 -Profile '游戏前' 或 GUI 下拉框一键套用;档案里还能带 include / exclude / minMemoryMB / rules。

不处理(skip)。内存门槛按进程名聚合(同名字下所有进程的工作集求和),否则 Chrome 那堆几 MB 的小助手会被逐个漏掉。

全部处理完才归档到 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 + 自测),每次推送都能验证。

如果你也在做"会改动系统状态"的工具,我的建议是:先把可验证的断言写出来,再写功能。

它会比你更早发现那些"看起来成功了"的失败。