Python 报 Too many open files:在 Linux 定位文件句柄泄漏
批处理脚本跑了一阵,突然报 OSError: [Errno 24] Too many open files。磁盘还有空间,输入文件也不大,却再也打不开下一份文件。问题往往出在进程同时持有的文件描述符数量,而不是输入文件的总数。
本文面向初学者到中级读者,在 Linux 上复现一个文件句柄泄漏,再从进程外查看实际限制和打开的文件,最后用 with 修复。演示只读取一份测试文件;降低软上限的操作只作用于演示 Python 进程。
1. 方法:检查发生错误的进程
Python 的 resource 官方文档说明,RLIMIT_NOFILE 控制当前进程的文件描述符上限,getrlimit() 返回软上限和硬上限。软上限是当前执行的限制,不能超过硬上限。
文件描述符不只用于普通文件,也用于 socket 和管道。根据 Python errno 文档,EMFILE 表示打开文件过多;ENFILE 是系统文件表耗尽,排查范围不同。
先区分两种情况:
| 观察结果 | 下一步 |
|---|---|
| 同样的工作反复执行后,描述符数量持续增加 | 查找漏掉的关闭操作,以及仍保存着文件对象的列表或缓存。 |
| 数量随并发增加,任务结束后回落 | 限制并发,估算每个任务需要的描述符,再判断当前上限是否够用。 |
一次高计数不能证明泄漏。 在相同负载下重复采样,结合描述符指向的文件和代码生命周期判断。
2. 准备环境和测试文件
需要 Linux、可访问的 /proc 和已有的 Python 3.9 或更新版本。无需安装第三方依赖;本文在 Linux、Python 3.11.4 上测试。resource 模块不适用于 Windows。
- 在 bash 中创建独立演示目录。重复实验时换一个目录名:
python3 --version
python3 -c 'import resource; print("resource ready")'
mkdir fd-demo
cd fd-demo
printf 'alpha\nbeta\n' > sample.txt
- 把下一节代码保存为当前目录的
fd_demo.py,也可以下载完整演示脚本。 - 演示会把自身软上限降至 64;原有上限更小时沿用较小值,低于 16 时拒绝运行。硬上限不变,启动它的 shell 和其他进程也不变。这是复现故障的测试设置,不要复制到实际任务入口。
3. 完整实现:泄漏与修复共用一份脚本
"""Reproduce a file descriptor leak, then compare scoped file reads on Linux."""
import argparse
import os
from pathlib import Path
import resource
import sys
import time
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("file", type=Path, help="existing input file, read only")
parser.add_argument("--mode", choices=("leak", "fixed"), default="fixed")
parser.add_argument("--iterations", type=int, default=200)
parser.add_argument("--hold", type=int, default=0, help="inspection pause, 0-60 seconds")
args = parser.parse_args()
if not 1 <= args.iterations <= 10000:
parser.error("iterations must be from 1 to 10000")
if not 0 <= args.hold <= 60:
parser.error("hold must be from 0 to 60")
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
finite = [64] + [n for n in (soft, hard) if n != resource.RLIM_INFINITY]
limit = min(finite)
if limit < 16:
parser.error("demo needs an existing descriptor limit of at least 16")
try:
resource.setrlimit(resource.RLIMIT_NOFILE, (limit, hard))
except (OSError, ValueError) as exc:
print(f"ERROR: cannot set demo limit: {exc}", file=sys.stderr, flush=True)
return 1
print(f"pid={os.getpid()} mode={args.mode} soft={limit} hard={hard}", flush=True)
retained = []
processed = status = 0
try:
try:
for _ in range(args.iterations):
if args.mode == "leak":
file = args.file.open("rb")
retained.append(file)
file.read(1)
else:
with args.file.open("rb") as file:
file.read(1)
processed += 1
except OSError as exc:
print(f"ERROR: errno={exc.errno}: {exc}", file=sys.stderr, flush=True)
status = 1
print(f"processed={processed} retained={len(retained)} status={status}", flush=True)
if args.hold:
print(f"READY: holding for {args.hold}s", flush=True)
time.sleep(args.hold)
finally:
for file in retained:
file.close()
return status
if __name__ == "__main__":
raise SystemExit(main())
leak 分支持续把打开的文件对象保留在列表中,即使已经读取完毕,也没有关闭。fixed 分支每次都在 with 内读取,离开代码块时关闭文件。两者重复打开同一份文件,因此「目录里只有一个文件」也能触发上限。
--hold 让进程在处理完或报错后暂留,方便另一个进程检查。泄漏分支会在暂停结束后的 finally 中关闭文件,但工作循环已经失败,退出码仍是 1;这种收尾不能替代循环内及时释放资源。
4. 方法 1:复现错误并查看打开的文件
- 查看帮助,然后运行泄漏模式。以下命令在同一个 bash 终端中执行,
pid=$!记录刚启动的后台任务:
python3 fd_demo.py --help
python3 fd_demo.py sample.txt --mode leak --hold 30 &
pid=$!
sleep 1
cat "/proc/$pid/limits"
ls -l "/proc/$pid/fd"
python3 - "$pid" <<'PY_FD'
from pathlib import Path
import sys
folder = Path("/proc", sys.argv[1], "fd")
print("fd_count=", sum(1 for _ in folder.iterdir()), sep="")
PY_FD
if wait "$pid"; then
printf 'exit=0\n'
else
printf 'exit=%s\n' "$?"
fi
- 重点看
Max open files行的软上限,以及/proc/$pid/fd中反复指向sample.txt的条目。只有标准输入、输出、错误三个初始描述符时,典型结果为:
ERROR: errno=24: [Errno 24] Too many open files: 'sample.txt'
processed=61 retained=61 status=1
READY: holding for 30s
fd_count=64
exit=1
实际 PID、硬上限和成功次数会随启动环境变化。启动器额外继承的描述符也会占用额度;不要把 61 写成业务断言。
Linux 内核的 /proc 文档说明,进程目录按 PID 命名,fd 包含文件描述符;/proc/PID/limits 手册说明该文件列出软、硬资源上限。ls -l 能查看这些描述符的链接目标。
这里从进程外检查,是因为已经耗尽描述符的进程可能连自己的 /proc/self/fd 目录都打不开。采样不是原子快照;在实际程序中,文件可能在查看期间关闭。
5. 方法 2:及时关闭,然后验证修复
- 在相同演示上限下运行修复模式:
python3 fd_demo.py sample.txt --mode fixed
printf 'exit=%s\n' "$?"
python3 fd_demo.py sample.txt --mode fixed --iterations 1000
第一次应输出 processed=200 retained=0 status=0 和 exit=0;第二次应输出 processed=1000 retained=0 status=0。成功次数已经超过软上限,因为处理完一轮就释放了描述符。
- 验证异常路径也会关闭文件:
python3 - <<'PY_CLOSE'
file = None
try:
with open("sample.txt", "rb") as file:
file.read(1)
raise RuntimeError("demo processing error")
except RuntimeError:
pass
assert file is not None and file.closed
print("OK: closed after exception")
PY_CLOSE
Python 文件读写教程说明,with 在代码块结束时关闭文件,即使处理过程中抛出异常。实际批处理也应把 with 放在每个文件的处理范围内,而不是先打开所有文件再统一处理。
读取 CSV 时可套用大型 CSV 流式处理中的单文件读取结构。运行外部命令时,还要管理管道、日志句柄和子进程结束,参考Python subprocess 超时与日志处理。
6. 排障:限制、权限和自动化环境
| 现象 | 具体操作 |
|---|---|
/proc/$pid 不存在 |
检查进程是否已经退出;重跑演示并使用 --hold 60,在暂停期间完成检查。 |
查看 fd 时提示权限不足 |
用任务所属用户检查,并确认容器 PID 命名空间和 /proc 访问策略。 |
演示显示 errno=2 |
确认 sample.txt 存在,或传入它的绝对路径;这是缺失文件,不是描述符耗尽。 |
| 修复文件读取后仍增长 | 按链接目标检查 socket、管道、日志处理器和重复创建的连接;为相应对象安排关闭流程。 |
| 终端成功,定时任务失败 | 查看定时任务实际 PID 的 limits;交互 shell 的限制不一定相同。 |
报的是 ENFILE |
查主机层面的文件表和其他进程;仅检查一个 Python 进程不足以定位。 |
检查当前 bash 的软、硬上限,可以运行以下只读命令:
ulimit -Sn
ulimit -Hn
这两行显示的是当前 shell 的限制,不能代表另一份已经运行的服务。生产排障应以 /proc/实际PID/limits 为准;定时任务的运行路径和工作目录也可按Linux 定时任务环境排查逐项核对。
如果描述符数量在合理并发下稳定回落,但峰值确实高于现有限制,再按启动器的配置方式调整上限,并重新查看运行进程验证。持续增长的泄漏只会在更高上限下更晚失败。修复后重复运行同一批工作,确认句柄数量回到稳定范围;retained=0 只说明演示列表为空,不代表整个应用没有其他句柄。
7. 小结
先查看发生错误的进程上限和描述符目标,再判断是泄漏还是正常并发需求。把文件处理放进 with,同时验证正常和异常路径都能释放资源。最后在实际启动环境下重复采样,确认修复后数量稳定,再考虑是否需要调整容量。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-too-many-open-files.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。