“Too many open files”是爬虫放大并发后最经典的报错之一。这篇在本机把软限制压低到 100,复现它在 aiohttp 和 requests 里的样子,量每条连接占多少描述符,再查出一个比并发更隐蔽的原因:把 Response 对象存起来。
当前 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,因为进程本身已占了几个描述符。注意同样的错误也会出现在“打开文件、读写数据库”等任何要新描述符的地方,不只是网络请求。
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 时连接被复用,也不涨。为什么读完的响应仍占着连接,我没有读源码查。