有些问题,表面上像是“任务偶尔失败”,真正的原因却是几项工作在同一台机器上撞到了同一个资源窗口。前段时间,我遇到过一次这样的场景:一台内存有限的机器上,多个重型模型进程同时运行,最后不是某个任务优雅地返回错误,而是系统直接因为内存耗尽而崩溃。
那次之后,给常驻的图像后端加一把机器级互斥锁,几乎成了最直观的修复方向。可是锁并不是加上去就结束了。常驻服务和一次性脚本的生命周期不同,如果从服务启动一直持锁,确实能挡住其他任务,却也等于把整个服务的可用性一起锁住;如果完全不加锁,服务虽然看起来随时可用,并发任务仍然可能把内存和 GPU 一起推过安全边界。
后来我把问题重新拆开:真正需要互斥的,不是“服务存在的这段时间”,而是“重型任务从提交到完成的这段时间”。锁的范围应该跟资源冲突的真实时间窗口重合,而不是跟进程的生命周期重合。
先从一次崩溃看资源冲突
这次遇到的后果很具体。多个大模型进程并行运行时,每个进程都觉得自己是在完成一项合理的工作,但机器只看总量。模型加载、任务执行和中间结果同时占用资源,最终可用内存被不断挤压,系统进入无法维持正常运行的状态。
如果不处理,后面会出现的问题不只是某一张图生成失败。常驻后端可能还在接受请求,调用方也会以为任务已经进入队列,但机器在多个重型任务之间没有清晰的边界,失败就会变成随机的:有时是任务超时,有时是进程退出,有时是整台机器失去响应。随机失败最难排查,因为每次复现所处的资源状态都不一样。
我一开始很容易被“是不是把并发数调小”这个念头带走。但并发数只是应用层的一个数字,不一定能覆盖所有进入机器的重型工作。只要还有另一个入口绕过这个数字,或者常驻服务和一次性任务各自维护自己的限制,机器级资源仍然可能被同时消耗。
为什么常驻服务不能一直持锁
给常驻后端加锁之后,最容易想到的实现是:服务启动时获取锁,服务退出时释放锁。它很简单,也容易解释。只要服务活着,其他任务就拿不到同一把锁,冲突似乎自然消失了。
但这个方案把两个不同的问题混成了一个问题。服务进程的生命周期可能很长,任务窗口却只是其中的一小段。如果服务只是等待请求,或者正在处理轻量级状态检查,它并没有持续占用那一整块重型资源;这时仍然持锁,相当于让一个没有实际工作的进程挡住所有需要工作的进程。
这会带来另一种故障:机器也许不再崩溃,但服务会变得不可用。其他合法任务一直拿不到锁,调用方只能等待、重试,甚至把重试堆积成新的压力。不能为了避免资源冲突,把资源协调做成服务级别的永久占用。
所以我判断,锁的所有权应该属于具体任务,而不是属于常驻进程。服务可以常驻,但只有进入真正可能和其他重型工作冲突的窗口,才暂时取得排他权。
提交前探测只负责快速拒绝
分层之后,第一层是提交前探测。它的目的不是预定未来的整个任务窗口,而是回答一个很窄的问题:现在是否已经有别的重型任务在进行?
这里要用非阻塞方式探测。如果锁已经被别人持有,当前请求立即得到一个明确的繁忙结果;如果探测成功,也要立即释放,不能把这次探测当成正式占用。这样做看起来有点反直觉:刚拿到锁就放掉,似乎没有真正阻止并发。但它解决的是另一件事——在任务正式提交前,先把已知的冲突挡在门外,让调用方尽早知道当前不能开始。
如果把探测锁一直留到后面,代码就会悄悄变成“先探测、再等待一串未知操作”。未知操作包括准备输入、构造请求、等待后端响应等。锁被持有的时间会越来越难估计,服务可用性也会跟着被拖慢。探测层应该短,职责应该单一:发现当前已经被占用,就 fail-closed 地拒绝;没有发现占用,就退出探测阶段。
这里最容易误判的是,把一次成功探测理解成“我已经预订了任务”。实际上,探测和正式占用之间存在时间间隔,其他进程可能在这段时间拿到锁。因此,探测只能改善用户反馈和入口秩序,不能替代真正覆盖任务窗口的排他锁。
真实提交到完成才是持锁窗口
第二层才是正式的任务锁。真正提交重型任务之前获取排他锁,并一直持有到轮询完成、确认任务结束,再释放。这个上下文覆盖的是资源最可能发生冲突的区间,而不是服务启动和退出之间的全部时间。
我选择用一个清晰的上下文边界包住“提交到完成”,是因为它把释放责任交给了控制流本身。正常完成时,离开上下文就释放;中途发生异常时,也不会因为漏写某个释放分支而把锁永久留在系统里。锁的边界越接近任务的真实边界,越容易审查,也越容易测试。
这个窗口不能只包住提交动作。若提交后立刻释放,另一个进程可能在第一个模型仍然运行时开始自己的模型任务,最终又回到并行冲突。相反,也不能把所有准备工作都包进去,否则锁会提前被占用,多个请求在准备阶段互相等待。排他锁必须覆盖“资源已经被真正激活”到“资源确认不再被任务占用”的完整区间。
轮询完成是这个设计里的关键。提交接口返回成功,只说明请求被接收,不说明模型已经释放资源。只有轮询到任务完成、失败或明确终止,才能认为这一次重型资源窗口结束。异常路径也要被当作正常设计的一部分:任务失败时照样释放锁,轮询出错时要让调用方知道失败,而不是静默地把锁和状态留在半完成状态。
不要丢掉原有的资源门禁
加入机器级互斥锁,不代表原有的内存和 GPU 门禁可以删除。锁解决的是多个进入者之间的时序冲突,内存门禁解决的是当前资源是否已经不足,GPU 门禁解决的是任务是否具备继续执行的硬条件。它们关注的不是同一个维度。
如果只依赖锁,仍可能出现单个任务本身就超过剩余资源的情况;如果只依赖内存检查,也可能在检查通过后被另一个进程抢先提交。前者是容量判断,后者是并发协调。把它们叠起来,才会形成比较保守的防线:先确认资源状态,再确认当前窗口没有被占用,最后在真正执行期间保持排他。
这也是我没有直接删掉旧防护的原因。一次新机制加入后,旧机制看起来可能有些重复,但它们覆盖的是不同失败方式。安全门禁的价值不在于每一层都单独完美,而在于一层失守时,下一层仍能阻止最坏结果。
测试要验证边界而不是只跑通主线
这个方案真正容易出错的地方,不是“能不能获取一把锁”,而是持锁区间有没有覆盖正确。测试至少要分别验证:探测在锁被占用时会立即失败;探测成功后会立即释放;正式提交会持锁到轮询完成;任务异常时也会释放;原来的内存和 GPU 条件仍然生效。
测试环境不能直接复用真实机器上的锁位置,否则一次失败测试可能把下一次测试挡住,也可能影响正在运行的服务。因此,锁的路径在测试中替换成临时目录,让测试拥有独立、可清理的状态。dry-run 则不持锁,因为它只验证流程和参数,不应该制造真实资源占用或改变并发状态。
这次改动后,原有测试与新增测试合计 98 passed。这个数字本身不是结论,真正有价值的是测试把两层职责分开了:第一层验证“现在能不能开始”,第二层验证“开始以后谁能继续占用”。如果只写一个主线测试,很容易把探测成功和正式持锁错误地当成同一件事。
下次我会怎么做
下次遇到多个重型任务可能互相影响,我会先画出资源冲突的真实时间窗口,再决定锁放在哪里。第一步确认事故是容量问题、并发问题,还是两者叠加;第二步保留已有的内存和 GPU 门禁;第三步增加非阻塞的提交前探测,让已知冲突快速失败;第四步只在正式提交到轮询完成之间持有排他锁;最后用隔离的锁路径覆盖成功、繁忙、异常和 dry-run 场景。
这个方法不只适用于图像后端。凡是多个常驻服务会调用同一类重型模型、编译器、硬件加速任务或其他机器级稀缺资源,都可以先问一句:冲突究竟发生在服务启动时,还是发生在任务真正运行时?答案决定了锁是应该跟着进程走,还是跟着任务窗口走。
这件事让我重新意识到,互斥锁不是越早拿、拿得越久就越安全。真正难的不是把“只能一个”写进代码,而是识别那个“只能一个”的时间范围。一个流程成熟的标志,不是它能跑通一次,而是它能在并发、失败和空闲等待之间保持清晰边界,让机器不会因为互相撞车而失控,也不会因为过度防守而停止工作。