Python 基础体系 · 第 37/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 文件系统与 pathlib:路径、遍历、元数据、原子替换和竞态

文件系统编程表面上是在拼接路径、读取目录、写入文件,实际处理的是一组由操作系统维护的状态:

  • 路径字符串如何解释;
  • 路径是否真的对应某个对象;
  • 对象是普通文件、目录、符号链接还是其他类型;
  • 目录内容在遍历期间是否发生变化;
  • 写入是否对其他进程可见;
  • 替换过程中读者看到的是旧文件、新文件,还是半成品;
  • 检查结果与随后操作之间,目标是否已经被其他线程、进程或攻击者改变。

pathlib 主要解决路径表示和高层文件系统操作;osstattempfile 等模块则提供更接近操作系统语义的工具。理解两者的边界,才能正确处理元数据、原子替换和竞态。


一、先区分“路径”与“文件系统对象”

路径是操作系统用来定位对象的一串名称。它不是对象本身,也不是对象的永久身份。

例如:

/data/config.json

可以被理解为:

  1. 从根目录 / 开始;
  2. 查找目录项 data
  3. data 中查找目录项 config.json
  4. 最终得到一个文件、目录、符号链接,或者失败。

路径解析过程中,目录项可能发生变化。假设某一时刻:

/data/config.json -> 普通文件 A

随后另一个进程执行:

unlink("/data/config.json")
symlink("/etc/passwd", "/data/config.json")

同一个字符串 /data/config.json 在不同时间可能指向不同对象。因此:

路径是定位对象的过程,不是对象身份。

在 POSIX 文件系统中,文件的身份通常由设备号和 inode 号共同描述;Python 可以通过 stat_result.st_devst_ino 观察这些信息。但这些字段的可移植性有限,Windows 上部分字段可能是零或具有不同语义。os.stat() 返回的 stat_result 包含大小、时间、类型、权限、设备号、inode 等字段,但具体字段和含义会随平台变化。(docs.python.org)

这一区分会贯穿全文:

path = Path("data/config.json")

# 这是一次“通过路径查找对象”的操作
info = path.stat()

# 这是打开对象后得到的文件对象
with path.open("rb") as file:
    data = file.read()

第一次调用和第二次调用之间,路径可能已经指向另一个对象。若程序需要保证“检查的对象”和“操作的对象”相同,单纯保存 Path 对象是不够的。


二、pathlib 的两类路径:纯路径与具体路径

pathlib 将路径对象分为两大类:

  • 纯路径:只做路径语法运算,不访问操作系统;
  • 具体路径:除了路径运算,还能执行 stat()open()iterdir() 等文件系统操作。

官方文档将 PurePathPurePosixPathPureWindowsPath 归入纯路径,将 PathPosixPathWindowsPath 归入具体路径。Path 会根据当前运行平台选择对应实现。(docs.python.org)

1. 纯路径只进行词法运算

from pathlib import PurePosixPath, PureWindowsPath

p1 = PurePosixPath("a/../b")
p2 = PureWindowsPath(r"C:\Users\alice\file.txt")

print(p1)          # a/../b
print(p1.parent)   # a
print(p2.name)     # file.txt

这里不会检查:

  • a 是否存在;
  • a 是否是目录;
  • a 是否是符号链接;
  • b 是否最终能被解析。

PurePath.parent 是词法意义上的父路径。例如:

from pathlib import PurePosixPath

path = PurePosixPath("foo/..")

print(path.parent)

结果是:

foo

而不是当前目录。这是因为纯路径不能知道 foo 是否为符号链接。若 foo 是指向另一个目录的符号链接,直接消除 .. 甚至可能改变路径语义。官方文档明确指出,.. 不能在纯词法处理中随意折叠。(docs.python.org)

2. 具体路径访问文件系统

from pathlib import Path

path = Path("data/config.json")

if path.is_file():
    print(path.read_text(encoding="utf-8"))

Path 实现了 os.PathLike 协议,因此可以传给接受路径对象的标准库 API:

import os
from pathlib import Path

path = Path("data/config.json")

print(os.fspath(path))

典型输出为:

data/config.json

Path 对象不可变。表达式:

child = root / "logs" / "app.log"

不会改变 root,而是生成一个新的路径对象。斜杠运算符本质上是路径拼接,但必须注意:如果后续部分是绝对路径,前面的部分会被丢弃。

from pathlib import Path

base = Path("/srv/app")

print(base / "logs" / "app.log")
print(base / "/tmp/file")

输出:

/srv/app/logs/app.log
/tmp/file

因此,下面这种代码不能把用户输入当作普通文件名:

target = base / user_input

如果 user_input/etc/passwd,拼接结果就不在 base 目录下。


三、路径规范化:absolute()resolve()relative_to()

这三个 API 解决的是不同问题,不能互相替代。

1. absolute():补全当前目录,不解析符号链接

from pathlib import Path

path = Path("a/../b")
print(path.absolute())

它会把相对路径转换为绝对路径,但不会负责消除 ..,也不会解析符号链接。官方文档将其定义为“不做规范化、不解析符号链接”的绝对化操作。(docs.python.org)

2. resolve():解析符号链接并消除 ..

from pathlib import Path

path = Path("data/../config.json")

print(path.resolve())

resolve() 会:

  1. 转换为绝对路径;
  2. 解析遇到的符号链接;
  3. 消除 ..
  4. 根据 strict 决定遇到不存在路径时是否报错。
path.resolve(strict=True)

要求路径能够被完整解析;如果路径不存在或存在符号链接循环,会抛出异常。

path.resolve(strict=False)

会解析能够解析的部分,剩余部分可以保留为未验证的路径。

但必须明确:

resolve() 是某一时刻的路径解析结果,不会锁定后续文件系统状态。

下面这种“先解析、后写入”的代码仍可能有竞态:

resolved = user_path.resolve()

if allowed_root in resolved.parents:
    resolved.write_text("data", encoding="utf-8")

检查完成后,路径上的目录仍可能被替换为符号链接。resolve() 适合做路径展示、日志记录和非对抗性的边界检查,不是自动提供安全文件访问的机制。

3. relative_to():计算词法上的相对路径

from pathlib import Path

path = Path("/srv/app/data/report.csv")

print(path.relative_to("/srv/app"))

输出:

data/report.csv

如果目标路径不在基路径的词法子树中,默认会抛出 ValueError

Path("/etc/passwd").relative_to("/srv/app")

需要注意,relative_to() 默认不访问文件系统,也不解析符号链接。Python 3.12 起提供了 walk_up=True,允许通过 .. 生成向上回溯的结果,但它仍然是路径运算;如果路径中存在符号链接,应该先谨慎处理解析问题。(docs.python.org)


四、路径边界检查:词法包含不等于安全包含

一个常见需求是:用户提供一个相对文件名,程序只允许访问某个根目录下的文件。

1. 错误写法:字符串前缀判断

root = "/srv/app"
candidate = "/srv/app-secret/config.json"

if candidate.startswith(root):
    print("allowed")

结果会误判,因为:

/srv/app-secret/config.json

虽然以 /srv/app 开头,但它不是 /srv/app 的子路径。

2. 改进写法:路径关系判断

from pathlib import Path

root = Path("/srv/app").resolve()
candidate = (root / user_input).resolve()

try:
    candidate.relative_to(root)
except ValueError:
    raise PermissionError("path escapes root directory")

这比字符串前缀判断准确,但依然存在时间窗口:

时间 T1:resolve() 检查通过
时间 T2:目录被替换为符号链接
时间 T3:open() 访问了另一个位置

这就是典型的 TOCTOU

Time Of Check To Time Of Use,即“检查时刻”和“使用时刻”之间的竞态。

对于不可信输入、共享目录和高权限程序,安全边界不能只依赖 resolve()。Unix 平台可以进一步使用目录文件描述符、dir_fdfollow_symlinks=FalseO_NOFOLLOW 等底层机制,但这些能力具有平台差异。Python 文档说明,dir_fd 参数目前只在 Unix 平台工作,Windows 不支持这些目录描述符参数。(docs.python.org)


五、文本、二进制和缓冲:路径操作之后的数据流

路径对象负责定位文件,文件内容则通过 I/O 流传输。Path.open() 的参数语义与内置 open() 基本一致。(docs.python.org)

1. 文本模式处理 str

from pathlib import Path

path = Path("message.txt")

with path.open("w", encoding="utf-8", newline="\n") as file:
    file.write("你好\n")

with path.open("r", encoding="utf-8") as file:
    text = file.read()

print(text)

文本流:

  • 接受和产生 str
  • 对底层字节进行编码和解码;
  • 可以进行换行符转换;
  • 编码不应依赖默认值。

io 文档明确区分了文本 I/O 和二进制 I/O:文本流操作 str,二进制流操作 bytes,二进制流不执行编码、解码或换行转换。(docs.python.org)

2. 二进制模式处理 bytes

from pathlib import Path

path = Path("data.bin")

with path.open("wb") as file:
    file.write(b"\x00\x01\x02")

with path.open("rb") as file:
    data = file.read()

print(data)

输出:

b'\x00\x01\x02'

对于图片、压缩包、数据库文件、加密数据和未知编码的数据,应该使用二进制模式。把任意二进制内容当作文本读取,可能因为解码失败或换行转换而改变数据。

3. read_text()write_text() 适合小文件

config = Path("config.json")

config.write_text('{"debug": true}\n', encoding="utf-8")
text = config.read_text(encoding="utf-8")

write_text() 会打开文件、写入内容并关闭文件;同名文件会被覆盖。read_text() 会一次性读入完整内容。(docs.python.org)

对于大文件,应使用流式处理:

from pathlib import Path

source = Path("large.log")

with source.open("r", encoding="utf-8") as file:
    for line_number, line in enumerate(file, start=1):
        if "ERROR" in line:
            print(line_number, line.rstrip("\n"))

这里的内存占用主要与单行长度有关,而不是与整个文件大小成正比。若单行本身可能极大,仍需设计最大行长或改用二进制分块读取。


六、目录遍历:iterdir()glob()rglob()walk()

不同遍历 API 表达的是不同意图。

1. iterdir():读取一层目录

from pathlib import Path

root = Path("project")

for child in root.iterdir():
    print(child)

iterdir() 只读取当前目录的直接子项,不递归。返回顺序是任意的;如果迭代器创建后目录发生变化,某个新增或删除的条目是否出现在结果中是不确定的。路径不存在、不是目录或不可访问时,会抛出 OSError。(docs.python.org)

如果需要稳定输出,显式排序:

for child in sorted(root.iterdir(), key=lambda p: p.name):
    print(child)

排序并不意味着遍历结果是一个快照。它只对已经返回的路径对象排序。

2. glob():按模式筛选

from pathlib import Path

root = Path("project")

for path in root.glob("*.py"):
    print(path)

常见模式:

root.glob("*.py")       # 当前目录中的 Python 文件
root.glob("*/*.py")     # 下一层目录中的 Python 文件
root.glob("**/*.py")    # 递归匹配 Python 文件
root.rglob("*.py")      # 等价于递归 glob

Path.glob()Path.rglob() 返回顺序没有保证;需要稳定结果时必须排序。Python 3.14 中,glob()rglob() 支持 case_sensitiverecurse_symlinks 参数;扫描文件系统时产生的 OSError 会被抑制,包括访问目录时的 PermissionError。(docs.python.org)

这会带来一个诊断陷阱:

files = list(Path("/").rglob("secret.txt"))

结果为空,不一定表示目标不存在,也可能表示某些目录没有权限,扫描错误被忽略了。

如果必须记录访问错误,使用 Path.walk()os.walk(),而不是把 rglob() 当作审计工具。

3. Path.walk():需要控制目录树时使用

Python 3.12 引入了 Path.walk()。它对目录树逐层产生:

(dirpath, dirnames, filenames)

其中:

  • dirpath 是当前目录的 Path
  • dirnames 是当前目录下子目录名列表;
  • filenames 是当前目录下非目录文件名列表。
from pathlib import Path

root = Path("project")

for dirpath, dirnames, filenames in root.walk():
    print(f"[DIR] {dirpath}")
    for name in filenames:
        print("  ", dirpath / name)

默认是自顶向下遍历:

先产生 root
再产生 root/child
再产生 root/child/grandchild

top_down=True 时,可以原地修改 dirnames 来剪枝:

from pathlib import Path

root = Path("project")

for dirpath, dirnames, filenames in root.walk():
    dirnames[:] = [
        name for name in dirnames
        if name not in {".git", "__pycache__", "node_modules"}
    ]

    for name in filenames:
        print(dirpath / name)

剪枝成立的原因是:Path.walk() 会根据遍历者留下的 dirnames 决定后续递归目录。目录名是字符串,因此完整路径需要通过 dirpath / name 构造。目录列表和文件列表是否排序取决于文件系统。(docs.python.org)

4. 自底向上删除目录树

目录必须为空才能删除,因此删除目录树时要先删除内容,再删除目录:

from pathlib import Path

root = Path("build-cache")

for dirpath, dirnames, filenames in root.walk(top_down=False):
    for name in filenames:
        (dirpath / name).unlink()

    for name in dirnames:
        (dirpath / name).rmdir()

root.rmdir()

这个示例只适用于你明确知道树中不存在需要特殊处理的符号链接、挂载点和并发修改。Path.walk() 默认不跟随符号链接,但会把指向目录的符号链接放入 filenames;如果设置 follow_symlinks=True,可能因符号链接指向父目录而无限递归,而且 Path.walk() 不会自动记录已访问目录。(docs.python.org)

生产环境删除目录树通常应优先使用:

import shutil
from pathlib import Path

shutil.rmtree(Path("build-cache"))

但即使是 shutil.rmtree(),也必须确认目标路径来自可信配置,避免把错误的根目录传入删除函数。


七、符号链接:stat()lstat()follow_symlinks

假设:

link.txt -> real.txt

那么:

from pathlib import Path

link = Path("link.txt")

print(link.stat().st_size)
print(link.lstat().st_size)

stat() 默认跟随符号链接,返回 real.txt 的元数据;lstat() 返回符号链接目录项自身的元数据。Path.stat(follow_symlinks=False) 也可以表达不跟随符号链接的意图。(docs.python.org)

判断类型时同样要明确语义:

path.is_file()                    # 默认跟随符号链接
path.is_file(follow_symlinks=False)
path.is_dir()
path.is_dir(follow_symlinks=False)
path.is_symlink()

例如:

from pathlib import Path

link = Path("link-to-dir")

print(link.is_dir())                         # 链接目标是目录时为 True
print(link.is_dir(follow_symlinks=False))    # 链接本身不是目录时为 False
print(link.is_symlink())                     # True

Python 3.14 中,exists()is_file()is_dir() 等方法对操作系统抛出的 OSError 会返回 False,不再像某些旧版本那样在部分情况下传播异常。若需要区分“不存在”“无权限”“路径无效”,应直接调用 stat() 并处理具体异常。(docs.python.org)

from pathlib import Path

path = Path("data/input.txt")

try:
    info = path.stat()
except FileNotFoundError:
    print("不存在")
except PermissionError:
    print("没有权限")
except OSError as exc:
    print(f"其他文件系统错误: {exc}")
else:
    print(f"大小: {info.st_size}")

不要把下面两种情况混为一谈:

if not path.exists():
    ...

它只能说明一次查询结果为假,不能准确说明失败原因,也不能保证后续操作仍然面对同一个状态。


八、元数据:stat_result 中到底有什么

最常用的元数据来自:

from pathlib import Path

info = Path("data/report.csv").stat()

print(info.st_size)
print(info.st_mtime)
print(info.st_mode)

常见字段包括:

字段 含义
st_mode 文件类型和权限位
st_ino 文件标识,平台相关
st_dev 所在设备,平台相关
st_nlink 硬链接数量
st_uid 所有者 UID,主要用于 Unix
st_gid 所属组 GID,主要用于 Unix
st_size 文件大小,单位为字节
st_atime 最近访问时间
st_mtime 最近修改时间
st_ctime 平台相关的状态变化时间

st_ctime 尤其容易误解:在 Unix 上通常表示 inode 状态变化时间,而不是创建时间;在 Windows 上历史上表示创建时间,但 Python 3.12 起已弃用这种含义,创建时间应使用 st_birthtime。(docs.python.org)

stat 模块判断类型,比手动位运算更清晰:

import stat
from pathlib import Path

info = Path("data/input").lstat()

if stat.S_ISREG(info.st_mode):
    print("普通文件")
elif stat.S_ISDIR(info.st_mode):
    print("目录")
elif stat.S_ISLNK(info.st_mode):
    print("符号链接")
else:
    print("其他类型")

Path.info:缓存的类型信息

Python 3.14 新增了 Path.info。通过 iterdir() 得到的路径对象可能携带父目录扫描时得到的类型信息:

from pathlib import Path

for child in Path("data").iterdir():
    if child.info.is_dir():
        print("目录:", child)
    elif child.info.is_symlink():
        print("链接:", child)
    else:
        print("其他:", child)

Path.info 的方法可能使用缓存,从而减少类型判断的系统调用。但缓存不是实时状态;若需要最新结果,应调用 Path.is_dir()Path.is_file()Path.is_symlink()。文档还说明,Path.info 没有重置缓存的方法,可以通过 Path(p) 创建一个新的路径对象。(docs.python.org)

因此:

child.info.is_file()

适合在目录扫描后立即进行快速分类;而在删除、覆盖、权限检查等敏感操作前,不应把缓存结果当作当前真实状态。


九、os.scandir() 与性能:为什么 walk() 不只是 listdir() 的包装

os.listdir() 返回名称列表:

import os

for name in os.listdir("data"):
    print(name)

os.scandir() 返回 DirEntry 迭代器:

import os

with os.scandir("data") as entries:
    for entry in entries:
        if entry.is_file():
            print(entry.name, entry.stat().st_size)

DirEntry 通常携带目录扫描时获得的文件类型信息,因此当程序既要遍历又要判断文件类型时,可以减少额外系统调用。官方文档指出,is_dir()is_file() 通常只在符号链接等情况下需要额外系统调用,而 DirEntry.stat() 在 Unix 上总是需要系统调用,在 Windows 上对符号链接等情况需要调用。(docs.python.org)

这并不表示 DirEntry 的结果永远实时。其部分结果会缓存;如果必须取得最新元数据,应重新调用 os.stat()

os.walk() 从 Python 3.5 起基于 os.scandir() 实现,以减少需要的 stat() 调用。(docs.python.org)

选择关系可以概括为:

只读取一层目录       -> Path.iterdir()
按模式查找           -> Path.glob() / Path.rglob()
需要剪枝、错误回调    -> Path.walk()
需要 DirEntry 和底层控制 -> os.scandir()
需要目录 fd          -> os.fwalk()

十、普通写入为什么会产生半成品

考虑下面的写入:

from pathlib import Path

Path("config.json").write_text(
    '{"version": 2, "features": ["a", "b"]}\n',
    encoding="utf-8",
)

这个操作通常相当于:

  1. 打开目标文件;
  2. 截断原内容;
  3. 写入新内容;
  4. 关闭文件。

如果进程在第 2 步之后崩溃,目标文件可能已经变成空文件;如果写入过程中其他进程读取目标,它可能看到不完整内容。

状态变化可以表示为:

旧文件
  |
  | open("w") + truncate
  v
空文件
  |
  | write 部分数据
  v
半成品
  |
  | write 完成
  v
新文件

这与“写入最终成功”是两回事。缓冲还会增加一层状态:Python 的 write() 返回,并不必然意味着数据已经到达持久存储。对于文件对象,官方文档建议先 flush(),再使用 os.fsync(file.fileno()),以确保 Python 内部缓冲区的数据提交给操作系统并执行同步。(docs.python.org)


十一、原子替换:先写临时文件,再替换目录项

原子替换的核心不是“写入过程很快”,而是让观察者在替换点前后看到两个完整状态:

替换前:目标 -> 旧文件
替换后:目标 -> 新文件

观察者不会通过目标路径看到:

空文件
半个 JSON
旧内容和新内容混合

典型流程是:

1. 在目标目录创建临时文件
2. 将完整内容写入临时文件
3. flush()
4. fsync()
5. 调用 os.replace(temp, target)
6. 必要时同步目录元数据

临时文件必须和目标文件位于同一文件系统中。否则重命名可能失败,因为跨文件系统不能执行同一个目录项原子移动。

完整示例

from __future__ import annotations

import os
import tempfile
from pathlib import Path


def atomic_write_text(
    target: Path,
    text: str,
    *,
    encoding: str = "utf-8",
) -> None:
    target = Path(target)
    target.parent.mkdir(parents=True, exist_ok=True)

    temporary_name: str | None = None

    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding=encoding,
            newline="",
            dir=target.parent,
            prefix=f".{target.name}.",
            suffix=".tmp",
            delete=False,
        ) as file:
            temporary_name = file.name
            file.write(text)
            file.flush()
            os.fsync(file.fileno())

        os.replace(temporary_name, target)
        temporary_name = None

        # Unix 上同步父目录,有助于提高“重命名结果在断电后仍可见”的保证。
        # Windows 上目录通常不能像普通文件一样通过这种方式打开。
        if os.name == "posix":
            directory_fd = os.open(target.parent, os.O_RDONLY)
            try:
                os.fsync(directory_fd)
            finally:
                os.close(directory_fd)

    finally:
        if temporary_name is not None:
            try:
                os.unlink(temporary_name)
            except FileNotFoundError:
                pass

调用:

from pathlib import Path

atomic_write_text(
    Path("data/config.json"),
    '{"version": 2, "enabled": true}\n',
)

每一步为什么成立

1. 临时文件与目标处于同一目录

dir=target.parent

这同时解决两个问题:

  • 减少跨文件系统失败的可能;
  • 确保替换发生在同一个目录中。

临时文件模块会使用随机字符生成文件名,并提供自动清理的高层接口;NamedTemporaryFile(delete=False) 适合需要在关闭后用文件名执行替换的场景。(docs.python.org)

2. 完整写入临时文件

目标路径始终指向旧文件,因此其他读取者不会看到正在生成的新内容。

3. flush()fsync()

flush() 处理 Python 文件对象自身的缓冲;os.fsync() 请求操作系统将文件描述符对应的数据同步到存储设备。两者作用不同,不能只调用其中一个。(docs.python.org)

4. os.replace()

os.replace(src, dst) 会将源路径重命名到目标路径;目标是已有文件时会替换它。若源和目标在同一文件系统且操作成功,POSIX 要求该重命名是原子的。Python 3.14 的 Path.replace() 具有相同的替换语义。(docs.python.org)

5. 同步父目录

文件内容同步与目录项同步不是同一件事。os.replace() 改变了父目录中的目录项;在需要更强崩溃一致性的 Unix 场景中,还会同步父目录文件描述符。

但要区分两个保证:

原子可见性:
读者不会看到目标文件的半成品

崩溃持久性:
断电或系统崩溃后,替换结果仍然存在

os.replace() 主要解决前者。后者还依赖 flush()fsync()、目录同步、文件系统、存储设备和操作系统行为。不能因为替换是原子的,就推断断电后一定保留新文件。


十二、Path.rename()Path.replace() 与跨文件系统移动

1. rename() 的目标存在语义依赖平台

source.rename(target)

Path.rename() 使用 os.rename()。在 Unix 上,目标是文件时通常可以覆盖;在 Windows 上,目标存在时通常会抛出 FileExistsError。如果源和目标位于不同文件系统,操作可能失败。(docs.python.org)

2. replace() 明确表达“无条件替换”

source.replace(target)

如果目标是已有文件或空目录,会被无条件替换;目标是非空目录时会失败。Path.replace() 的语义与 os.replace() 对应。(docs.python.org)

因此,配置发布这类场景通常更适合:

temporary_path.replace(target_path)

而不是依赖不同平台上 rename() 的差异。

3. move() 可能不是原子操作

Python 3.14 的 Path.move() 在同一文件系统上可以使用 os.replace();跨文件系统时则会复制后删除。跨文件系统的“移动”过程可能是:

复制部分数据
复制剩余数据
删除源文件

它不具备原子替换的可见性。官方文档明确区分了同一文件系统的替换移动与跨文件系统的复制删除。(docs.python.org)

如果业务要求目标始终是完整文件,必须让临时文件和目标位于同一文件系统,不能把跨文件系统移动当作原子发布。


十三、竞态:为什么 exists() 后再 open() 不可靠

下面代码看起来合理:

from pathlib import Path

path = Path("data/input.txt")

if path.exists():
    with path.open("r", encoding="utf-8") as file:
        content = file.read()

但它包含两个独立系统调用:

T1: exists(path)
T2: open(path)

其他进程可以在 T1 和 T2 之间删除、替换或重命名目标:

T1: path 存在,检查通过
T1.5: 其他进程删除 path
T2: open(path) 抛出 FileNotFoundError

也可能发生:

T1: path 是普通文件
T1.5: 其他进程将 path 替换为符号链接
T2: open(path) 打开了链接目标

这就是 TOCTOU 竞态。解决思路不是“加一个更快的检查”,而是减少“先检查、后使用”的分离,直接执行目标操作并处理失败:

from pathlib import Path

path = Path("data/input.txt")

try:
    with path.open("r", encoding="utf-8") as file:
        content = file.read()
except FileNotFoundError:
    print("文件不存在")
except PermissionError:
    print("没有读取权限")

如果必须判断类型并随后使用,应尽量围绕同一个已经打开的文件描述符操作,而不是反复通过路径查找。


十四、创建文件时的竞态:不要用 exists() 模拟独占创建

错误写法:

from pathlib import Path

path = Path("job.lock")

if not path.exists():
    path.write_text("locked", encoding="utf-8")

两个进程可能同时执行:

进程 A:exists() -> False
进程 B:exists() -> False
进程 A:write_text()
进程 B:write_text()

最终谁覆盖谁取决于调度顺序。

需要“只有一个进程能创建成功”时,应使用操作系统提供的独占创建语义:

import os
from pathlib import Path

path = Path("job.lock")

flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL

try:
    fd = os.open(path, flags, 0o600)
except FileExistsError:
    print("锁文件已经存在")
else:
    try:
        os.write(fd, b"locked\n")
    finally:
        os.close(fd)

O_CREAT | O_EXCL 的含义是:如果目标已经存在,创建操作失败,而不是先检查再创建。os.open() 的标志可以通过按位或组合;O_EXCLO_CREAT 等常见标志在 Unix 和 Windows 上可用,但具体标志仍应依据目标平台验证。(docs.python.org)

该示例只是一个创建竞态示例,不是完整的分布式锁实现。锁文件还需要考虑:

  • 进程崩溃后如何恢复;
  • 锁文件内容是否包含持有者信息;
  • 文件系统是否为本地文件系统;
  • NFS 等网络文件系统的语义;
  • 是否需要锁超时和租约。

十五、Path.infoDirEntry 和竞态:缓存不是锁

下面代码不能保证删除的是刚才判断过的目录:

for child in root.iterdir():
    if child.is_dir():
        child.rmdir()

可能发生:

T1: child.is_dir() -> True
T1.5: 其他进程删除 child,并创建同名普通文件
T2: child.rmdir() -> NotADirectoryError

也可能发生:

T1: child.is_dir() -> True
T1.5: 目录被替换为符号链接
T2: 后续操作面对的已经不是原目录

Path.info 的缓存更不能改变这个事实。缓存只减少查询,不提供同步;DirEntry 的类型信息也可能来自目录扫描时刻。官方文档明确说明了 Path.info 的缓存性质,以及 scandir() 结果和目录变化之间没有快照保证。(docs.python.org)

正确的处理方式是:

  1. 允许检查结果过时;
  2. 直接执行操作;
  3. 捕获 FileNotFoundErrorNotADirectoryErrorPermissionError 等异常;
  4. 根据业务决定重试、跳过还是失败。

十六、目录文件描述符与 dir_fd

Unix 上,一些 os API 支持相对于目录文件描述符执行操作:

import os

directory_fd = os.open("data", os.O_RDONLY)
try:
    info = os.stat("config.json", dir_fd=directory_fd)
finally:
    os.close(directory_fd)

这里的 "config.json" 不再相对于当前工作目录,而是相对于 directory_fd 指向的目录。

os.replace() 也支持:

os.replace(
    "new.tmp",
    "config.json",
    src_dir_fd=directory_fd,
    dst_dir_fd=directory_fd,
)

目录文件描述符的价值在于:程序先打开并持有目录对象,再在该目录上下文中操作相对名称,减少依赖全局当前工作目录以及部分路径替换竞态。os.fwalk() 会额外返回当前目录的文件描述符:

import os

for dirpath, dirnames, filenames, dirfd in os.fwalk("data"):
    for name in filenames:
        info = os.stat(name, dir_fd=dirfd, follow_symlinks=False)
        print(name, info.st_size)

os.fwalk()os.walk() 类似,但多返回一个指向当前目录的文件描述符,并支持目录描述符相对路径。(docs.python.org)

不过,这不是跨平台抽象:

  • dir_fd 目前只在 Unix 上工作;
  • Windows 不支持这些参数;
  • 并非每个系统调用都支持;
  • 可通过 os.supports_dir_fd 检查当前平台支持哪些函数;
  • follow_symlinks=False 的支持也因平台而异,可通过 os.supports_follow_symlinks 检查。(docs.python.org)

因此,跨平台库通常需要提供两条实现路径:普通 Path 路径操作,以及 Unix 上更严格的文件描述符实现。


十七、关联场景:文件发布与子进程读取

文件系统竞态经常与子进程结合出现。

错误流程可能是:

父进程:
1. 创建 output.json
2. 启动子进程
3. 继续写 output.json

子进程:
4. 读取 output.json

子进程可能在文件写完之前读取到半成品。更可靠的顺序是:

父进程:
1. 写入同目录临时文件
2. flush + fsync
3. os.replace(temp, output.json)
4. 启动子进程

这样子进程通过目标路径读取时,只会看到旧版本或新版本,而不会看到正在生成的中间状态。

如果子进程接收的是标准输入,也应区分文本和二进制流:

import subprocess

result = subprocess.run(
    ["python", "-c", "import sys; print(sys.stdin.read().upper())"],
    input="hello\n",
    text=True,
    capture_output=True,
    check=True,
)

print(result.stdout)

如果传输压缩包、图片或协议帧,则应使用 bytes,不要启用文本转换。io 层对文本和二进制流有明确类型边界:文本流处理 str,二进制流处理 bytes;向错误类型的流写入会抛出 TypeError。(docs.python.org)


十八、故障诊断:从异常推断失败阶段

文件系统异常通常包含足够的分类信息:

from pathlib import Path

path = Path("data/input.txt")

try:
    with path.open("rb") as file:
        data = file.read()
except FileNotFoundError:
    print("路径不存在,或路径解析中的某一级目录不存在")
except NotADirectoryError:
    print("路径中间某一级不是目录")
except IsADirectoryError:
    print("目标是目录,不能按普通文件读取")
except PermissionError:
    print("权限不足")
except OSError as exc:
    print(f"其他操作系统错误: errno={exc.errno}, message={exc}")

原子替换还需要区分:

失败表现 可能原因
临时文件创建失败 目录无权限、磁盘空间不足、文件名限制
写入失败 磁盘满、配额、设备错误
fsync() 失败 存储设备或文件系统错误
os.replace() 失败 跨文件系统、目标类型不兼容、权限不足
清理临时文件失败 进程崩溃、权限变化、文件被其他进程处理

原子写入函数必须在失败路径中尝试清理临时文件,但清理失败不能覆盖原始异常。上面的示例通过 finally 清理临时文件,并只忽略“临时文件已经不存在”的情况。


十九、常见误解

误解一:Path 对象代表一个固定文件

不对。Path 只是路径值。它不会自动绑定 inode,也不会阻止路径被替换。

误解二:exists() 返回 True 后,文件一定存在

不对。它只描述查询发生时的结果;下一条系统调用可能面对完全不同的状态。

误解三:resolve() 可以防止路径穿越

不完全正确。它可以帮助发现某一时刻解析后的路径是否位于指定根目录内,但不能消除后续访问中的 TOCTOU 竞态。

误解四:write_text() 是安全发布配置的方式

不对。它通常直接覆盖目标文件,进程崩溃或并发读取时可能产生空文件或半成品。需要完整可见性时,应采用同目录临时文件加 os.replace()

误解五:重命名原子,所以断电后一定安全

不对。原子性主要描述并发观察者看到的目录项变化;断电持久性还需要文件和目录同步,并受文件系统、操作系统和存储硬件影响。

误解六:rglob() 找不到文件,就说明文件不存在

不对。pathlib 的 glob 扫描可能抑制扫描期间的 OSError,权限错误可能表现为空结果。需要审计错误时,应使用显式错误回调的遍历方式。(docs.python.org)

误解七:遍历结果是目录快照

不对。iterdir()scandir()listdir() 和 walk 类 API 都允许目录在遍历期间变化;新增或删除条目是否出现在结果中通常没有确定保证。(docs.python.org)


二十、选择 API 的判断顺序

可以按操作意图选择工具:

只拼接、拆分、比较路径
    -> PurePath / PurePosixPath / PureWindowsPath

访问当前平台文件系统
    -> Path

读取一层目录
    -> Path.iterdir()

按名称模式查找
    -> Path.glob() / Path.rglob()

需要递归剪枝或错误处理
    -> Path.walk()

需要高效获取目录项类型
    -> os.scandir()

需要目录文件描述符和相对操作
    -> os.fwalk() + os.*(..., dir_fd=...)

读取小型文本
    -> Path.read_text(encoding="utf-8")

处理大文件
    -> Path.open() + 逐行或分块读取

发布完整配置或索引
    -> 同目录临时文件 + flush + fsync + os.replace()

独占创建
    -> os.open(..., O_CREAT | O_EXCL)

跨文件系统移动
    -> shutil.move() 或 Path.move()
    但不能假设它具有原子替换语义

文件系统代码的关键不是记住更多方法名,而是始终问清楚三个问题:

  1. 当前变量是一个路径,还是已经打开的对象?
  2. 这个结果是否只是某一时刻的查询?
  3. 操作失败或并发修改时,程序是否仍能保持正确状态?

pathlib 让路径表达更清楚;os 让底层状态和原子操作更可控;tempfile 让临时资源创建更安全。只有把路径解析、目录遍历、元数据查询、文件描述符和原子替换放在同一个状态模型中理解,才能避免把“看起来正确”的文件操作写成竞态条件。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。