雨天小六

读懂 Codex(8.15):跨进程 Writer Lock 和同 Thread 写冲突

· 更新于 2026-08-03 · 专栏:读懂 Codex

#Codex#Agent Runtime#持久化#Rollout#软件架构

进程内 mutex 只能约束一个 LocalThreadStore;Paginated Thread 还用每 Thread 文件锁阻止另一个 Codex 进程同时追加同一 ordinal/offset 序列。

本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:说明 lock file、协调锁、stale cleanup 与生命周期锁的关系。输入:ThreadId 和 create/resume/archive/delete 生命周期操作;状态所有者:WriterLockCoordinator 与 per-thread WriterLockGuard;成功结果:当前进程独占写资格或 Conflict。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。

状态边界

问题本节答案
输入ThreadId 和 create/resume/archive/delete 生命周期操作
状态所有者WriterLockCoordinator 与 per-thread WriterLockGuard
成功产物当前进程独占写资格或 Conflict
研究范围说明 lock file、协调锁、stale cleanup 与生命周期锁的关系

正常路径:先看顺序点

跨进程 Writer Lock 和同 Thread 写冲突的正常路径时序图,展示按 ThreadId 解析 lock path、协调锁保护创建/清理窗口、try_lock OS 文件锁、成功 Guard 随 recorder 存活、Drop 关闭句柄再删除 lock file
图 8.15-1:按 ThreadId 解析 lock path → 协调锁保护创建/清理窗口 → try_lock OS 文件锁 → 成功 Guard 随 recorder 存活 → Drop 关闭句柄再删除 lock file。这张图标出本节的实际顺序点与状态所有者。

这条路径可以压缩成五步:

  1. 按 ThreadId 解析 lock path。
  2. 协调锁保护创建/清理窗口。
  3. try_lock OS 文件锁。
  4. 成功 Guard 随 recorder 存活。
  5. Drop 关闭句柄再删除 lock file。

图中的箭头不是“可能调用”的依赖图,而是源码中决定可见性和所有权转移的先后关系。前一步没有确认时,后一步不能替它作出更强的成功承诺。

源码机制拆解

Paginated 才需要跨进程序列保护

Legacy 主要依赖进程内 live writer;Paginated 的 ordinal、byte offset 与 SQLite projection 对并发 append 更敏感,因此 create/resume 会获取 OS-backed per-thread lock。

try_lock 让冲突立即可见

第二所有者不会排队到未知时间,而是把 WouldBlock 映射为 Conflict。上层可以判断 Thread 已在另一个进程活动,而不是把它误报为文件损坏。

协调锁避免清理与创建互相踩踏

全局 coordination lock 只覆盖 lock file 管理的短窗口。真正的 Thread 写独占由单独文件锁承担,不把所有 Thread 串行化。

stale cleanup 用“能否锁住”判断

启动时只处理文件名是合法 UUID.lock 的候选;若 try_lock 成功,说明没有活动所有者,可以删除。仍被锁住的文件必须保留,mtime 本身不能证明 stale。

Drop 顺序兼容平台差异

Guard 先在 coordination lock 内释放/关闭 writer lock,再删除文件,避免 Windows 等平台在句柄仍开着时无法移除或出现新 owner 与清理交错。

Python 风格伪代码

下面的伪代码只保留设计职责、状态和失败顺序;它不逐行翻译 Rust,也不借 Python 语法虚构源码中不存在的事务:

def acquire_writer(thread_id):
    lock_path = lock_dir / f'{thread_id}.lock'
    with coordination_lock():
        file = open_or_create(lock_path)
        if not file.try_exclusive_lock():
            raise Conflict(f'{thread_id} has another writer')
        return WriterGuard(file, lock_path)

def cleanup_stale_locks():
    with coordination_lock():
        for path in valid_uuid_lock_files(lock_dir):
            file = open(path)
            if file.try_exclusive_lock():
                file.unlock_and_close()
                remove(path)

def release(guard):
    with coordination_lock():
        guard.file.unlock_and_close()
        remove_if_exists(guard.path)

阅读时要特别看三处:哪个对象拥有可变状态,哪一个 await 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。

失败、取消与恢复

跨进程 Writer Lock 和同 Thread 写冲突的失败路径图,区分第二进程打开同 Thread、崩溃留下 lock file、仅凭 mtime 删除、批量操作锁顺序不同
图 8.15-2:同一操作在不同故障点留下的状态不同,恢复责任必须回到拥有该状态的组件。
故障点已留下的状态可观察结果恢复责任
第二进程打开同 ThreadProcess A 持有 OS lockProcess B 得到 Conflict复用/等待正确进程,不强占
崩溃留下 lock fileOS lock 已自动释放文件仍在cleanup try_lock 成功后删除
仅凭 mtime 删除活动长任务看起来很旧可能删掉有效协调文件必须用 OS lock 证明无人持有
批量操作锁顺序不同多个 Thread 交叉获取可能死锁按 ThreadId 排序后统一获取

这里没有统一的“回滚一切”。内存状态、日志行、SQLite 投影、父子拓扑和工具造成的文件/网络变化分别有自己的提交点。恢复代码只能根据已经存在的权威证据继续,不能用较弱的投影替较强的事实背书。

必须保持的不变量

  • 同一 Paginated Thread 全系统至多一个 writer guard
  • 不同 Thread 可以并行写
  • stale cleanup 不删除仍被锁住的文件
  • 生命周期管理与 writer 锁按稳定顺序获取

这些不变量比“最终能 Resume”更严格:正常路径要成立,Writer 竞争、任务取消、坏尾行、投影落后和旧格式兼容时也必须成立。

设计取舍

文件锁带来平台兼容和 stale 文件管理,却是单进程 mutex 无法替代的进程间序列保护。

源码可以直接证明字段、分支、调用顺序和测试期望;“为什么这样设计”的表述是基于这些事实作出的工程归纳,不把它包装成未公开的产品承诺。

Mini Codex 复刻

用 flock/msvcrt 抽象实现 per-thread try-lock;启动清理必须尝试加锁,批量 archive/delete 对 ThreadId 排序后加锁。

复刻时先验证协议不变量,再补性能优化。一个能在故障注入下说明“留下了什么”的小实现,比一个只在正常路径调用 save() 的演示更接近真实 Runtime。

源码导航

相邻测试也很重要:

本节结论

进程内 mutex 只能约束一个 LocalThreadStore;Paginated Thread 还用每 Thread 文件锁阻止另一个 Codex 进程同时追加同一 ordinal/offset 序列。

阅读导航

上一节:8.14 · 下一节:8.16

评论


← 返回文章列表