qinghuan
← 回到知识探索

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

两个文件描述符编号不同,为什么读取位置仍会互相影响?

让 A 读出 AB、复制得到的 B 接着读出 CD,再与重新打开的 C 对照:分清编号、打开状态和文件内容。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先分清三样东西:文件、打开状态、访问编号
  4. 复制入口与重新打开,会产生两种结果
  5. 运行完整程序,观察每一次位置变化
  6. 关掉 A,为什么 B 还能继续读?
  7. 为什么这次实验特意避开高层缓冲和并发?
  8. 排查重复读取或漏读时,先画出共享关系
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 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 会读到哪里。

位置 0A
位置 1B
位置 2C
位置 3D
位置 4E
位置 5F
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。任何核对失败都会抛异常并以非零退出,而不是仍然打印通过。

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

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

小实验 · python
"""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,并调整三个入口的读取顺序:哪些操作会影响谁?

  1. 保留 A 和 B 的复制关系,C 仍独立打开;先在纸上为两份打开状态分别画位置。
  2. 依次让 C 读 3 字节、B 读 4 字节、A 回到位置 1、B 再读 2 字节。
  3. 运行你自己的变体,核对预测与实际字节;最后检查文件内容是否改变。

做到这些,就算完成

  • 解释每次位置变化属于哪份打开状态。
  • 确认所有打开的描述符最终都被关闭。
  • 不要用描述符整数值判断谁共享偏移;本节不提供结果答案。

查原始资料

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

  • Python 3.14:os.dup 与底层文件操作 (新标签页)↗Python Software Foundation · 英文

    dup 的入口含义,以及 os.open/read/lseek/close 对应的低层操作。

    2026-10-17 实际打开核对:os.dup 条目及其平台说明;文档页面目前为 3.14 维护版,实验运行版本另列。

  • Linux man-pages:dup(2) (新标签页)↗Linux man-pages 项目 / Michael Kerrisk · 英文

    新旧描述符指向同一 open file description,并共享偏移;不把描述符标志也统称为共享。

    2026-10-17 实际打开核对:DESCRIPTION 中共享打开状态、offset 和 descriptor flags 的区别;未运行 Linux 实验。

  • Microsoft CRT:_dup、_dup2 (新标签页)↗Microsoft · 英文

    Windows CRT 中为已打开文件增加另一描述符,以及描述符与 FILE 流的区别。

    2026-10-17 实际打开核对:Remarks、Requirements 和示例;本机偏移的具体变化由本篇实测提供。