开始前,会这些就够了
- 会运行 Python 文件
- 知道文件可以按字节读取
- 了解从零开始的位置编号
- 需要补基础时:先认识进程与文件资源
遇到不熟悉的词,先看这 2 个解释
- 文件描述符
- 程序打开文件后,操作系统给它一个可用于读写的编号。这个编号是访问入口;两个不同编号仍可能指向同一次打开所维护的状态。
- 文件偏移量
- 对于可定位的普通文件,偏移量记录下一次读写从哪里开始,通常以距文件开头多少字节表示。读取推进位置,定位操作可以显式改变位置。
有个文件只写着 ABCDEF。你拿到读取器 A,又复制出一个编号不同的读取器 B,以为两者都会从头读。结果 A 先读出 AB,B 却读出 CD。复制成功了,问题出在你复制的是访问入口,还是独立的读取进度。
本文用 Python 标准库真正打开一个临时普通文件。实验不会读你的业务文件;先用最少的字节看清共享关系,再说明这个结论在哪些地方不能直接套用。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- dup_offset.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B dup_offset.py解压到独立练习文件夹,在含 dup_offset.py 的目录打开终端运行。真实普通临时文件的顺序 os.open/os.dup/os.read/os.lseek 实验;制作时在 Windows 与 Python 3.14.0 实测。未测试进程继承、并发竞态、终端、管道或磁盘持久化。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
三个入口,两处读位置
文件内容固定为 ABCDEF。A 打开文件,B 由 A 复制,C 重新打开同一路径。先依次让 A、B、C 各读 2 字节,再观察 B 归零后 A 会读到哪里。
- A 与 B 的共享位置
- 0
- C 的独立位置
- 0
- A 是否关闭
- 仍打开
还没有读取。A、B 共用位置 0,C 单独处于位置 0。
这是页面中的普通文件读位置模型,没有打开你的文件。A、B、C 是说明用的入口名称;正文 Python 实验实际调用 os.open、os.dup、os.read 与 os.lseek。模型固定按顺序、每次至多读取 2 个 ASCII 字节,不模拟缓冲层、并发读写或管道。
先分清三样东西:文件、打开状态、访问编号
文件内容是 ABCDEF。一次打开还需要保存当前读到哪里等状态。程序里拿到的文件描述符,则是用来找到这份打开状态的整数编号。三个名字容易混在一起,却不承担同一个职责。
在 Linux 的接口说明中,dup 得到的新描述符指向原来的 open file description,因此共用文件偏移。可以把它理解成两张借书卡指向同一张阅读进度表。这个比喻只解释本篇的读位置;关闭、继承和状态标志仍要分别看接口契约。
复制入口与重新打开,会产生两种结果
本例先 os.open 得到 A,随后 os.dup(A) 得到 B;再对同一个文件路径执行一次 os.open,得到 C。A、B、C 的数字不同,但只有 C 有独立的读取进度。A 读两个字节后,共享进度到了位置 2;B 再读便从 C 开始,得到 CD。C 的进度还在 0,所以仍读到 AB。
反过来,让 B 用 lseek 回到位置 0,再由 A 读取,A 也会重新得到 AB。文件没有变化,是共享的进度变了。C 的位置仍保持为 2。这组对照比仅看描述符编号更能判断有没有共享状态。
运行完整程序,观察每一次位置变化
保存为 dup_offset.py,执行 python -B dup_offset.py。程序创建 ABCDEF 临时文件,使用不带 Python 文本缓冲的底层读接口,逐步核对 14 项结果,最后关闭全部描述符并清理临时目录。Windows 下额外使用可用的 O_BINARY 标志,避免文本换行规则混入字节实验。
输出中的 A_read_2、B_read_2、C_read_2 应依次是 AB、CD、AB。B_seek_start 之后,A_read_after_B_seek 是 AB;C_position_unchanged 保持 2。任何核对失败都会抛异常并以非零退出,而不是仍然打印通过。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Real ordinary-file descriptor experiment; Python standard library only."""
import json
import os
import tempfile
from pathlib import Path
def experiment():
checks = []
observed = {}
def check(name, actual, expected):
if actual != expected:
raise AssertionError(f"{name}: {actual!r} != {expected!r}")
checks.append(name)
observed[name] = actual
with tempfile.TemporaryDirectory(prefix="dup-offset-") as folder:
path = Path(folder) / "letters.bin"
path.write_bytes(b"ABCDEF")
descriptors = {}
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
try:
descriptors["A"] = os.open(path, flags)
descriptors["B"] = os.dup(descriptors["A"])
descriptors["C"] = os.open(path, flags)
a, b, c = (descriptors[key] for key in ("A", "B", "C"))
check("three_different_numbers", len({a, b, c}), 3)
check("A_read_2", os.read(a, 2).decode("ascii"), "AB")
check("B_position_after_A_read", os.lseek(b, 0, os.SEEK_CUR), 2)
check("B_read_2", os.read(b, 2).decode("ascii"), "CD")
check("C_read_2", os.read(c, 2).decode("ascii"), "AB")
check("A_position_after_B_read", os.lseek(a, 0, os.SEEK_CUR), 4)
check("B_seek_start", os.lseek(b, 0, os.SEEK_SET), 0)
check("A_read_after_B_seek", os.read(a, 2).decode("ascii"), "AB")
check("C_position_unchanged", os.lseek(c, 0, os.SEEK_CUR), 2)
os.close(descriptors.pop("A"))
check("B_read_after_A_close", os.read(b, 2).decode("ascii"), "CD")
check("B_read_remaining", os.read(b, 10).decode("ascii"), "EF")
check("B_at_end", os.read(b, 1).decode("ascii"), "")
check("C_read_independently", os.read(c, 2).decode("ascii"), "CD")
check("file_bytes_unchanged", path.read_bytes().decode("ascii"), "ABCDEF")
finally:
for descriptor in descriptors.values():
os.close(descriptor)
return {"experiment": "real ordinary-file os.open/os.dup/os.read/os.lseek",
"checks_passed": len(checks), "observed": observed}
if __name__ == "__main__":
print(json.dumps(experiment(), indent=2, ensure_ascii=False))
怎样运行:在独立练习文件夹中执行 python -B dup_offset.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。真实普通临时文件的顺序 os.open/os.dup/os.read/os.lseek 实验;制作时在 Windows 与 Python 3.14.0 实测。未测试进程继承、并发竞态、终端、管道或磁盘持久化。
运行后对照结果
{
"experiment": "real ordinary-file os.open/os.dup/os.read/os.lseek",
"checks_passed": 14,
"observed": {
"three_different_numbers": 3,
"A_read_2": "AB",
"B_position_after_A_read": 2,
"B_read_2": "CD",
"C_read_2": "AB",
"A_position_after_B_read": 4,
"B_seek_start": 0,
"A_read_after_B_seek": "AB",
"C_position_unchanged": 2,
"B_read_after_A_close": "CD",
"B_read_remaining": "EF",
"B_at_end": "",
"C_read_independently": "CD",
"file_bytes_unchanged": "ABCDEF"
}
}关掉 A,为什么 B 还能继续读?
关闭 A 是释放这一个访问入口。B 仍然是有效入口,所以程序接着通过 B 读到 CD,再读到 EF。这里没有依赖关闭后的旧编号,也没有把关闭操作误解成删除文件或立即销毁所有相关状态。
最后再看 C:它仍从自己的位置继续读取 CD。需要两个独立扫描器时,分别打开文件通常更符合需求;需要共享同一打开状态的别名时,dup 才是相应工具。两者都在访问同一份文件内容,独立偏移并不等于文件内容也被复制或隔离。
为什么这次实验特意避开高层缓冲和并发?
高层文件对象可能预先读入一批字节,文本解码器还可能维护自己的解码状态。若把多个缓冲对象随意包在共享描述符外,再把对象层位置与底层偏移混用,看到的现象会复杂得多。本例直接使用 os.read 和 os.lseek,只回答最基础的共享关系。
本次实际执行环境是 Windows/Python 3.14.0;Linux 语义另有 man-pages 文档支持,没有声称已在 Linux 运行。程序按固定顺序操作普通文件,未证明多个线程的 seek 加 read 是一个不可分割动作,也不讨论管道和套接字能否定位。
排查重复读取或漏读时,先画出共享关系
遇到两个读取器互相影响,先记录它们来自独立打开、dup,还是同一个上层对象;随后区分对象自己的缓冲和底层偏移。仅凭变量名不同、整数不同,无法断定状态独立。
如果业务需要从指定位置读且不改变公共进度,可以继续研究目标平台的定位读取接口;如果需要多人安全修改同一文件,还必须另外设计同步和写入协议。复制描述符不会自动完成这些工作。
记住这三点
- 描述符编号不同,不保证读取进度独立。
- dup 的别名与重新打开同一路径,具有不同的偏移关系。
- 关闭一个入口不等于关闭所有别名;独立偏移也不等于独立内容。
别把例子用过头
- 把 dup 当成复制文件字节或创建独立扫描器。
- 把高层缓冲位置和底层文件偏移混为一谈。
- 把顺序普通文件实验推广为并发原子性、管道定位或跨平台全接口证明。
自己试一小步
把文件改为 0123456789,并调整三个入口的读取顺序:哪些操作会影响谁?
- 保留 A 和 B 的复制关系,C 仍独立打开;先在纸上为两份打开状态分别画位置。
- 依次让 C 读 3 字节、B 读 4 字节、A 回到位置 1、B 再读 2 字节。
- 运行你自己的变体,核对预测与实际字节;最后检查文件内容是否改变。
做到这些,就算完成
- 解释每次位置变化属于哪份打开状态。
- 确认所有打开的描述符最终都被关闭。
- 不要用描述符整数值判断谁共享偏移;本节不提供结果答案。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Python 3.14:os.dup 与底层文件操作 (新标签页)↗
dup 的入口含义,以及
os.open/read/lseek/close对应的低层操作。 - Linux man-pages:dup(2) (新标签页)↗
新旧描述符指向同一 open file description,并共享偏移;不把描述符标志也统称为共享。
- Microsoft CRT:_dup、_dup2 (新标签页)↗
Windows CRT 中为已打开文件增加另一描述符,以及描述符与 FILE 流的区别。