← 返回题目列表

文件描述符是什么?os.open 和内置 open 有什么区别?

困难 第 21 / 27 题 更新于 2026/07/31
文件描述符os.openfd泄漏底层IO

简化版

文件描述符(fd)是内核给进程的一个「已打开文件」的整数句柄(0/1/2 分别是标准输入输出错误,之后从 3 开始递增)。内置 open() 返回的是包了三层的文件对象**(TextIOWrapperBufferedWriterFileIO),而 os.open() 返回的是裸的整数 fd,配套 os.read/os.write/os.close 使用。两者的分工很清晰:绝大多数场景用内置 open()(有缓冲、有编码、能用 with、能迭代行);只有需要「内置 open 表达不了的语义」时才下沉到 os.open——最常见的三个需求是:O_EXCL(文件已存在就失败,防符号链接劫持)、创建时指定权限 mode=0o600(内置 open 做不到)、以及 O_NONBLOCK/O_DIRECTORY 之类的特殊标志**。两者可以用 os.fdopen(fd, "w") 桥接:先用 os.open 精确控制创建语义,再包装成好用的文件对象。最重要的实践问题是 fd 泄漏:每个进程能打开的 fd 数量有上限(ulimit -n,常见 1024/65535),忘了 close() 就会累积到 OSError: [Errno 24] Too many open files——CPython 靠引用计数通常能及时回收,但这不是语言保证(循环引用、异常持有栈帧、PyPy 都会让它延迟),所以必须用 with。开发时加 python -W error::ResourceWarning 能把泄漏变成报错,第一时间发现。核心记忆:fd 是整数句柄;日常用内置 open();需要 O_EXCL/权限/特殊 flag 才用 os.open永远用 with

详细版

两套 API 对照

维度内置 open()os.open()
返回文件对象(三层封装)整数 fd
缓冲✅ 有(可配 buffering=,每次 os.write 都是系统调用
编码✅ 文本模式自动编解码❌ 只有 bytes
创建权限不能指定✅ 第三个参数 mode=0o600
特殊标志O_EXCL/O_NONBLOCK/O_NOFOLLOW/O_DIRECTORY
with 支持❌(要自己 try/finallyos.fdopen
迭代行
典型用途99% 的场景精确控制创建语义、非阻塞、传 fd
import os, io

# ① 内置 open:日常首选
with open("a.txt", "w", encoding="utf-8") as f:
    f.write("hello")
    print(f.fileno())                 # 3 ← ★文件对象底下就是一个 fd★

# ② os.open:返回裸 fd,必须自己关
fd = os.open("b.txt", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
    os.write(fd, b"hello")            # ★只接受 bytes,没有缓冲★
finally:
    os.close(fd)

# ③ ★桥接:用 os.open 控制语义,再包成文件对象★
fd = os.open("secret.txt", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:   # ★包装后 with 会负责 close★
    f.write("token")
# O_EXCL:路径已存在(哪怕是个符号链接)就直接失败 → 防劫持
# mode=0o600:文件★从诞生起★就只有属主可读写(内置 open 做不到)

# ④ ★fd 泄漏:最常见的资源 bug★
def bad(paths):
    return [open(p).read() for p in paths]     # ✗ 文件对象没关,靠 GC 兜底
def good(paths):
    out = []
    for p in paths:
        with open(p) as f:                      # ✓
            out.append(f.read())
    return out
# 泄漏到极限:OSError: [Errno 24] Too many open files

# ⑤ 开发时把泄漏变成报错
# python -W error::ResourceWarning app.py
# → ResourceWarning: unclosed file <_io.TextIOWrapper name='a.txt'> 直接抛异常

# ⑥ 查看限制与当前占用
import resource                                  # Unix
print(resource.getrlimit(resource.RLIMIT_NOFILE))  # (软限制, 硬限制) 如 (1024, 1048576)
print(len(os.listdir("/proc/self/fd")))            # Linux:当前打开的 fd 数

# ⑦ dup / dup2:复制 fd(重定向的底层机制)
saved = os.dup(1)                     # 备份 stdout
with open("out.log", "w") as f:
    os.dup2(f.fileno(), 1)            # ★让 fd 1 指向文件(子进程也受影响)★
    os.system("echo 子进程的输出也进文件了")
os.dup2(saved, 1); os.close(saved)    # 恢复

# ⑧ ★dup 出来的 fd 共享文件偏移量,独立 open 的不共享★
fd1 = os.open("c.txt", os.O_RDONLY)
fd2 = os.dup(fd1)                     # 复制
os.read(fd1, 2); print(os.lseek(fd2, 0, os.SEEK_CUR))   # 2 ← ★偏移量共享★
fd3 = os.open("c.txt", os.O_RDONLY)   # 独立打开
print(os.lseek(fd3, 0, os.SEEK_CUR))  # 0 ← ★各自独立★
os.close(fd1); os.close(fd2); os.close(fd3)

# ⑨ 不改变偏移量的读写(多线程友好,3.3+)
data = os.pread(fd, 100, 0)           # 从偏移 0 读 100 字节,★不动文件指针★
# os.pwrite(fd, b"x", 50)

⚠️ 三个必须记住的点:① 内置 open() 无法指定新建文件的权限,它固定用 0o666 & ~umask(通常得到 0o644)。要创建「从诞生起就是 0o600」的密钥文件,只能用 os.open(path, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600)os.fdopen() 包装——「先 open() 建、再 chmod」中间存在一个窗口期,文件是 0o644,其他用户能读到内容。② fd 是有限资源ulimit -n 常见值是 1024(软限制),高并发服务器会调到几万;泄漏的典型症状是运行一段时间后突然 OSError: [Errno 24] Too many open files,而且连接、socket、子进程管道也占用 fd。CPython 的引用计数通常能在对象失去引用时立刻关闭文件,但这不是语言保证(PyPy 等实现靠 GC,时机不确定;循环引用、异常回溯持有的栈帧都会延迟回收),所以必须显式 with。③ os.write() 没有缓冲,每次都是一次系统调用——逐行 os.write 写百万行会比缓冲的文件对象慢一个数量级;反过来,os.write不保证一次写完(返回实际写入的字节数,非阻塞或大块写时可能是部分写入),必须循环处理,这也是不该直接用低级 API 做常规读写的原因。

完整版教学

一、fd 到底是什么:三层表结构

内核维护三张表(★理解 dup、继承、偏移量共享的关键★):

  进程 A 的 fd 表          系统级"打开文件表"           inode 表
  ┌────┬──────┐         ┌──────────────────┐      ┌──────────┐
  │ 0  │ ───────────────►│ 偏移量=0         │      │          │
  │ 1  │ ─────┐          │ 状态标志(O_APPEND)│─────►│ /var/log │
  │ 2  │ ─────┤          │ inode 指针        │      │ 的 inode │
  │ 3  │ ───┐ │          └──────────────────┘      └──────────┘
  └────┴────┼─┘
            │             ┌──────────────────┐      ┌──────────┐
            └────────────►│ 偏移量=1024      │─────►│ a.txt    │
                          └──────────────────┘      │ 的 inode │
                                                    └──────────┘

  ★ fd(整数)→ 打开文件表项(含★偏移量★和状态)→ inode(文件本体)

由这个结构直接推出四条重要行为:
  ① os.dup(fd) 复制的是"fd → 同一个打开文件表项"的指向
     → ★两个 fd 共享偏移量★(一个读了 2 字节,另一个的位置也前进 2)
  ② 两次独立 os.open(同一个文件) 会创建★两个打开文件表项★
     → 偏移量各自独立
  ③ 子进程 fork 时★复制整张 fd 表★,但指向同一批打开文件表项
     → 父子进程共享偏移量(这也是 fork 后要小心文件缓冲的原因)
  ④ 删除文件(unlink)只是删目录项,★只要还有 fd 指着 inode,数据就不释放★
     → "rm 了大日志但 df 显示磁盘没释放",因为进程还开着它
     → 要真正释放:重启进程,或用 : > file 把它 truncate 掉

fd 号的分配规则:
  ★ 总是分配"当前最小的可用整数"★
  0/1/2 被标准流占用 → 第一个 open 通常得到 3
  关掉 3 再 open → 又拿到 3
  → 所以不能假设某个 fd 号固定属于谁(除了 0/1/2 的惯例)

Python 里从对象拿 fd、从 fd 拿对象:
  f.fileno()                   文件对象 → fd
  socket.fileno()              socket 也有 fd(★同一套资源池★)
  os.fdopen(fd, "r")           fd → 文件对象(★所有权转移,关闭包装对象即关闭 fd★)
  io.FileIO(fd, closefd=False) fd → 对象但★不接管所有权★

理解 fd 要先理解内核的三层表结构进程的 fd 表(整数 → 表项)→ 系统级打开文件表(含偏移量和状态标志)→ inode 表(文件本体)。四条重要行为都是这个结构的直接推论:os.dup() 复制的是「指向同一个打开文件表项」,所以两个 fd 共享偏移量;而两次独立 os.open() 会创建两个表项,偏移量各自独立fork 出的子进程复制整张 fd 表但指向同一批表项(所以父子共享偏移量);删除文件只是删目录项,只要还有 fd 指着 inode 数据就不释放——这正是运维经典问题「rm 掉大日志但 df 显示磁盘没释放」的原因(要么重启进程,要么用 : > file 截断)。还有个实用细节:fd 号总是分配当前最小的可用整数,所以关掉 3 再打开还会拿到 3,不能假设某个号固定属于谁。

二、os.open 的 flags:内置 open 表达不了的语义

os.open(path, flags, mode=0o777) —— flags 用 | 组合

访问模式(★三选一,必须有★):
  os.O_RDONLY   只读     os.O_WRONLY  只写     os.O_RDWR  读写

创建与截断:
  os.O_CREAT    不存在则创建(★此时 mode 参数才生效★)
  os.O_EXCL     ★配合 O_CREAT:已存在则失败(EEXIST)★
                → 这是"原子地创建一个新文件"的唯一可靠方式
                → 也是防符号链接劫持的关键(路径上有链接也算存在,直接失败)
  os.O_TRUNC    已存在则清空
  os.O_APPEND   ★每次写入前自动移到末尾(原子)★

  内置 open 的模式字符串其实就是这些 flag 的组合:
    "r"  → O_RDONLY
    "w"  → O_WRONLY | O_CREAT | O_TRUNC
    "a"  → O_WRONLY | O_CREAT | O_APPEND
    "x"  → O_WRONLY | O_CREAT | O_EXCL      ← ★这个内置 open 支持★
    ★ 但内置 open 无法表达:"创建且指定权限 0o600"、"O_NOFOLLOW"、"O_NONBLOCK"

安全相关:
  os.O_NOFOLLOW  ★路径最后一段是符号链接就报错(ELOOP)★
  os.O_DIRECTORY 不是目录就报错(配合 os.scandir(fd) 做安全遍历)
  os.O_CLOEXEC   exec 时自动关闭(★Python 3.4+ 默认就带,见 PEP 446★)

行为控制:
  os.O_NONBLOCK  非阻塞(对 FIFO/设备有意义;★普通文件上基本无效★)
  os.O_SYNC      每次写都等落盘(极慢,但崩溃安全)
  os.O_DIRECT    绕过页缓存(Linux;对齐要求苛刻,数据库才用)
  os.O_TMPFILE   创建一个★无名★临时文件(Linux)

★ 三个最值得记住的组合:
  ① 安全创建新文件(密钥、锁文件):
     fd = os.open(p, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
     → 已存在就失败;权限从诞生起就是 600
  ② 追加日志(多进程安全):
     fd = os.open(p, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o644)
     → ★O_APPEND 的"移到末尾+写"是原子的★,多个进程同时写不会互相覆盖
        (前提:单次写入小于 PIPE_BUF/块大小,且不要混用 seek)
  ③ 只读且拒绝符号链接(处理不可信路径):
     fd = os.open(p, os.O_RDONLY | os.O_NOFOLLOW)
     st = os.fstat(fd)         # ★对 fd 做 stat,杜绝 TOCTOU★

mode 参数的注意点:
  只有带 O_CREAT 时才有意义
  ★实际权限 = mode & ~umask★(见"文件权限与 umask"专题)
  → 指定 0o600 总是安全的(umask 只会减不会加)

os.open 的价值全在 flags 上——它能表达内置 open() 表达不了的语义。最值得记住的是三个组合O_CREAT | O_EXCL | O_WRONLYmode=0o600 用于安全地创建密钥或锁文件(已存在就失败,因此能挡住符号链接劫持,且权限从诞生起就正确);O_CREAT | O_WRONLY | O_APPEND 用于多进程共写的日志——O_APPEND 的「移到末尾 + 写入」是原子的,多个进程同时写不会互相覆盖(前提是单次写入不太大且不混用 seek);O_RDONLY | O_NOFOLLOWos.fstat(fd) 用于处理不可信路径(拒绝符号链接、对 fd 而不是路径做 stat,彻底杜绝 TOCTOU)。顺带理解内置 open() 的模式字符串其实就是这些 flag 的组合:"w"O_WRONLY|O_CREAT|O_TRUNC"a"O_WRONLY|O_CREAT|O_APPEND"x"O_WRONLY|O_CREAT|O_EXCL

三、fd 泄漏:最常见的资源 bug

症状:跑一段时间后突然
  OSError: [Errno 24] Too many open files
  或 socket.error: [Errno 24] ...
  ★ 注意 fd 是共享资源池:普通文件、socket、管道、epoll、inotify、子进程都占 fd

限制在哪:
  ulimit -n                     软限制(常见 1024,容器里可能更小)
  ulimit -Hn                    硬限制
  resource.getrlimit(resource.RLIMIT_NOFILE)      Python 里读
  resource.setrlimit(RLIMIT_NOFILE, (65535, hard))  提升(★不能超硬限制★)
  /proc/自身/fd                 Linux 上看当前打开了哪些(★排查泄漏的第一现场★)
    ls -l /proc/<pid>/fd | head
    lsof -p <pid> | wc -l

为什么"CPython 好像不会泄漏":
  CPython 用★引用计数★,文件对象引用归零时 __del__ 会自动 close
    def read1(p): return open(p).read()      # 表面上没泄漏(临时对象立刻回收)
  ★ 但这不是语言保证,四种情况会失效:
    ① 其他实现(PyPy/Jython)用分代 GC → ★回收时机不确定★,可能积压上千个
    ② 循环引用 → 要等 gc 跑
    ③ 异常回溯持有栈帧 → 局部变量(含文件对象)被 traceback 引用着
       try: f = open(p); 1/0
       except: pass          # ★sys.exc_info 里的 traceback 还引用着 f★
    ④ 长生命周期容器/闭包持有文件对象
  → 结论:★永远显式 with★,别指望引用计数

排查手段:
  ① 开发/测试时:python -W error::ResourceWarning app.py
     → 任何未关闭的文件对象在被回收时★直接抛异常★,附带文件名,一抓一个准
  ② pytest:filterwarnings = error::ResourceWarning(写进 pytest.ini)
  ③ 线上:定期采样 len(os.listdir("/proc/self/fd")) 打点告警
  ④ lsof -p <pid> 看具体是哪些文件/socket 在堆积(同名文件重复出现=泄漏点)

容易漏关的地方(清单):
  □ 循环里 open 但异常跳出                → with
  □ zipfile/tarfile 打开后未 close        → with
  □ subprocess 的 stdout/stderr PIPE      → ★用 with Popen(...) 或 communicate()★
  □ socket / requests.Session             → with 或显式 close
  □ sqlite3 / 数据库连接                   → 连接池 + with
  □ tempfile.mkstemp() 返回的 fd          → ★它返回裸 fd,必须 os.close 或 fdopen★
  □ os.pipe() 的两端                       → 两个都要关
  □ mmap 对象                              → with 或 close

fd 泄漏是最常见的资源 bug,症状是运行一段时间后突然 OSError: [Errno 24] Too many open files——注意 fd 是共享资源池,普通文件、socket、管道、子进程都占用它。很多人误以为「CPython 有引用计数所以不会泄漏」,这个印象很危险:引用计数只是 CPython 的实现细节、不是语言保证,而且有四种情况会失效——其他实现(PyPy)靠 GC 回收时机不确定、循环引用要等 gc、异常回溯持有的栈帧会引用住文件对象try: f = open(p); 1/0 except: pass 之后 f 仍被 traceback 引用)、以及长生命周期容器持有。所以结论是永远显式用 with。排查手段里最好用的是 python -W error::ResourceWarning——任何未关闭的文件对象在被回收时直接抛异常并附带文件名,一抓一个准(pytest 里配 filterwarnings = error::ResourceWarning);线上则采样 /proc/self/fd 的数量打点,用 lsof -p <pid> 定位具体是哪些文件在堆积。特别提醒 tempfile.mkstemp() 返回的是裸 fd,必须 os.close()os.fdopen() 接管——这是最常被忘记的一个。

四、fd 的继承与 dup2:重定向的底层

os.dup(fd)          复制一个 fd,指向★同一个打开文件表项★(共享偏移量)
os.dup2(src, dst)   ★把 dst 强制变成 src 的副本★(dst 原来开着的会先被关掉)
                    → 这是所有"重定向"的底层机制

  ★ shell 的 cmd > f 就是:open(f) 得到 fd 3 → dup2(3, 1) → close(3)

Python 里做 fd 级重定向:
  saved = os.dup(1)                      # ★先备份★
  with open("out.log", "w") as f:
      os.dup2(f.fileno(), 1)             # fd 1 现在指向文件
      print("Python 的输出")              # 进文件
      os.system("echo 子进程的输出")       # ★子进程也进文件(继承了 fd 1)★
      sys.stdout.flush()                  # ★别忘了:Python 层还有缓冲★
  os.dup2(saved, 1)                       # 恢复
  os.close(saved)

  ★ 与 contextlib.redirect_stdout 的区别:
    redirect_stdout 只换 sys.stdout 对象 → 只影响 Python 的 print
    dup2 改的是 fd → ★子进程、C 扩展全都受影响★

继承性(PEP 446,★Python 3.4 起的重要变更★):
  ★ Python 3.4 起,所有新创建的 fd 默认是"不可继承"(带 O_CLOEXEC)★
    → 子进程 exec 后自动关闭它们
    → 目的:防止子进程意外持有父进程的文件/socket(安全 + 避免"文件删不掉")
  os.get_inheritable(fd) / os.set_inheritable(fd, True)   查询/设置
  ★ 例外:标准流 0/1/2 永远可继承★(否则子进程没法输出)

  subprocess 的相关参数:
    close_fds=True     ★3.2 起默认★:除 0/1/2 外都不传给子进程
    pass_fds=(fd,)     显式指定要传给子进程的 fd

  典型踩坑:想把一个 socket 传给子进程用,忘了 set_inheritable(True) → 子进程收到无效 fd

fork 与文件缓冲(★多进程编程的经典坑★):
  f = open("log.txt", "w")
  f.write("父进程")            # ★还在缓冲区里★
  if os.fork() == 0:
      f.write("子进程"); f.close(); os._exit(0)
  f.close()
  → ★缓冲区被 fork 复制了一份 → "父进程"这句可能被写两次★
  ✓ fork 前先 flush(或干脆用 multiprocessing 的 spawn 方式)

os.dup2(src, dst)所有重定向的底层机制——shell 的 cmd > f 本质就是「打开文件得到 fd 3 → dup2(3, 1) → 关掉 3」。它和 contextlib.redirect_stdout 的关键区别是作用层次:后者只换 sys.stdout 对象(只影响 Python 的 print),而 dup2 改的是 fd 本身,子进程和 C 扩展全都受影响(记得操作前 flush(),因为 Python 层还有自己的缓冲)。继承性方面有个必须知道的变更:Python 3.4(PEP 446)起,所有新创建的 fd 默认「不可继承」(带 O_CLOEXEC),子进程 exec 后会自动关闭它们——目的是防止子进程意外持有父进程的文件和 socket(既是安全考虑,也避免「文件明明删了但空间没释放」);标准流 0/1/2 是永远可继承的例外,要主动传别的 fd 给子进程得用 os.set_inheritable(fd, True)subprocesspass_fds。最后一个多进程经典坑:fork 会复制文件对象的缓冲区,所以 fork 前没 flush 的内容会被父子各写一遍。

五、低级 IO 的行为差异

差异 1:★os.write 没有缓冲,且不保证一次写完★
  n = os.write(fd, data)
  ★ 返回值 n 可能小于 len(data)★(非阻塞、大块写、被信号打断时)
  ✓ 正确写法:
    view = memoryview(data)
    while view:
        n = os.write(fd, view)
        view = view[n:]
  → 内置文件对象的 f.write() 帮你处理了这些(还带缓冲),这就是它更好用的原因

差异 2:★os.read 返回的字节数可能小于请求量★
  data = os.read(fd, 4096)      # 可能只返回 100 字节,不代表文件结束
  ★ 只有返回 b"" 才表示 EOF★
  ✓ 要读满必须循环(或用 f.read() / os.readv)

差异 3:性能
  逐行 os.write(fd, line) 写 100 万行 → 100 万次系统调用
  用缓冲文件对象 → 约 (总字节/8KB) 次系统调用
  ★ 实测差距可达一个数量级★ → 常规读写永远用内置 open

差异 4:偏移量的操作
  os.lseek(fd, offset, whence)      移动(SEEK_SET/CUR/END)
  os.pread(fd, n, offset)           ★从指定偏移读,不改变文件指针★(3.3+)
  os.pwrite(fd, data, offset)       ★同上,写★
  → pread/pwrite 是★多线程共享一个 fd 时的正确做法★
    (否则 seek 和 read 之间会被别的线程插入,读到错的位置)

差异 5:文件末尾与稀疏文件
  os.lseek(fd, 10**9, os.SEEK_SET); os.write(fd, b"x")
  → 创建一个 1GB 的★稀疏文件★,实际只占几 KB(中间的空洞不落盘)
  → st_size 报 1GB,st_blocks * 512 才是真实占用

差异 6:截断与预分配
  os.truncate(path_or_fd, length)   截断或扩展(扩展部分是空洞)
  os.posix_fallocate(fd, 0, size)   ★真正预分配磁盘空间★(Linux,避免中途 ENOSPC)

什么时候该下沉到低级 IO(决策清单):
  ✓ 需要 O_EXCL / O_NOFOLLOW / O_APPEND 的原子语义
  ✓ 创建文件时要指定权限(mode)
  ✓ 需要非阻塞、O_DIRECT、O_TMPFILE 等特殊标志
  ✓ 要传 fd 给别的库(socket、mmap、inotify)或子进程
  ✓ 多线程共享 fd 做随机读(pread/pwrite)
  ✗ 只是"读个文件"、"写个日志"   → ★老老实实用内置 open★

低级 IO 有五个必须知道的行为差异。os.write 不保证一次写完(返回实际写入的字节数,非阻塞、大块写或被信号打断时会部分写入),os.read 返回的字节数也可能小于请求量只有返回 b"" 才表示 EOF)——这两点都必须靠循环处理,而内置文件对象已经替你做好了。性能差异同样明显:逐行 os.write 写百万行是百万次系统调用,缓冲的文件对象只需要「总字节 / 8KB」次,实测能差一个数量级。多线程共享同一个 fd 时必须用 os.pread/os.pwrite(指定偏移读写、不改变文件指针),否则 seekread 之间会被别的线程插入、读到错误位置。还有两个实用能力:lseek 到很远处再写会创建稀疏文件st_size 报 1GB 但实际只占几 KB),以及 os.posix_fallocate 能真正预分配磁盘空间(避免写到一半 ENOSPC)。决策上很简单:只是读文件写日志就用内置 open(),只有需要特殊 flag、指定权限、传 fd 或多线程随机读时才下沉。

六、实战模板

① 安全地创建一个密钥文件(权限 + 防劫持)
   fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
   with os.fdopen(fd, "w", encoding="utf-8") as f:      # ★fdopen 接管所有权★
       f.write(secret)
   # 如果 os.fdopen 抛异常,fd 会泄漏 → 严谨写法:
   # try: f = os.fdopen(fd, "w")
   # except: os.close(fd); raise

② 进程级互斥锁(原子创建 + PID 文件)
   try:
       fd = os.open("/var/run/app.pid", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
   except FileExistsError:
       sys.exit("已有实例在运行")
   with os.fdopen(fd, "w") as f:
       f.write(str(os.getpid()))
   ★ 更健壮的做法用 fcntl.flock(进程崩溃后锁会自动释放,而 PID 文件会残留)

③ 多进程安全的追加日志
   fd = os.open("app.log", os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o644)
   with os.fdopen(fd, "a", buffering=1) as f:    # ★行缓冲★
       f.write(line + "\n")
   ★ O_APPEND 保证"定位到末尾+写"是一次原子操作,多进程不会互相覆盖

④ 处理不可信路径(杜绝 TOCTOU 和符号链接)
   fd = os.open(user_path, os.O_RDONLY | os.O_NOFOLLOW)
   try:
       st = os.fstat(fd)                          # ★对 fd 做 stat,不会被换掉★
       if st.st_size > MAX or not stat.S_ISREG(st.st_mode):
           raise ValueError("拒绝")
       data = os.read(fd, MAX)
   finally:
       os.close(fd)

⑤ 临时提升 fd 上限(服务启动时)
   import resource
   soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
   resource.setrlimit(resource.RLIMIT_NOFILE, (min(65535, hard), hard))

⑥ 检查是否有 fd 泄漏(测试里)
   def count_fds():
       return len(os.listdir("/proc/self/fd"))     # Linux
   before = count_fds(); do_work(); assert count_fds() == before

★ 一条总原则:
  能用内置 open + with 解决的,就不要碰 os.open;
  必须用 os.open 时,★立刻用 os.fdopen 包装成文件对象交给 with 管理★,
  这样既拿到了精确的创建语义,又不用手写 try/finally。

最后是可以直接抄的模板。创建密钥文件O_CREAT|O_EXCL|O_WRONLY0o600,然后 os.fdopen 接管(注意:如果 os.fdopen 本身抛异常,fd 会泄漏,严谨写法要 try/except: os.close(fd); raise)。进程级互斥可以用 O_EXCL 原子创建 PID 文件(但更健壮的是 fcntl.flock——进程崩溃后锁自动释放,而 PID 文件会残留)。多进程共写日志O_APPEND 保证原子追加。处理不可信路径O_NOFOLLOW + os.fstat(fd) 彻底消除 TOCTOU。整节收敛成一条总原则:能用内置 open() + with 解决的就别碰 os.open;必须用时立刻用 os.fdopen 包装交给 with 管理——既拿到了精确的创建语义,又不用手写 try/finally

记忆钩子:「fd 是内核给进程的整数句柄(0/1/2 是标准流,之后★总是分配当前最小可用整数★)。内核有三层表:★fd 表 → 打开文件表(含偏移量和状态)→ inode★,四条行为全由它推出:os.dup 复制的是指向同一表项所以★共享偏移量★、两次独立 open 的偏移量各自独立、fork 复制 fd 表但共享表项、★unlink 只删目录项,只要还有 fd 指着 inode 数据就不释放★(rm 了大日志但 df 没变的原因)。★内置 open 返回三层封装的文件对象(有缓冲、有编码、能 with、能迭代),os.open 返回裸 fd★;99% 场景用前者,只有三种需求才下沉:★O_EXCL(已存在就失败,防符号链接劫持)、创建时指定权限 mode=0o600(内置 open 做不到)、O_NOFOLLOW/O_NONBLOCK 等特殊标志★,然后立刻 os.fdopen 包成文件对象交给 with。★最常见的 bug 是 fd 泄漏★:ulimit -n 常见 1024,文件/socket/管道/子进程共用这个池,泄漏就是 OSError: [Errno 24] Too many open files;别指望 CPython 的引用计数——★它不是语言保证★,PyPy 靠 GC、循环引用要等 gc、★异常回溯持有的栈帧会引用住文件对象★;开发时用 python -W error::ResourceWarning 把泄漏变成报错,线上采样 /proc/self/fd 打点,用 lsof 定位;最常忘的是 ★tempfile.mkstemp() 返回的裸 fd★。低级 IO 的行为差异:★os.write 不保证一次写完、os.read 返回的可能少于请求量(只有 b” 才是 EOF)★,都要循环;多线程共享 fd 要用 ★pread/pwrite★(不动文件指针)。os.dup2 是所有重定向的底层(shell 的 > 就是它),★和 redirect_stdout 的区别是它连子进程和 C 扩展一起改★。Python 3.4(PEP 446)起新 fd ★默认不可继承★(带 O_CLOEXEC),要传给子进程得 set_inheritable 或 pass_fds。」

七、常见误区与追问

  • 误区:CPython 有引用计数,文件不 close 也会被及时回收,不会泄漏。 引用计数通常确实能在文件对象失去最后一个引用时立刻调用 __del__ 关闭它,但这只是 CPython 的实现细节,不是 Python 语言的保证,而且有四种常见情况会失效:① 其他实现(PyPy、Jython)用分代 GC,回收时机不确定,短时间内可能积压上千个未关闭的文件;② 循环引用的对象要等 gc 运行才回收;③ 异常回溯持有栈帧——try: f = open(p); 1/0 except: pass 之后,sys.exc_info() 里的 traceback 仍然引用着局部变量 f,在异常处理函数里这个引用可能存活很久;④ 长生命周期的容器、闭包、缓存持有文件对象。加上 fd 是有上限的共享资源ulimit -n 常见 1024,且和 socket、管道、子进程共用),泄漏的后果是运行一段时间后突然全面失败。永远用 with,并在开发和 CI 里开 -W error::ResourceWarning 把泄漏变成立即可见的报错。
  • 误区:os.write(fd, data) 会把 data 全部写进去。 它返回实际写入的字节数,可能小于 len(data)——非阻塞 fd、写入大块数据、被信号打断、管道缓冲区将满时都会发生部分写入。忽略返回值就会静默丢数据(而且往往只在数据量大或负载高时才暴露,测试期发现不了)。正确写法是循环:view = memoryview(data)while view: n = os.write(fd, view); view = view[n:]os.read 同理——请求 4096 字节可能只返回 100 字节,这不代表文件结束,只有返回 b"" 才是 EOF(对管道、socket 尤其常见)。内置文件对象的 f.write()/f.read() 已经替你处理了这些循环和缓冲,这正是「常规读写应该用内置 open()」的重要理由。
  • 误区:os.dup2contextlib.redirect_stdout 效果一样,都是重定向输出。 作用层次完全不同。redirect_stdout 只是把 sys.stdout 这个 Python 变量临时指向别的对象——影响的是 Python 代码里的 printsys.stdout.write抓不到子进程的输出,也抓不到 C 扩展直接往 fd 1 写的内容os.dup2(f.fileno(), 1) 改的是内核里的 fd 1 本身——之后所有往 fd 1 写的东西都会进文件,包括子进程(它们继承 fd 表)和 C 扩展。选择取决于你要抓什么:只测自己的 printredirect_stdout(简单、可恢复);要抓子进程或原生库的输出必须用 dup2(记得先 os.dup(1) 备份、用完恢复,且操作前 sys.stdout.flush(),因为 Python 层还有自己的缓冲)。pytest 的 capsyscapfd 正好对应这两个层次。
  • 误区:os.open() 打开的 fd 用完不管也没关系,反正进程退出会释放。 进程退出时内核确实会回收所有 fd,但长期运行的服务不会退出——泄漏会一直累积到 Errno 24。而且 os.open() 返回的是裸整数,没有任何自动清理机制:它不是对象,没有 __del__,引用计数也帮不上忙,忘了 os.close() 就是确定的泄漏(比忘记关文件对象更危险,因为后者至少还有 GC 兜底)。所以用 os.open 必须配 try/finally: os.close(fd),或者立刻用 os.fdopen(fd, ...) 包装成文件对象交给 with 管理(此时所有权转移,关闭包装对象就会关掉 fd)。还有个容易忽略的细节:os.fdopen 本身抛异常时 fd 会泄漏,严谨写法是 try: f = os.fdopen(fd, "w") except: os.close(fd); raise。同类陷阱还有 tempfile.mkstemp()——它返回 (fd, path),那个 fd 必须由你负责关闭。
  • 误区:删除了一个大文件,磁盘空间就立刻释放了。 os.remove()(unlink)只是删除目录项并把 inode 的链接数减一;只要还有进程持有指向该 inode 的 fd,数据块就不会被释放——ls 已经看不到文件了,但 df 显示的可用空间纹丝不动,du 也统计不到它(这是运维排查「磁盘满了但找不到大文件」的经典场景)。定位方法是 lsof | grep deleted,能看到哪个进程还开着已删除的文件。解决办法有两个:重启(或让进程重新打开日志文件,比如给它发 SIGHUP,或者不要删而是截断——: > /var/log/app.log(shell)或 os.truncate(path, 0),这样 inode 还在、进程的 fd 依然有效,空间立刻释放。这也是日志轮转工具必须配合 copytruncate 或通知进程重开文件的原因。
  • 追问:os.dup(fd) 复制出来的 fd 和重新 os.open() 同一个文件,有什么区别? 区别在是否共享「打开文件表项」,而偏移量和状态标志就存在那个表项里。os.dup(fd) 复制的是「指向同一个表项」的箭头:两个 fd 共享同一个偏移量(用 fd1 读了 100 字节,fd2 的位置也前进了 100)、共享状态标志(一个设了 O_APPEND 另一个也是),关掉其中一个不影响另一个。重新 os.open() 会创建一个全新的表项:偏移量从 0 开始、与原来的 fd 完全独立,两者只是最终指向同一个 inode。这个区别在两处特别重要:① 重定向必须用 dup2——shellcmd > f 要让 fd 1 和文件共享同一个写入位置,重新 open 做不到;② fork 后父子进程共享偏移量(fd 表被复制但指向同一批表项),所以父子同时往一个日志文件写会互相覆盖,除非用 O_APPEND
  • 追问:为什么 Python 3.4 起新创建的 fd 默认不可继承? 这是 PEP 446 的变更,目的是消除一类长期存在的安全与资源问题。3.4 之前,Python 创建的 fd 默认会被 fork+exec 出来的子进程继承,后果有三类:① 安全泄漏——子进程(可能是不受信任的外部程序)意外拿到了父进程打开的敏感文件、数据库连接、私钥文件的 fd,甚至能读写它们;② 资源无法释放——父进程关闭并删除了某个文件,但子进程还持有 fd,磁盘空间不释放、Windows 上文件甚至删不掉;③ 端口/socket 被意外占用——服务重启时发现端口还被某个残留子进程占着。3.4 起所有新 fd 默认带 O_CLOEXEC(exec 时自动关闭),只有标准流 0/1/2 是永远可继承的例外(否则子进程没法输入输出)。要主动把某个 fd 传给子进程,需要 os.set_inheritable(fd, True),或者用 subprocesspass_fds=(fd,) 参数(它还会自动处理 close_fds 的相互作用)。
  • 追问:什么时候真的需要从内置 open() 下沉到 os.open() 五种情况。① 需要原子的创建语义O_EXCL 保证「文件已存在就失败」——用于创建锁文件、PID 文件,以及防止符号链接劫持(路径上是链接也算存在,直接失败)。② 创建时必须指定权限:内置 open() 固定用 0o666 & ~umask,写密钥、token 时需要文件从诞生起就是 0o600,先建后 chmod 中间有窗口期。③ 需要特殊标志O_NOFOLLOW(拒绝符号链接)、O_NONBLOCK(打开 FIFO 不阻塞)、O_DIRECTORYO_TMPFILE(无名临时文件)、O_DIRECT(绕过页缓存)。④ 要把 fd 交给别的东西mmapsocket.fromfdinotify、传给子进程。⑤ 多线程共享一个 fd 做随机读写:用 os.pread/os.pwrite 指定偏移,避免 seekread 之间被其他线程插入。除此之外——「读个文件」「写个日志」「解析 CSV」——都应该老老实实用内置 open(),它的缓冲、编码、行迭代和 with 支持都是实打实的价值;即便用了 os.open,也应该立刻 os.fdopen() 包装回文件对象。

八、加强记忆

文件描述符是内核给进程的整数句柄(0/1/2 是标准流,之后总是分配当前最小的可用整数)。内核维护三层表:fd 表 → 打开文件表(含偏移量和状态标志)→ inode,四条关键行为全由它推出:os.dup 复制的是指向同一表项的箭头,所以两个 fd 共享偏移量两次独立 os.open 的偏移量各自独立fork 复制 fd 表但共享表项(父子共享偏移量);unlink 只删目录项,只要还有 fd 指着 inode 数据就不释放(这就是「rm 了大日志但 df 没变」的原因,解法是截断而不是删除)。内置 open() 返回三层封装的文件对象(有缓冲、有编码、能 with、能迭代行),os.open() 返回裸 fd;99% 的场景用前者,只有三类需求才下沉:O_EXCL(已存在就失败,防符号链接劫持)、创建时指定权限 mode=0o600(内置 open 做不到)、O_NOFOLLOW/O_NONBLOCK/O_APPEND 等特殊语义——用完立刻 os.fdopen() 包装成文件对象交给 with 管理最常见的 bug 是 fd 泄漏ulimit -n 常见 1024,文件、socket、管道、子进程共用这个池,泄漏就是 OSError: [Errno 24] Too many open files别指望 CPython 的引用计数——它不是语言保证,PyPy 靠 GC、循环引用要等 gc、异常回溯持有的栈帧会引用住文件对象;开发时用 python -W error::ResourceWarning 把泄漏变成报错,线上采样 /proc/self/fd 打点、用 lsof 定位,最常被忘记的是 tempfile.mkstemp() 返回的裸 fd低级 IO 的行为差异os.write 不保证一次写完、os.read 返回的可能少于请求量(只有 b"" 才是 EOF),两者都必须循环处理;多线程共享 fd 要用 os.pread/os.pwrite(不改变文件指针)。os.dup2 是所有重定向的底层(shell 的 > 就是它),redirect_stdout 的区别在于它连子进程和 C 扩展一起改。最后,Python 3.4(PEP 446)起新 fd 默认不可继承(带 O_CLOEXEC),要传给子进程得 os.set_inheritable(True)subprocesspass_fds