一个后台任务正在发送结果,消息后来也送到了,调度器却把它记成“执行权已经丢失”。遇到这种矛盾,我现在不会先加重试,也不会先把超时调大,而会多问一句:检查任务是否还活着的那条路径,会不会正在等待任务自己占用的锁?
最近复核自动化收尾问题时,我沿着这个问题读了一份公开修复,并单独做了一个线程实验。最值得提炼的不是某个超时数值,而是一个容易被忽略的设计错误:保护业务执行的锁,也可能挡住证明业务仍在合法执行的心跳。
本文只讨论锁竞争、心跳续约与所有权判断。它不是远端接口重试教程,也不代表所有“任务失败”都由同一种原因造成。
心跳没成功,不等于执行者已经换了
可以把一个后台任务想成三条相互配合的路径:
- 执行路径负责计算、写入或发送结果。
- 心跳路径定期续约,让调度器知道当前执行者仍持有任务。
- 终态路径记录任务最后到底完成、失败还是被中断。
这些路径需要共享少量状态,但不必为所有事情等待同一把锁。
Python 官方文档明确说明,一条线程取得锁以后,其他取得同一把锁的尝试会阻塞,直到锁被释放;带超时的取得操作可以在未拿到锁时返回失败。[2] 这个返回值回答的是“本次是否拿到锁”,不是“谁拥有业务任务”。
如果代码把这两个问题压成同一个布尔值,错误就很容易发生:心跳没拿到锁,返回失败;上层把失败解释成执行权被别人接走;再把仍在运行的任务中断。
保守地拒绝未经确认的操作,可以是安全选择;把证据不足记录成已经确认的所有权丢失,却不是同一回事。 停止继续执行与确定故障原因,需要分别表达。
一个公开修复:慢投递把自己的保活挡住了
Hermes Agent 的一份已合并修复描述了这条具体路径:任务线程在发送结果期间持有任务级保护锁,心跳线程续约时也要经过这把锁。投递足够慢时,心跳等锁超时,返回值被上层当成执行权丢失,最后出现消息已投递、任务却被记为失败的矛盾。[1]
这里要区分两种锁的责任:
- 执行保护锁:约束哪些执行者可以推进受保护操作。
- 状态锁:保护“当前所有者是谁、能否为它续约”这一小段读改写。
该修复没有取消所有锁,也没有让心跳无条件报告成功。它让心跳不再等待跨越消息投递的那把保护锁,同时继续用状态锁串行化“比较所有者并续约”;所有者不匹配时,仍然返回失败。[1]
我检查了这份变更的差异和测试。测试一方面让另一条线程保持占锁,验证合法心跳仍能续约;另一方面传入不同所有者,验证它仍被拒绝。这里引用的是公开实现与测试设计,不把作者报告的测试数量写成我的实测结果。[1]
我做了一个更小的实验,排除真实网络的干扰
为了确认自己理解的是锁关系,而不是碰巧记住一份补丁,我用 Python 标准库构造了一个独立模型。没有连接真实服务,没有发送消息,也没有使用生产数据。
实验里,一条工作线程取得“投递锁”后等待一个事件,模拟尚未结束的慢投递;心跳在另一条执行路径上运行。当前所有者由固定的合成值表示,比较与续约放在同一个状态锁内。
我实际运行了下面三种条件:
| 条件 | 观察到的结果 | 能说明什么 |
|---|---|---|
| 心跳先取投递锁,再检查同一所有者 | 等锁超时,续约失败;所有者没有改变 | 锁等待失败可以发生在没有接管的情况下 |
| 心跳只取状态锁,检查同一所有者 | 投递仍占锁,续约成功 | 这段状态操作不必依赖投递结束 |
| 心跳只取状态锁,但提供不同所有者 | 续约被拒绝 | 去掉不必要的等待,不等于放弃身份检查 |
三项断言均通过。实验用线程事件明确安排“先占锁,再心跳”,不靠网络偶然变慢碰运气。短超时只是为了让失败路径尽快出现,不是推荐的生产超时配置。
这个实验也有明确边界:它没有模拟进程崩溃、跨机器接管、持久化、租约到期或文件锁,更没有证明任意系统都可以照着删除一把锁。它只隔离验证了一个因果关系:等待执行锁超时,与所有者不匹配,是两个不同事件。
为什么我不把“加长超时”当作第一修复
在这个模型里,只要投递仍可能超过新的等待上限,误判路径就没有消失,只是更晚出现。把超时从短改长,也不能回答“为什么续约一定要等待发送完成”。
当然,某些系统确实需要调整不合理的超时。我的判断顺序不是“永不调大”,而是先检查依赖:
- 心跳实际需要读写哪些状态?
- 哪把锁保证这些状态一致?
- 锁里有没有网络发送、文件传输或其他长等待?
- 检查失败是否保留了原因,还是全被翻译为“失去执行权”?
如果证据表明是同一把锁把慢操作和短续约绑在一起,优先修依赖关系,比继续寻找一个足够大的数字更直接。
也不能反过来,把所有慢操作都搬到锁外
这份修复给我的启发不是“持锁做网络操作一律错误”,而是让锁只承担它确实必须保护的承诺。
有些执行保护锁承担防止旧执行者继续产生副作用的责任。如果不了解接管、取消和写入的关系,直接移走它,可能使旧任务与新任务同时执行。本文的实验没有覆盖这种风险。
因此,我会把修复验收分成两面:
- 合法任务不能被自己的慢操作误伤:投递仍在进行时,匹配的所有者能够完成必要续约。
- 失去资格的执行者不能蒙混过关:所有者确已改变时,旧执行者不能续约,也不能凭本地旧状态继续推进受保护动作。
后一条需要真实系统另外验证。只跑“慢投递不再报错”的正向测试,还不足以证明并发安全。
排查时,把结果、投递和终态放回同一条时间线
我现在更愿意先对齐一次具体运行,而不是盯着失败列表猜根因:
业务结果形成 → 开始投递并取得锁 → 心跳请求锁 → 等待失败 → 投递返回 → 记录终态。
这是一条排查模板,不是每个系统必然的事件顺序。现场需要分别取证:结果是否存在,投递方是否确认接收,锁等待发生在哪个线程,以及终态为何被写成失败。
如果结果已存在,就先核对同一次操作,不因为最后一个状态发红便重新执行。若只拿到平台的投递确认,也不能继续夸大为“用户已阅读”。这些证据各自只回答自己负责的问题。
我的结论是:心跳不是业务操作的附属尾巴,而是一条有自己时限和一致性要求的控制路径。 设计它时,最该防的未必是外部网络坏了,而是内部慢操作堵住了它通向事实的路。
本文为 AI 辅助整理的原创实践分析。独立实验仅使用合成状态;公开修复核验于 2026 年 9 月 18 日,合并日期为 2026 年 9 月 12 日。合并不代表所有已安装版本都已包含修复。Python 文档为持续维护页,本文只引用锁的阻塞与超时语义。未披露私人环境或业务对象,也未声称生产修复已经完成。
来源与核验
[1] https://github.com/NousResearch/hermes-agent/pull/109310 — Hermes Agent 公开修复:投递持锁与错误的执行权丢失
[2] https://docs.python.org/3/library/threading.html — Python 官方文档:threading 锁对象