进程内 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 与生命周期锁的关系 |
正常路径:先看顺序点
这条路径可以压缩成五步:
- 按 ThreadId 解析 lock path。
- 协调锁保护创建/清理窗口。
- try_lock OS 文件锁。
- 成功 Guard 随 recorder 存活。
- 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 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。
失败、取消与恢复
| 故障点 | 已留下的状态 | 可观察结果 | 恢复责任 |
|---|---|---|---|
| 第二进程打开同 Thread | Process A 持有 OS lock | Process B 得到 Conflict | 复用/等待正确进程,不强占 |
| 崩溃留下 lock file | OS 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。
源码导航
- codex-rs/thread-store/src/local/writer_lock.rs:per-thread OS lock、协调锁、cleanup 与 Drop
- codex-rs/thread-store/src/local/live_writer.rs:Create/Resume 获取 Paginated writer lock
相邻测试也很重要:
- codex-rs/thread-store/src/local/writer_lock_tests.rs:竞争 owner、不同 Thread 并行和 stale cleanup 保留活动锁
- codex-rs/thread-store/src/local/archive_thread.rs:批量生命周期操作的稳定锁顺序
本节结论
进程内 mutex 只能约束一个 LocalThreadStore;Paginated Thread 还用每 Thread 文件锁阻止另一个 Codex 进程同时追加同一 ordinal/offset 序列。
评论
登录后即可评论