那次要处理的不是一个网页任务,而是多个任务同时使用同一个网页组合器。每个任务都要打开页面、上传参考图、填写提示词,再提交一次生成。真正让我犹豫的地方在于:这些动作看起来都发生在同一个网页里,如果任务 A 正在填写,任务 B 也切进来,参考图和提示词就可能被互相覆盖。
最直接的想法是给整个任务加一把锁。谁先开始,谁就一直占着浏览器,直到渲染结束、文件下载完成,其他任务排队等待。这个方案容易理解,却和并发的目的相冲突。网页生成的长时间渲染并不需要持续修改创建页面,如果把它也放进锁里,几十分钟的等待就会被人为制造出来。
这次真正要解决的不是“多个任务能不能同时运行”,而是“哪些状态还会被后来者改写”。
先确认网页到底按什么隔离
我一开始把注意力放在“共享浏览器”这件事上,担心不同任务会跨标签页复用同一份页面状态。后来先做了一个小范围实测:使用不同 workspace 参数的完整 URL 打开页面,得到的是彼此隔离的标签,不会在不同程序之间复用。
这个发现改变了问题的形状。只要任务从一开始就带着各自的隔离标识进入网页,上传参考图和填写提示词并不会天然互相抢占。页面看起来属于同一个组合器,但它们实际进入的是不同的工作空间。表面上我是在担心浏览器共享,实际上我是在确认网页用什么键区分状态。
这里的顺序很重要。如果没有先确认隔离键,就很容易把所有操作都当成共享资源;如果误以为每个标签都完全独立,又可能漏掉某个真正共用的入口。并发设计的第一个问题,不是先加锁,而是先找出资源真正的隔离边界。
冲突不在整个任务里
继续观察后,真正危险的窗口变得更窄了。带 workspace 的完整 URL 可以把配置阶段分开,真正可能发生复用竞态的,是提交前短暂回到裸生成地址的那一段。此时多个任务面对的不是各自独立的地址,而是一个可能被共同复用的创建入口。
这一区分很容易被忽略,因为从执行者的视角看,上传、填写、点击提交都属于“生成任务”。但从状态变化的视角看,它们不是一回事。上传参考图和写提示词是在自己的工作空间里修改创建配置;裸地址窗口则可能让两个任务争用同一个当前页面。前者主要靠 URL 隔离,后者才需要锁来保护。
如果不处理这个边界,最坏的结果不是单纯变慢,而是任务 A 以为自己已经配置好,提交时却拿到了任务 B 的页面状态。生成出来的内容可能没有立刻暴露错误,直到下载或人工查看时才发现,定位成本会比一次等待高得多。
所以我把锁的保护对象从“整个任务”改成“创建窗口”。它只覆盖配置、提交和确认重定向这段可能改变共享状态的流程。这样既不放任竞态,也不把与竞态无关的工作拖进临界区。
为什么提交后可以放锁
这次的关键判断发生在页面提交之后。页面提交并完成重定向,意味着创建动作已经交给后台处理,后面进入的是长时间渲染。此时任务的核心配置已经确定,渲染过程不再需要另一个任务进入同一个创建窗口重新填写内容。
我选择在确认重定向后立即释放锁,是因为那一刻状态已经从“还可能被改写的创建中”变成了“等待结果的已提交任务”。如果继续持锁,锁保护的就不再是共享状态,而只是保护一个漫长的等待过程。这个等待过程并不能降低新的冲突,却会让其他任务无法开始自己的创建。
提交后的任务如果已经不可变,就应该交给无锁轮询;锁只需要保护仍然能够被改写的部分。
这里的“无锁”不是不做任何管理,而是不再占用创建阶段的那把共享锁。每个任务仍然根据自己的已提交状态轮询结果,完成后下载自己的产物。渲染和下载可以并行,创建阶段也能继续被下一个任务使用。
最容易误判的错误捷径
最容易误判的错误捷径,就是给整个浏览器会话或整个任务周期加一把长锁。它看上去最安全,因为所有动作都被排成一列,理论上不会互相碰撞。但它把“安全范围”和“等待范围”混在了一起。
长锁确实可能掩盖创建阶段的冲突,却不能证明锁的边界正确。只要渲染耗时远大于配置和提交,所有任务的总耗时就会接近串行累加。更麻烦的是,后来维护的人会把这种排队当成系统必然要求,继续在锁里添加轮询、重试和下载,临界区只会越来越长。
我没有直接采用这个捷径,是因为已经有证据表明不同 workspace 的完整 URL 能够隔离配置,剩余冲突也只出现在裸地址窗口。既然风险可以被定位,就没有理由让锁覆盖被证实安全的部分。锁越大不等于系统越安全;当它保护了不需要保护的时间,反而制造了新的性能风险。
另一个危险是只凭代码结构猜测隔离关系。例如看到“打开新标签”就认为状态一定独立,或者看到“共享组合器”就认为所有页面一定相同。网页应用的真实复用规则需要实测。没有经过隔离实验的并发假设,只是把不确定性藏进了实现。
用状态边界做最小验证
为了确认锁没有越界,我会把一次任务拆成几个清楚的状态:进入带 workspace 的配置页,完成参考图和提示词配置,进入裸地址窗口,执行提交,确认页面重定向,然后释放锁,最后独立轮询渲染并下载。每个状态都要能回答一个问题:此刻还有没有别的任务可以改写我依赖的页面状态?
验证重点不只是“最终能不能下载文件”,还包括锁的生命周期。配置和提交前不能出现两个任务同时占用共享窗口;确认重定向之后必须发生释放;渲染等待期间不能继续持有创建锁。这样的契约测试比单纯跑通一次更有价值,因为它检查的是并发规则,而不是某次恰好没有碰撞的结果。
如果出现失败,也要先判断失败发生在哪个边界。配置阶段失败,可能是 workspace 隔离或页面填写问题;提交阶段失败,可能是共享窗口没有被正确保护;重定向后失败,则应当和创建锁分开排查。把阶段分开,才能避免用“再加一把大锁”掩盖真正原因。
这件事让我重新意识到,流程成熟的标志,不是它能在单任务下跑通,而是它能说明并发时哪些动作必须互斥、哪些动作可以自由进行。一个好的锁不是把所有事情都挡住,而是把不可同时发生的那几秒钟准确圈出来。
下次我会怎么做
下次遇到多个自动化任务共享网页资源,我会先做隔离实验,再画出状态边界。先确认带有不同 workspace 的完整 URL 是否能打开隔离标签,再单独寻找裸地址、公共提交入口或会被后来的任务改写的页面。只有找到真实冲突窗口,才决定锁的范围。
具体流程会保持最小:配置阶段使用隔离 URL;进入共享创建入口后取得短锁;锁内完成必要配置、提交和确认重定向;一旦确认任务已经交给后台渲染,就立即释放;之后由各自任务无锁轮询和下载。这样做的理由不是追求看起来更复杂的并发模型,而是让每一段时间都对应一个明确的风险。
我还会把锁的契约写进验证:锁从哪里开始、在哪个重定向证据出现后结束、渲染和下载是否完全在锁外。以后如果有人想把轮询放回锁里,必须先回答它要保护哪种可变状态;如果回答不出来,就说明这只是把等待重新塞进临界区。
表面上我是在设计网页自动化的并发控制,其实我是在练习一种更普遍的判断:不要因为一个任务从头到尾看起来连续,就把它当成一个不可拆分的整体。真正难的不是加锁,而是承认任务内部有不同的生命阶段,并让每个阶段只承担它确实需要承担的约束。