bigywater.tech
爬虫工程 · 25

文件描述符上限:并发连接撞到 ulimit -n 时的报错

“Too many open files”是爬虫放大并发后最经典的报错之一。这篇在本机把软限制压低到 100,复现它在 aiohttp 和 requests 里的样子,量每条连接占多少描述符,再查出一个比并发更隐蔽的原因:把 Response 对象存起来。

errno 24
描述符耗尽时,aiohttp 的 ClientConnectorError 和 requests 的 ConnectionError 底层都是 OSError 24
97 对 300
软限制 100 时,保留 Response 对象能完成的请求数,对只保留 .content 能完成的请求数
1.1
aiohttp 每条并发连接占的描述符数(20 条连接多出 23 个)

限制是多少

实测 · 本机执行输出
  当前 shell 的 ulimit -n(软限制):1048576;硬限制:unlimited;内核 kern.maxfilesperproc = 61440
  把软限制设成 10000000:10000000(设置没有报错,但内核实际能给单个进程的上限仍是 kern.maxfilesperproc;这里没有去验证真能开到多少个)

这台 Mac 的软限制是 1048576,内核对单个进程的上限是 61440,所以本文的问题在默认设置下碰不到;很多 Linux 服务器默认 1024,更容易碰到。把软限制 setrlimit 成一个很大的数不会报错,但内核的上限仍然生效。

每条连接占多少

实测 · 本机执行输出
  请求发出前 3 个,20 条连接都在途时 26 个,每条连接多出 1.1 个

每条并发连接占一个套接字描述符;asyncio 事件循环自己还占几个(这里推测是 kqueue 和自管道)。同时在途的连接数加上这几个,不能超过软限制。

耗尽时的报错

实测 · 本机执行输出
  aiohttp:成功 94,失败 {'ClientConnectorError:OSError:24': 206},过程中最多同时打开 100 个描述符(起点 3)
  requests(300 个线程):成功 97,失败 {'ConnectionError:OSError:24': 203}(起点 3)
  对照:同样 300 并发,软限制 5000:
  aiohttp:成功 300,失败 {},最多同时打开 306 个描述符

软限制压到 100、并发 300:aiohttp 抛 ClientConnectorError,requests 抛 ConnectionError,里面的 OSError 都是 errno 24(Too many open files);成功的数量略少于 100,因为进程本身已占了几个描述符。注意同样的错误也会出现在“打开文件、读写数据库”等任何要新描述符的地方,不只是网络请求。

保留 Response 对象

实测 · 本机执行输出
  results.append(requests.get(url))(保留整个 Response):完成 97 个请求,第 98 个失败(ConnectionError);结束时进程里有 100 个描述符
  保留 Response,但读完后调用了 r.close():完成 97 个请求,第 98 个失败(ConnectionError);结束时进程里有 100 个描述符
  results.append(requests.get(url).content)(只保留 bytes):完成 300 个请求,全部成功;结束时进程里有 4 个描述符
  同一个 Session.get,保留整个 Response:完成 300 个请求,全部成功;结束时进程里有 4 个描述符

把 requests.get(url) 的返回值存进列表,每个响应背后的连接就一直开着,哪怕响应体已经读完、哪怕调用了 r.close()。软限制 100 时 97 个请求之后就耗尽。只保留需要的内容(.content、.text、解析结果),连接随响应被回收;共用一个 Session 时连接被复用,也不涨。为什么读完的响应仍占着连接,我没有读源码查。

分步演示

文件描述符上限
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。限制在客户端子进程里用 resource.setrlimit 把软限制压低(服务器在父进程里,软限制保持默认);描述符数量通过读 /dev/fd 目录统计(减去读目录本身占的 1 个);第四节的服务器如果与客户端在同一进程会把两端都数进去(我一开始就踩了这个坑,所以最终实验把服务器放在父进程、客户端在子进程);aiohttp 3.14.3、requests 2.34.2
    • 没有验证:Linux 上的行为与默认限制;kern.maxfilesperproc 实际能开到多少个;HTTPS、代理下每条连接占用的描述符数;保留 Response 时连接为什么没被关闭(没读源码);aiohttp 里保留 ClientResponse 对象的类似问题;ulimit 与 launchd/systemd 的服务级配置;文件、管道等其它描述符消耗与网络连接混合时的预算
    京ICP备2026067502号-1京公网安备11010502063487号