json.dump(data, f) 不是先序列化再写盘,而是用纯 Python 的编码器一边遍历一边写,所以序列化和磁盘 I/O 分不开:放锁里,落盘多久写者就等多久;放锁外,另一个线程一改字典就抛 RuntimeError。Autarkia 的记忆保存在这两种写法之间来回改了一次。
json.dump(data, f) 和 json.dumps(data) 走的不是同一个编码器。json.dumps 一次性生成整个字符串,在我本机的 Python 3.13 上用的是 C 实现;json.dump 要往文件里分块写,用的是纯 Python 的生成器,遍历到哪写到哪。后者意味着:字典还没遍历完,第一块数据已经在写盘了,这期间别的线程完全有机会改这个字典。
一个后台线程不停增删 key,主线程各跑 200 次:
json.dump(d, SlowFile(), indent=2) # 200/200 次 RuntimeError
json.dumps(d, indent=2) # 0/200SlowFile.write 里只有一句 time.sleep(0),模拟真实写盘时让出 GIL。
01第一次:锁里落盘
Autarkia 的记忆管理器用一把读写锁保护内存里的几个大字典。4 月的版本里,后台保存线程拿着写锁调用整套保存,序列化加写多个 JSON 文件,写锁一次持有十秒以上,期间所有读和写都在排队。修法是把保存挪到写锁外面,锁里只清空保存队列、更新索引。
02第二次:锁外遍历
9 月,保存线程开始偶发报 RuntimeError: dictionary changed size during iteration。保存函数这时全程不持锁,写盘那一步用的是:
with open(temp_path, "w", encoding=encoding) as f:
json.dump(data, f, ensure_ascii=False, indent=2)第一轮修法是在被调函数里给字典迭代加 list(...) 快照。list(d.items()) 是 C 层一次性拷贝,在 GIL 下不会被打断,所以这些地方确实不再抛了。但 json.dump 里面的遍历不归这些快照管,序列化的还是原来那个字典。
第二轮把整个保存函数放回了锁里,这次是读锁:
def safe_save_all(manager):
with get_read_lock(manager):
...
manager._safe_json_dump(short_disk_records, short_file)
manager._save_weighted_data_locked()
manager._save_important_prompts_locked()
manager._write_readable_history_mirror()
manager.last_save_time = time.time()读锁挡住了写者,遍历不会再被打断。可这一段里也包括了写好几个文件和 fsync。读者可以并发进来,写者要等到整段落盘结束。4 月那次挪出锁的东西,又回来了一半。现在的写者要等多久,我还没有量过。
03两次都在挪同一个东西
两次修的人(都是我)想的都是「这把锁该盖住哪一段」,没注意到 json.dump 让「遍历数据」和「写磁盘」成了同一个动作,只能一起进锁或一起出锁。把它们拆开,锁的范围就好定了:
with get_read_lock(manager):
payload = json.dumps(data, ensure_ascii=False, indent=2) # 只在内存里,很快
atomic_write_text(path, payload) # 锁外写盘锁里只做内存操作,生成一份不会再被别人改的快照;写盘在锁外。要是数据里有自定义对象、json.dumps 会回调 Python 代码,就在锁里先 copy.deepcopy 一份。这个改法我还没在主项目里落地,记在修复建议里了。顺带要改的是那段注释:它说会抛错的是 json.dumps(indent=...),在 3.13 上实际出问题的是 json.dump(..., f)。