Windows 上「保存失败」:文件被占用时原子替换为什么会报错

在桌面应用里做自动保存时,遇到了一个看起来像玄学的问题:

保存失败:拒绝访问 (os error 5)

代码逻辑没有任何异常分支会走到这条路径,重试也没有用。查了半天才发现:这不是权限问题,是目标文件正在被别的东西打开着。

根因

原子保存的常规做法是:写一个临时文件 → 把临时文件 rename 成目标文件。

在 POSIX 上,这个操作是原子的:别的进程要么看到旧文件,要么看到新文件,不会看到半截内容。Windows 上 MoveFileEx 的语义类似,但如果目标文件当前还有句柄指向它,替换就会失败。

而在桌面应用里,「还有句柄指向目标文件」是常态:

  • 自动保存任务正在读目标文件;
  • 另一个任务同时触发了手动保存;
  • 文件索引、预览、杀毒软件、搜索索引都可能持有句柄。

只要任何一个句柄还开着,替换就会以 FILE_REPLACE_FAILED 失败。

三层修法

1. 进程内写操作串行化

最有效的一层:所有写路径共用一把进程内的锁。

static WRITE_LOCK: Mutex<()> = Mutex::new(());

自动保存和手动保存排队,后一个不再踩前一个。这不是「优化」,是消除了一整类竞争。

2. 替换失败要退避重试

进程内锁挡不住外部持有者。占用句柄通常很短命,所以失败后应该退避重试:

25ms → 50ms → 75ms → 100ms,最多 5 次

真正的权限问题重试后仍会失败,此时再如实报错即可。重试不是万能药,但它精确覆盖了「短命占用」这个场景。

3. 重试时不能丢掉临时文件

这是最容易写错的一层。持久化失败时,错误对象里带着临时文件本身,必须把它留下来继续用:

persist() 失败
→ 从错误里取出临时文件
→ 退避
→ 再次 persist()

如果这里把临时文件丢了直接返回错误,用户看到的就是「保存失败」,而磁盘上那份完整的新内容被一起删掉了——最坏的情况是既没保存成功,又丢了数据。

一个反直觉的结论

排查过程中我做过一个假设:把目录设为只读,就能模拟「写不进去」。

在 Windows 上这是错的。 把目录设为只读,并不会阻止在里面创建或修改文件——这个属性只影响删除和重命名目录本身。

所以那次「模拟写失败」的测试其实是个假阴性:atomic_write 照样返回 Ok,测试里的 unwrap_err() 反而失败了。

可用的模拟方式有两个:

  • 让替换目标是一个已存在的目录(rename 到目录必失败);
  • 用并发读者测试守住真正的不变量。

第二条更值得做。真正要保证的不变量不是「写不进去会报错」——那本来就是环境行为;而是:

读者永远看不到半截文件。

把测试目标从「模拟错误」换成「守住不变量」,测试才真正有价值。




Enjoy Reading This Article?

Here are some more articles you might like to read next: