那天我正要继续用电脑,系统内存突然显示冲到 74G。这个数字很有压迫感:屏幕上能看到几个正在工作的会话和浏览器,但我不能确定到底是谁在吃资源,更不能为了让数字立刻下降,就把眼前最大的那个程序直接杀掉。正在渲染的任务可能被误伤,真正失控的进程却可能还留在后台。
我一开始以为问题会很简单:找到占用最大的程序,结束它,内存应该就会回来。后来事实证明,真正要处理的不是“哪个窗口最可疑”,而是“哪棵进程树正在持续增长”。这件事让我重新意识到,系统变慢时,界面上最显眼的对象不一定是责任人。
74G 只是警报,不是结论
活动监视器给出的总内存,是一个结果,不是一份责任清单。它能告诉我系统已经危险,却不能直接告诉我这部分内存来自主进程、子进程、显卡缓存,还是多个任务叠加出来的压力。如果只盯着一个会话名称,很容易把“发起任务的人”和“真正干活的人”混为一谈。
这次表面上像是某个会话或浏览器突然失控,继续观察又发现,高占用主要来自会话派生出的语音合成子进程。会话本身更像一个调度入口,真正加载模型、生成内容、不断申请内存的,是它下面那棵进程树。内存告警只能触发排查,不能直接替代责任归属判断。
如果不先分清这层关系,最容易出现两种坏结果:一是强杀主程序,正常的编辑或渲染一起中断;二是主程序看起来退出了,子进程还在继续跑,内存数字短暂下降后又重新上涨。前一种损失是误伤,后一种更麻烦,因为它会让人误以为问题已经解决。
错误捷径是直接杀最大的那个
当时最容易误判的动作,就是看列表里最大的数字,然后直接结束对应的主程序。这个捷径很符合直觉,也最容易在机器已经卡顿时被采用。可是进程的显示名称、父子关系和实际资源占用,并不总是重合;尤其是一个会话可以启动多个工作进程,多个调用方也可能共享同一类合成程序。
我没有直接把正在使用的会话全部关掉,是因为用户还要继续用电脑,而且批量任务本身是可暂停、可重跑的。这里的决策不是“保留所有东西”,而是先停掉最容易造成持续损害、又能安全恢复的那一层。那次批量语音合成在大约 1 分 44 秒内曾升到 69G,继续让它跑下去,风险已经不是一次任务失败,而是把整台机器拖进交换区和无响应状态。
所以第一步应该是暂停批量任务,避免它继续制造新压力;第二步才是确认它的子进程是否真的退出,以及有没有别的调用方还在启动同类任务。可重跑的批量工作,应当先被暂停;不可逆的主会话,应当先被保护。
这条顺序看起来保守,实际是在给自己争取判断时间。任务已经停止增长后,才有机会观察现存进程的关系、交换区的变化和系统是否恢复。如果一上来就连续强杀,证据也会一起被删掉,最后只能凭印象猜测刚才发生了什么。
真正的难点在进程树和统计口径
后续排查发现,旧的保护方式并不完全可靠。它主要按命令名找进程,定时查看单个进程的常规内存,阈值大约是 20G。但这次短文本试跑只有 52 字,峰值已经接近 16G;更早的一次试跑里,worker 报到 17.23G,却仍然正常退出。这说明“看到了数字”和“真正拦住增长”是两件事。
更关键的是,Mac 的统一内存会把显卡侧的缓存也带进系统压力里。常规进程统计可能只显示 3.9G,实际机器已经承担了约 15G 的占用。后来定位到,生成过程中显卡缓存只增不及时释放,每生成一个内容单位可能再增加约 35–45MB,旧逻辑既没有覆盖整棵进程树,也没有把这部分真实压力及时算进去。
这解释了为什么“单进程不超过 20G”仍然可能失效:统计对象不完整,统计维度也不对,轮询间隔又可能错过快速上涨。内存保护的边界必须覆盖责任进程的整棵树,并且使用与实际硬件压力相符的统计口径。
这里还有一个常见误区:看到短文本也冲到高位,就以为文本长度无关、所有任务都一样危险。短文本试跑确实说明模型加载有固定成本,但它不能证明长文本没有额外增长。后来 619 字的长文本在修复前约 40 秒就涨到 39G 并被杀,说明固定基线和持续累积是两个问题,必须分开测。
把保护拆成几道门
修复后的思路不是再找一个“万能阈值”,而是把保护拆到不同层。调用脚本内部负责观察自己的工作进程;外层守护负责观察整棵进程树。这样即使某一层没有及时发现,另一层仍有机会停止任务。超限时只杀当前合成子进程,已经完成的片段可以保留,避免一次保护动作把全部成果一起清掉。
交换区也被纳入门禁。启动前先看当前交换区,如果已经处在高位,就不启动新的合成;运行中如果交换区相对启动值增长超过 2G,或者系统内存压力明显恶化,就结束整棵进程树。被保护机制拦下的退出码要被视为失败,并且最多只允许 1 次重试,不能因为“失败了再试一次”把同一棵失控进程树叠起来。
阈值也不能脱离路线基线硬写。默认路线修复后的上限约为 17.8G,带情绪参考的路线约为 17.6G,文字情绪路线约为 20.2G;这些数字来自实测基线加余量,并且还要受实时可用内存约束,始终给正在使用电脑的人留出空间。我选择动态阈值,是因为短句本身就可能稳定占用十几 G,简单设成 12G 会把正常启动误判成故障;但只看基线又不够,因为真正危险的是后续继续上涨。
先短样本,再长文本
保护逻辑加上以后,不能直接恢复整批任务。小样本的意义不是证明“已经绝对安全”,而是验证门禁能否在一个可控范围内工作:进程能否正常退出,峰值是否落在该路线的上限以内,交换区有没有上涨,失败时是否留下正确的退出结果。
这次短句试跑曾经峰值约 16G,交换区没有上涨,退出码为 0,说明任务完成本身没有问题,但它也暴露出原来的 12G 标准不现实。随后再用长文本验证,624 字不带情绪时峰值约 15.77G,交换区没有上涨,逐段峰值也没有继续爬升;196 字的情绪路线峰值约 15.30G,同样没有引发交换区增长。
我不会只看最后一个峰值。长文本还要比较开头几段和结尾几段:如果后 5 段的峰值比前 5 段最高值高出超过 1.5G,就算最终数字没越过阈值,也应该怀疑缓存仍在累积。因为一次跑完,只能说明这次没有崩;峰值不持续上涨,才说明修复触及了原来的根因。
这个过程也保留了失败的含义。退出码 86–89 表示被内存保护拦下,不应该自动重试;真正需要重试时,最多重试 1 次,并且要先确认交换区和可用内存已经恢复。小样本试跑不是形式上的预演,而是把恢复任务拆成可观察、可停止、可解释的几个阶段。
这套方法可以迁移到更多任务
以后遇到“内存突然暴涨”,我会把流程固定成 5 个问题。第一,谁是用户正在使用的主程序,谁是它派生出的工作进程?第二,真正增长的是哪棵进程树,而不是哪个窗口看起来最忙?第三,常规内存统计是否覆盖了显卡、缓存和子进程?第四,当前任务能不能暂停、保留已完成片段并安全恢复?第五,恢复前的小样本验收门槛是什么?
这组问题比“哪个进程最大”多了一点判断,却能避免很多无效操作。它适合语音合成,也适合浏览器渲染、批量转码和其他会派生 worker 的任务。只要任务存在父子进程关系,就不应该只看一个名称;只要硬件存在统一内存或缓存,就不应该只相信常规 RSS;只要任务可重跑,就应该先把它放进保护边界。
表面上我是在处理内存,其实我是在处理任务的可恢复性。一个任务如果只能靠“千万别超过某个数字”来保护,说明流程还没有把失败当成正常分支。真正成熟的门禁,应该告诉我什么时候等待、什么时候暂停、什么时候只结束子进程、什么时候停止重试,以及完成后看哪些证据。
下次我会怎么做
下次再看到内存快速上涨,我会先暂停可重跑的批量任务,再沿进程树确认责任归属,不先强杀正在使用的主会话。接着检查实时可用内存和交换区,确认有没有同类任务在后台继续启动;如果发现异常,就停止整棵相关进程树,并记录它是被哪一道门拦下的。
恢复时,我会先用短文本测固定基线,再用一段长文本看峰值是否持续上涨。验收不只看任务有没有成功,还看峰值、交换区、退出码和末段峰值是否稳定。任何一次保护性失败,都不会因为“再试试”而无限重跑。
这件事让我重新意识到,机器出现危险数字时,最重要的动作不是立刻让数字变小,而是先保住判断能力。一个流程成熟的标志,不是它能顺利跑完一次,而是它在内存失控时知道该停哪一层、留下什么证据,以及怎样安全回来。