开始前,会这些就够了
- 会运行完整 Python 脚本
- 知道两个进程可以操作同一路径
- 需要补基础时:先分清路径检查与文件操作
遇到不熟悉的词,先看这 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 个子进程均正常退出。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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 还能按原来的结果预测吗?
- 只在程序自己的
TemporaryDirectory内修改受控实验,不使用真实资料路径。 - 让 A 完成 x 创建并关闭,再由协调者删除本轮刚创建的那个临时文件,最后放行 B 的 x。
- 先预测两份创建结果和最后内容,再运行;分别记录创建时刻、删除时刻与后续观察。
做到这些,就算完成
- 说明每一次 x 检查的条件发生在哪个时刻。
- 解释结果是否与“名字一直保留”的原实验矛盾。
- 保留有界等待和所有子进程清理;本节不提供结果答案。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Python 3.14:io.FileIO 创建模式 (新标签页)↗
支持 x 排他创建、目标存在时的
FileExistsError与 w 截断行为。 - CPython v3.14.0:FileIO 模式与系统打开标志 (新标签页)↗
支持 x→
O_EXCL|O_CREAT、w→O_CREAT|O_TRUNC以及 Windows_wopen调用路径。 - Python 3.14:multiprocessing 进程与连接 (新标签页)↗
支持 spawn、Pipe 控制、进程等待与清理、
__main__保护,以及 Windowsset_executable选择 pythonw。