那天排查一个网页渲染器时,我先看到的是一个很熟悉的“清理残留”逻辑:启动新任务前,把本机上看起来像无界面浏览器的进程全部扫掉。单独运行时,它确实很干净。上一轮任务如果异常中断,浏览器没有退出,下一轮可以顺手把它们收走。
问题出在“本机上”这3个字。现在同一台机器上不只运行一个会话,多个任务可能同时调用无界面浏览器。一个会话启动时,并不知道另一个浏览器是不是正在为别的成片工作。于是,原本为了清理自己残留而写的逻辑,实际上取得了清理所有人的权限。
这件事让我重新意识到,进程清理不是一个普通的收尾动作。它首先是资源归属问题,其次才是删除问题。谁启动的、属于哪一次任务、启动者是否已经退出,这些信息没有被留下,清理就只能靠猜。
一次看似正常的清理,为什么会误伤
最容易误判的地方,是把进程名当成了身份。无界面浏览器的进程名通常相同,多个任务启动出来以后,系统不会自动替我们标注“这是第1个会话的”“这是第2个会话的”。如果清理逻辑只按名称匹配,它看到的是一组相同的进程,而不是一组有主人、有生命周期的资源。
这在单任务环境里很难暴露。任务启动、渲染、退出,一切按顺序发生,残留进程也大概率就是自己留下的。但并发一出现,时间顺序就不再能证明归属。任务甲刚进入清理阶段,任务乙可能正在截图;甲只能看见乙的浏览器也符合自己的名称规则,却看不见乙正在使用的页面。
如果不处理这个边界,后果不只是某个任务失败。被清理的浏览器可能让页面加载中断,成片出现黑帧、缺帧或中途停止;更麻烦的是,失败表现会落在渲染结果上,排查的人却可能继续盯着页面脚本,以为是动画本身出了问题。真正的故障发生在资源管理层,却被伪装成了内容质量问题。
只凭进程名清理,等于把“看起来相同”误当成“属于我”。
表面问题是误杀,真正问题是没有归属
一开始我也容易把这件事理解成“清理条件写得不够严格”,好像只要多加几个字符串判断就行。后来把启动过程和退出过程放在一起看,才发现真正缺的不是一个更复杂的筛选条件,而是一套从启动时就建立的归属标记。
一个任务如果没有在创建资源时留下身份,等到它要清理时,就只能从外部现象反推:进程名字像不像,启动参数像不像,目录位置像不像。这些线索可以辅助判断,却不应该单独承担删除别人的风险。清理动作越强,归属证据就越应该明确。
我选择给每次渲染建立专属配置目录,并让目录名带上启动它的主进程号。这里的目录不是为了保存内容,而是为了给浏览器和它的辅助进程贴上可追踪的标签。启动时知道自己创建了哪个目录,清理时就只寻找带有这个标记、且对应主进程已经不存在的资源。
这个决定有两个理由。第1,配置目录会出现在浏览器及其辅助进程的启动参数里,主进程、图形进程、渲染进程和工具进程因此有了同一条可观察的归属线索。第2,启动进程号能把“哪个任务创建的”从模糊的进程名,收紧到一次具体的运行实例。
资源归属必须在启动时建立,不能等到清理时临时猜测。
清理范围要小,退出判断要完整
有了专属目录之后,启动时的清理规则就不再是“找到浏览器就杀”,而是分成两步:先找带有本次任务标记的资源,再确认它的启动者已经退出。只有两个条件都满足,才把它视为自己的陈旧残留。
这一步看起来保守,实际上是在处理并发中的时间差。一个任务可能暂时没有响应,但它仍然活着;另一个任务可能刚好在使用相同类型的浏览器。如果只看目录,不看启动者状态,仍然可能把正在运行的任务当成残留。如果只看进程号,不看目录,又无法排除同类进程之间的混淆。
我把清理动作限制在“自己的标记 + 启动者已退出”这个交集里。这样做的代价是,遇到状态不确定的资源时,清理不会逞强立即删除,可能需要下一轮再处理。但我判断这是正确取舍,因为一次未清干净的临时资源,通常比误杀另一个正在生成的任务更容易恢复。
最容易走的错误捷径,就是直接使用 pkill chrome,或者按统一进程名把所有看起来像残留的浏览器扫掉。这种办法在测试机上很有诱惑力:命令短,结果快,日志里也容易显示“清理完成”。但它把机器上的其他任务当成了自己的内部状态,规模一大就一定会出事故。
能出片,不代表主进程会退出
第2个问题更隐蔽。成片已经写完,结果文件也能播放,但负责渲染的主进程偶尔还挂着。表面看,任务似乎完成了;如果外层调度器按照进程是否退出判断任务状态,就会把它当成未完成,或者继续等待。连续积累以后,残留的浏览器和主进程还会占用资源,让后面的并发任务越来越不稳定。
我一开始会怀疑编码子进程、定时器或页面回调。但逐层检查后,真正没有收干净的是浏览器子进程留下的输入输出管道。浏览器关闭调用返回,并不等于它的所有子进程已经退出;只要管道仍被某个子进程继承,主进程就可能继续等待,看起来像是“浏览器已经关了,程序却没结束”。
这次的收尾顺序因此不能只写成一个 browser.close()。先给浏览器正常关闭的机会,限定等待时间;如果仍未退出,再根据专属目录找到自己的主进程和辅助进程并结束它们;随后关闭相关管道,移除临时配置目录,最后才让主进程结束。遇到中断信号时,也沿用同一条收尾路径,而不是让信号直接把现场丢在那里。
真正可靠的退出,不是文件写完,而是浏览器、辅助进程、管道和临时目录都完成了自己的收尾。
我没有直接把所有异常都交给一个强制退出命令,是因为强制退出只能解决“现在不再等待”,不能保证资源已经按归属被处理。先正常关闭、再在边界内强制收尾,既保留了正常退出的机会,也避免一个卡住的子进程把整条流水线拖住。
验收必须同时覆盖并发和重复
这类修改不能只跑1次成功案例。单次运行只能说明主路径能通,不能说明清理不会碰到别人,也不能说明第5次运行时还剩下多少资源。因此我把验收拆成几种互相补足的场景。
首先同时启动2路渲染,观察两路是否都能成功完成,并确认一方的清理不会让另一方消失。测试期间还保留了一个不属于本次任务的外部浏览器,检查它是否全程存活。这个对照很重要,因为“自己的残留被清掉”并不能证明“别人的资源没有被碰”。
其次连续运行5次,关注每次主进程是否自行退出、浏览器是否无残留、专属配置目录是否被收走。重复运行是在检查生命周期闭环:创建出来的东西有没有被记录,失败时留下的东西下次能不能识别,正常完成时有没有忘记清理。
最后再看内容层面的结果,页面报错为0,黑帧检测为0。这里还要注意测试页本身的性质:如果页面开头就是深色背景,黑帧检测可能命中页面设计,而不是渲染器故障。所以测试门槛必须和测试素材一起解释,不能把检测数字机械地当成结论。
这次结果是并发2路互不清理对方进程,外部浏览器保持存活;连续5次都能自行退出且无残留;页面报错和黑帧检测均为0。它们合在一起,才足以说明问题不只是在某一次运行里“碰巧没出错”。
下次我会怎么做
下次设计类似的并发任务,我会在启动资源之前先问3个问题:这项资源如何证明属于本次任务?清理时怎样确认它的启动者已经退出?正常完成和异常中断是否走同一条收尾路径?如果其中任何一个问题答不上来,我不会等到故障发生后再补清理逻辑。
具体流程可以很朴素:任务启动时创建专属标记,记录启动者和资源位置;清理时只匹配自己的标记,并检查启动者状态;关闭主服务后继续处理辅助进程、管道和临时目录;验收时同时做并发、重复运行、外部资源存活和内容质量检查。每一步都不复杂,难的是不要因为“平时能跑”就省掉它。
这件事让我重新意识到,边界不是给系统增加麻烦,而是让系统知道哪些东西不该动。表面上我是在修浏览器清理,实际上我是在给一次运行划出责任范围。真正难的不是把残留删掉,而是只删掉自己有权处理的残留,并且在结束时把自己打开的每一扇门都关上。
一个并发流程成熟的标志,不是它能跑通一次,而是它能在资源共享、异常中断和重复运行时仍然认得自己的东西。