qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

两个程序都确认文件不存在,为什么最后仍覆盖了对方?

让两个真实进程先检查、再按指定次序创建。比较 w 与 x,把检查结果和真正取得创建资格分开。

展开本文目录
  1. 下载完整练习包
  2. 检查回答当时情况,没有替后面的动作占位
  3. 普通 w 写入,两个人都能成功,却只留下后来的内容
  4. x 把不存在条件带进创建动作
  5. 运行五轮实验,区分受控顺序与未指定胜者
  6. 取得名字,不等于内容已经完整发布
  7. 它不是一个永久的所有权或通用文件锁
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
检查后使用竞争(TOCTOU)
先看见一个条件成立,到真正执行操作之间,另一个参与者可能已经改变它。
排他创建
把“目标必须不存在”作为创建操作本身的条件;已有同名目标时直接失败。
打开完整术语小抄 ↗

程序 A 想保存自己的结果,先发现 result.txt 不存在。程序 B 也检查了一次,同样得到不存在。随后 A 写入 A,B 写入 B,最后只剩下 B。两个检查都曾经真实,却没有给任何一方保留这个名字。

本篇在程序自己新建的临时目录中启动真实子进程,安排一个可重复的交错顺序。我们不依赖多跑几次碰运气,也不碰已有业务文件;对照同一交错下普通写入与排他创建的结果。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • file_create_race.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B file_create_race.py

解压到独立练习文件夹,在含 file_create_race.py 的目录打开终端运行。Windows/Python 3.14.0 的本机普通临时文件与真实 spawn 进程实验,Windows 子进程通过同目录 pythonw.exe 隐藏窗口;只验证创建竞争,不验证原子内容发布、持久化、符号链接竞态或网络文件系统。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

检查回答当时情况,没有替后面的动作占位

path.exists() 回答的是检查那一刻能否看到目标。调用返回后,别的进程仍可能创建、删除或替换这个名字。把“先检查没有”和“再写入”写在相邻两行,并不会让它们成为一个不能插入别人的整体动作。

实验中的协调进程先收齐 A、B 的检查结果,两者都必须报告不存在;随后才允许任何一个写入。这样便固定了最关键的间隙。控制消息只安排先后,不修改子进程检查的答案,也不伪造文件系统结果。

普通 w 写入,两个人都能成功,却只留下后来的内容

w 模式允许创建文件,也允许把已有文件截断后重写。先放行 A,等它写入并关闭,再放行 B:两者都成功,最终内容是 B。交换次序后,两者仍成功,最终内容变成 A。第二位并不是拿到了旧检查给它保留的资格,而是使用了本来就允许覆盖的写入方式。

因此真正需要表达的条件不是“我刚才看见没有”,而是“本次创建时必须仍然没有,否则失败”。这个条件应随创建动作一起交给文件接口处理,不能靠更频繁地 exists 检查补齐。

x 把不存在条件带进创建动作

Python 的 x 模式表示排他创建:目标已存在时抛出 FileExistsError。本例保留双方都先检查不到文件的同一交错,仍先放行 A,再放行 B;A 创建成功,B 报告已经存在,A 写的内容保留。交换次序时,先创建的 B 保留自己的内容。

CPython v3.14.0 的 FileIO 参考实现把 x 转成 O_EXCL 与 O_CREAT 组合,把 w 转成 O_CREAT 与 O_TRUNC 组合;在 Windows 路径调用 _wopen。这里核对的是明确的接口意图与参考实现,本机实际结果另由下面的运行记录提供。除了已经存在,权限、目录或磁盘故障仍可能导致创建失败,不能把所有异常都当成别人抢先。

运行五轮实验,区分受控顺序与未指定胜者

保存为 file_create_race.py,执行 python -B file_create_race.py。四轮受控实验分别是 w_AB、w_BA、x_AB、x_BA;下标表示创建动作的放行次序,每轮开始前 A、B 的检查都已完成。第五轮 x 不指定哪一方先完成,只要求恰好一方创建、另一方报告已存在,且文件内容与成功者一致。

程序共启动 10 个 spawn 子进程,另检查预先存在的 keep-me 文件不会被 x 截断,总计通过 31 项检查。每轮使用共同的 8 秒期限,父进程收消息和等退出都扣减剩余时间;异常时关闭管道并终止仍未退出的本轮子进程。它不根据谁赢得更多次来测量调度公平或性能。

Windows 子进程不需要交互窗口,脚本只在本次运行内用 multiprocessing.set_executable 选择当前解释器同目录的 pythonw.exe;没有该文件就明确报错,不修改系统设置。__main__ 保护保留在完整源码中,必须作为文件运行。制作时在 Windows/Python 3.14.0 实际运行,10 个子进程均正常退出。

展开收起代码 · 小实验python · 150 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Real two-process file creation in a private temporary directory."""
import json
import multiprocessing as mp
import os
import sys
import tempfile
import time
from pathlib import Path


def worker(name, filename, mode, connection):
    path = Path(filename)
    try:
        connection.send({"stage": "checked", "exists": path.exists()})
        if connection.recv() != "create":
            raise ValueError("unknown command")
        try:
            with path.open(mode, encoding="ascii") as stream:
                stream.write(name)
            result = "created_or_written"
        except FileExistsError:
            result = "already_exists"
        connection.send({"stage": "finished", "name": name, "result": result})
    finally:
        connection.close()


def receive(connection, deadline):
    remaining = deadline - time.monotonic()
    if remaining <= 0 or not connection.poll(remaining):
        raise TimeoutError("child did not report before the shared deadline")
    return connection.recv()


def run_round(folder, label, mode, order):
    # order=None releases both children without prescribing the winning process.
    context = mp.get_context("spawn")
    path = Path(folder) / (label + ".txt")
    processes, parents = {}, {}
    deadline = time.monotonic() + 8
    try:
        for name in ("A", "B"):
            parent, child = context.Pipe()
            process = context.Process(target=worker, args=(name, str(path), mode, child))
            parents[name], processes[name] = parent, process
            try:
                process.start()
            finally:
                child.close()
        checked = {name: receive(parents[name], deadline) for name in ("A", "B")}
        if not all(item == {"stage": "checked", "exists": False} for item in checked.values()):
            raise AssertionError("both checks must finish before either creation")
        finished = {}
        if order is None:
            for name in ("A", "B"):
                parents[name].send("create")
            for name in ("A", "B"):
                finished[name] = receive(parents[name], deadline)
        else:
            for name in order:
                parents[name].send("create")
                finished[name] = receive(parents[name], deadline)
        for process in processes.values():
            process.join(max(0, deadline - time.monotonic()))
            if process.is_alive() or process.exitcode != 0:
                raise RuntimeError("child did not exit normally before deadline")
        return {"both_checked_absent": True,
                "outcomes": {name: finished[name]["result"] for name in ("A", "B")},
                "content": path.read_text(encoding="ascii"),
                "children_exited_zero": True}
    finally:
        for connection in parents.values():
            connection.close()
        for process in processes.values():
            if process.pid is not None and process.is_alive():
                process.terminate()
                process.join(2)
            if process.pid is not None and process.is_alive():
                process.kill()
                process.join(2)
            if process.pid is not None and not process.is_alive():
                process.close()


def main():
    checks = 0

    def check(condition):
        nonlocal checks
        if not condition:
            raise AssertionError("file creation check failed")
        checks += 1

    controlled = {}
    with tempfile.TemporaryDirectory(prefix="exclusive-create-") as folder:
        for mode in ("w", "x"):
            for order in (("A", "B"), ("B", "A")):
                label = mode + "_" + "".join(order)
                result = run_round(folder, label, mode, order)
                check(result["both_checked_absent"])
                check(result["children_exited_zero"])
                successes = sum(v == "created_or_written" for v in result["outcomes"].values())
                check(successes == (2 if mode == "w" else 1))
                check(result["content"] == (order[-1] if mode == "w" else order[0]))
                check(result["outcomes"][order[0]] == "created_or_written")
                check(result["outcomes"][order[1]] ==
                      ("created_or_written" if mode == "w" else "already_exists"))
                controlled[label] = result

        raced = run_round(folder, "x_unordered", "x", None)
        winners = [name for name, status in raced["outcomes"].items()
                   if status == "created_or_written"]
        check(raced["both_checked_absent"])
        check(raced["children_exited_zero"])
        check(len(winners) == 1)
        check(list(raced["outcomes"].values()).count("already_exists") == 1)
        check(raced["content"] == winners[0])

        existing = Path(folder) / "existing.txt"
        existing.write_text("keep-me", encoding="ascii")
        try:
            with existing.open("x", encoding="ascii"):
                pass
        except FileExistsError:
            check(True)
        else:
            raise AssertionError("exclusive creation replaced an existing file")
        check(existing.read_text(encoding="ascii") == "keep-me")

    print(json.dumps({"checks_passed": checks, "real_child_processes": 10,
                      "child_console": "hidden via pythonw" if os.name == "nt" else "not applicable",
                      "controlled_rounds": controlled,
                      "unordered_exclusive_round": {
                          "both_checked_absent": True, "successes": 1,
                          "already_exists": 1, "content_matches_winner": True,
                          "winner_order_is_not_guaranteed": True},
                      "preexisting_content_preserved": True,
                      "scope": "local temporary files; creation does not atomically publish payload or grant a permanent lock"},
                     ensure_ascii=False, indent=2))


if __name__ == "__main__":
    mp.freeze_support()
    if os.name == "nt":
        # This experiment has no interactive child UI. Keep Windows workers hidden.
        pythonw = Path(sys.executable).with_name("pythonw.exe")
        if not pythonw.is_file():
            raise RuntimeError("Windows workers require pythonw.exe beside this Python interpreter")
        mp.set_executable(str(pythonw))
    main()

怎样运行:在独立练习文件夹中执行 python -B file_create_race.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。Windows/Python 3.14.0 的本机普通临时文件与真实 spawn 进程实验,Windows 子进程通过同目录 pythonw.exe 隐藏窗口;只验证创建竞争,不验证原子内容发布、持久化、符号链接竞态或网络文件系统。

运行后对照结果
{
  "checks_passed": 31,
  "real_child_processes": 10,
  "child_console": "hidden via pythonw",
  "controlled_rounds": {
    "w_AB": {
      "both_checked_absent": true,
      "outcomes": {
        "A": "created_or_written",
        "B": "created_or_written"
      },
      "content": "B",
      "children_exited_zero": true
    },
    "w_BA": {
      "both_checked_absent": true,
      "outcomes": {
        "A": "created_or_written",
        "B": "created_or_written"
      },
      "content": "A",
      "children_exited_zero": true
    },
    "x_AB": {
      "both_checked_absent": true,
      "outcomes": {
        "A": "created_or_written",
        "B": "already_exists"
      },
      "content": "A",
      "children_exited_zero": true
    },
    "x_BA": {
      "both_checked_absent": true,
      "outcomes": {
        "A": "already_exists",
        "B": "created_or_written"
      },
      "content": "B",
      "children_exited_zero": true
    }
  },
  "unordered_exclusive_round": {
    "both_checked_absent": true,
    "successes": 1,
    "already_exists": 1,
    "content_matches_winner": true,
    "winner_order_is_not_guaranteed": true
  },
  "preexisting_content_preserved": true,
  "scope": "local temporary files; creation does not atomically publish payload or grant a permanent lock"
}

取得名字,不等于内容已经完整发布

排他创建成功后,文件名已经出现,后续写入内容还需要时间。另一个只负责读取的程序仍可能遇到尚未写完的文件;如果创建者在写到一半时退出,x 也不会自动补完数据。当前实验等写入者关闭且进程结束后才读最终内容,所以没有验证读者在写入期间能看到什么。

如果业务承诺读者只能看到完整结果,还需要独立的发布协议,例如先准备候选内容,再使用目标平台确实支持的替换或提交方式,并考虑崩溃恢复和持久化。这里没有把“创建时不存在”扩展成完整写入、原子发布或断电后保留的保证。

它不是一个永久的所有权或通用文件锁

x 不阻止后来有权限的人删除名字,也不阻止有人另用 w 覆盖。文件一旦被删除,后续 x 又可能成功。因此仅凭某次排他创建成功,不能宣称自己永远拥有业务资源,更不能直接当作跨主机租约或可靠分布式锁。

实验使用本机可信临时目录和普通文件,不包含攻击者修改父目录、符号链接切换或网络文件系统。原有路径课提醒的真实路径、权限与检查使用间隙仍需分别处理;这份对照只具体验证创建入口的一个条件。

记住这三点

  • 不存在检查是过去的一次观察,不是预订名字。
  • 需要拒绝已有目标时,将条件交给排他创建,并处理真实错误结果。
  • 创建资格、完整发布、持久化和后续所有权是不同承诺。

别把例子用过头

  • 先 exists 再 w,却认为两行共同保证不会覆盖。
  • 把所有 OSError 都当作正常的竞争失败。
  • 把 x 创建成功当成内容已完整、文件不可再变或跨主机锁。

自己试一小步

如果第一次创建后,这个临时名字又被移除了,后一次 x 还能按原来的结果预测吗?

  1. 只在程序自己的 TemporaryDirectory 内修改受控实验,不使用真实资料路径。
  2. 让 A 完成 x 创建并关闭,再由协调者删除本轮刚创建的那个临时文件,最后放行 B 的 x。
  3. 先预测两份创建结果和最后内容,再运行;分别记录创建时刻、删除时刻与后续观察。

做到这些,就算完成

  • 说明每一次 x 检查的条件发生在哪个时刻。
  • 解释结果是否与“名字一直保留”的原实验矛盾。
  • 保留有界等待和所有子进程清理;本节不提供结果答案。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Python 3.14:io.FileIO 创建模式 (新标签页)↗Python Software Foundation · 英文

    支持 x 排他创建、目标存在时的 FileExistsError 与 w 截断行为。

    2026-10-17 实际打开核对:Raw File I/O 的 FileIO 模式段落;没有把 FileIO 的单次原始写入语义等同于整个文本文件发布。

  • CPython v3.14.0:FileIO 模式与系统打开标志 (新标签页)↗Python Software Foundation / CPython · 英文

    支持 x→O_EXCL|O_CREAT、w→O_CREAT|O_TRUNC 以及 Windows _wopen 调用路径。

    2026-10-17 实际打开核对:FileIO 初始化中的 x/w 分支和实际打开调用;这是固定版本参考源码,未声称审计了本机二进制全部编译细节。

  • Python 3.14:multiprocessing 进程与连接 (新标签页)↗Python Software Foundation · 英文

    支持 spawn、Pipe 控制、进程等待与清理、__main__ 保护,以及 Windows set_executable 选择 pythonw。

    2026-10-17 实际打开核对:Contexts and start methods、Pipe/Connection、Process、set_executable 和 Safe importing of main module;本篇没有使用并行胜率推导公平性。