文件替换实测:os.replace 在 Windows 上也会失败,目标文件正被打开就是 WinError 5
一个把自己绕进去的 bug
有个服务要热更新配置文件,我写的是先写临时文件再替换过去:
with open('conf.json', 'w') as f:
json.dump(new_config, f)
os.replace('conf.json', 'config.json')
服务重启后读到的还是旧配置。排查发现,我直接往目标文件写了,然后又拿同一个名字去 replace,等于什么都没换,临时文件那步我压根没写。
顺着这个错,我把这几个 API 在 Windows 上全测了一遍,结果和流传的说法差挺远。测试环境是 Windows 10 加 NTFS,Python 3.13.14。
os.rename:目标已存在直接报错
os.rename(src, dst),而 dst 已经存在:
FileExistsError: [WinError 183] 当文件已存在时,无法创建该文件。
源文件还在,目标内容一个字节没变。官方文档写明 os.rename 在 Windows 上只要目标存在就必然抛这个异常,而 POSIX 的 rename(2) 是直接覆盖旧文件。同一段代码在 Linux 上跑得好好的,搬到 Windows 上就崩,而且报错离真正的原因隔了一层。
os.replace:覆盖成功,但有两个坑
os.replace(tmp, target) 在目标已存在时替换成功,tmp 消失,目标内容变成新的。这是最常被推荐的写法,我原以为它会一直成功。实测下来有两个坑。
坑一:跨卷不会降级成复制,是直接报错
流传较广的说法是,跨卷时 os.replace 会退化成复制加删除,原子性就没了。我实测了一次,源在系统盘、目标在另一块本地盘:
OSError: [WinError 17] 系统无法将文件移到不同的磁盘驱动器。
errno 是 18,也就是 EXDEV。源文件还在,目标文件内容没变。也就是说 os.replace 既没有复制也没有删除,它就是失败了。
真要跨卷搬运的是 shutil.move:同一个场景它成功,源文件消失、目标变成源的内容,代价是中间有一段时间目标文件的状态不对。
坑二:目标文件正被打开时,替换直接失败
这条更反直觉。Windows 上替换一个正被别的句柄打开着的文件,os.replace 会直接抛:
PermissionError: [WinError 5] 拒绝访问。
关键在于替换根本没有发生。目标文件还是旧内容,我写好的临时文件也还留在盘上。
而"正被打开"在真实场景里非常常见:日志切割、配置热更新、缓存落盘,读方大概率正拿着文件句柄。
我做了个并发实验,一个线程每 0.5 毫秒读一次目标文件(每次读 200 KB),主线程连续 os.replace 500 次:
- 500 次里只有 54 次成功,446 次是 WinError 5
- 把读者的节奏放慢到 50 毫秒一次,300 次尝试里 238 次成功、62 次失败
- 完全没有读者的时候也不是 100%:两轮各 200 次,分别失败了 2 次和 6 次,全是 WinError 5,而且立刻重试两次都成功
最后一条说明这些失败不是逻辑问题,而是有别的进程短暂持有了目标文件的句柄,读者、索引服务、杀毒软件都可能。临时文件因此会残留,得自己清理。
替换成功的时候,可见性确实是原子的
失败说完了,说成功的那一面。
我攒够 2000 次成功替换(每次负载 200 KB),同时让读者不停地打开并读全文件,校验内容是否完整:
- 成功替换 2000 次,其间发生 14685 次失败
- 读者一共读了 2705 次
- 读到半截内容 0 次,读到空文件 0 次
读者要么读到完整的旧内容,要么读到完整的新内容,没有中间态。官方文档把 os.replace 的原子性写成 POSIX 要求,我在这台 Windows 机器上的实测也对得上。原子性是真的,只不过在 Windows 上要先保证这一步能成功。
shutil.move:覆盖成功,走的是复制加删源
shutil.move(src, dst) 在 dst 已存在时会覆盖成功。我用包装函数挂住 os.rename 和 os.unlink 记录调用序列:
os.rename -> FileExistsError: 183
(复制)
os.unlink -> ok
所以它确实是先试重命名,撞上"目标已存在"以后退回复制再删源。
复制期间读方看到什么?我拿 8 MB 的文件做了一次,读者全程守着目标文件读:
- 移动耗时 548 毫秒
- 读者读到完整旧内容 8 次、完整新内容 36 次,半截 0 次
- 但有 2 次根本打不开目标文件,抛的是 PermissionError [Errno 13]
也就是说在这台机器上,复制窗口里目标文件不是"读到一半",而是"读不了"。64 MB 那次同样,一个半截都没读到。
不管读方看到的是半截还是打不开,这条路径都不是原子的:复制完成和删除源文件是两个动作,中间崩了就会留下两份。
只读目标和目录目标
顺手测了两个边界,都是 WinError 5:目标文件是只读的,PermissionError,替换没发生,临时文件留在盘上;目标是已存在的目录,同样是 PermissionError。
落地怎么写
替换文件的基本形状还是那句老话,先写同目录临时文件,写完关掉,再 os.replace:
import json, os, tempfile
def atomic_write_json(path, data):
d = os.path.dirname(os.path.abspath(path))
fd, tmp = tempfile.mkstemp(dir=d, suffix='.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
if os.path.exists(tmp):
os.remove(tmp)
raise
在我的实测之外还要补三件事:
- 重试:WinError 5 大概率是别的进程短暂持有句柄,重试一次通常就好,我那两轮间歇失败都是立即重试就成功的
- 清理残留:替换失败时临时文件还在,异常分支里删掉,别让它攒一地
- 同目录:临时文件必须和目标在同一个卷,跨卷的 os.replace 根本不会成功
- 点赞
- 收藏
- 关注作者
评论(0)