文件替换实测:os.replace 在 Windows 上也会失败,目标文件正被打开就是 WinError 5

举报
yd_232353574 发表于 2026/11/03 10:31:54 2026/11/03
【摘要】 实测 os.replace、os.rename、shutil.move 在 Windows 上的真实行为:替换成功时可见性原子(2000 次替换 0 次读到半截),但目标文件正被打开时直接 WinError 5,跨卷则报 WinError 17。

一个把自己绕进去的 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 根本不会成功
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。