文件描述符是什么?os.open 和内置 open 有什么区别?
简化版
文件描述符(fd)是内核给进程的一个「已打开文件」的整数句柄(0/1/2 分别是标准输入输出错误,之后从 3 开始递增)。内置 open() 返回的是包了三层的文件对象**(TextIOWrapper → BufferedWriter → FileIO),而 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/finally 或 os.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_WRONLY 加 mode=0o600 用于安全地创建密钥或锁文件(已存在就失败,因此能挡住符号链接劫持,且权限从诞生起就正确);② O_CREAT | O_WRONLY | O_APPEND 用于多进程共写的日志——O_APPEND 的「移到末尾 + 写入」是原子的,多个进程同时写不会互相覆盖(前提是单次写入不太大且不混用 seek);③ O_RDONLY | O_NOFOLLOW 加 os.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) 或 subprocess 的 pass_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(指定偏移读写、不改变文件指针),否则 seek 和 read 之间会被别的线程插入、读到错误位置。还有两个实用能力: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_WRONLY 加 0o600,然后 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.dup2和contextlib.redirect_stdout效果一样,都是重定向输出。 作用层次完全不同。redirect_stdout只是把sys.stdout这个 Python 变量临时指向别的对象——影响的是 Python 代码里的print和sys.stdout.write,抓不到子进程的输出,也抓不到 C 扩展直接往 fd 1 写的内容。os.dup2(f.fileno(), 1)改的是内核里的 fd 1 本身——之后所有往 fd 1 写的东西都会进文件,包括子进程(它们继承 fd 表)和 C 扩展。选择取决于你要抓什么:只测自己的print用redirect_stdout(简单、可恢复);要抓子进程或原生库的输出必须用dup2(记得先os.dup(1)备份、用完恢复,且操作前sys.stdout.flush(),因为 Python 层还有自己的缓冲)。pytest 的capsys和capfd正好对应这两个层次。 - 误区:
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——shell的cmd > 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),或者用subprocess的pass_fds=(fd,)参数(它还会自动处理close_fds的相互作用)。 - 追问:什么时候真的需要从内置
open()下沉到os.open()? 五种情况。① 需要原子的创建语义:O_EXCL保证「文件已存在就失败」——用于创建锁文件、PID 文件,以及防止符号链接劫持(路径上是链接也算存在,直接失败)。② 创建时必须指定权限:内置open()固定用0o666 & ~umask,写密钥、token 时需要文件从诞生起就是0o600,先建后chmod中间有窗口期。③ 需要特殊标志:O_NOFOLLOW(拒绝符号链接)、O_NONBLOCK(打开 FIFO 不阻塞)、O_DIRECTORY、O_TMPFILE(无名临时文件)、O_DIRECT(绕过页缓存)。④ 要把 fd 交给别的东西:mmap、socket.fromfd、inotify、传给子进程。⑤ 多线程共享一个 fd 做随机读写:用os.pread/os.pwrite指定偏移,避免seek与read之间被其他线程插入。除此之外——「读个文件」「写个日志」「解析 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) 或 subprocess 的 pass_fds。