Python 中如何解决 asyncio 文件描述符最大数量限制问题


问题复现

Windows 平台下,Python 版本 3.5,使用异步框架 asyncio,有时候会出现 ValueError: too many file descriptors in select() 的报错信息,今天我们就来聊一下为什么会出现这种问题,以及问题的一些解决方法。

写一个小 dome 复现这个问题(环境:Windows 64 位、Python 3.7):

import aiohttp
import asyncio


num = 0


async def main(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            global num
            num += 1
            print('%s ——> %s' % (str(num), response.status))


def tasks():
    url = 'https://www.baidu.com/s?ie=UTF-8&wd=%s'
    task = [main(url % i) for i in range(10000)]
    return task


loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks()))

在打印 500 次左右后就会出现以下报错:

01.png

问题分析

好像这个报错和 select 有关,那什么是 select 呢?要怎么解决呢?别急,我们首先来了解一下 asyncio 中的事件循环,即 EventLoop。

事件循环 EventLoop

事件循环是 asyncio 的核心,异步任务的运行、任务完成之后的回调、网络 I/O 操作、子进程的运行,都是通过事件循环完成的,通俗来讲,事件循环所做的就是等待事件发生,然后再将每个事件与我们已明确与所述事件类型匹配的函数进行匹配。下图很好的展示了协程、事件循环之间的相互作用:

02.png

在 asyncio 中,主要提供了两种不同事件循环的实现方法:

  • SelectorEventLoop:基于 selectors 模块的事件循环,selectors 又是建立在底层的 I/O 复用模块 select 之上的,selectors 提供了高度封装和高效的 I/O 复用,也就是说 SelectorEventLoop 在底层就是使用了 select I/O 多路复用的机制。

  • ProactorEventLoop:使用 IOCP 专为 Windows 构建的事件循环,IOCP 全称 I/O Completion Port,即 I/O 完成端口。它是支持多个同时发生的异步 I/O 操作的应用程序编程接口,它充分利用内核对象的调度,只使用少量的几个线程来处理和客户端的所有通信,消除了无谓的线程上下文切换,是 Windows 下性能最好的 I/O 模型,有关 IOCP 的详细介绍可参考微软文档

那么这两种方法有什么区别呢?在 asyncio 中什么时候用什么方法呢?

我们不妨看一下 asyncio 的源码,在 Python 3.7 中,无论在 Windows 还是 Linux 中都可以看到其默认的设置是 SelectorEventLoop:

03.png

我们也可以分别在 Windows 平台和 Linux 平台打印一下 EventLoop 对象(Python 3.7),可以看到默认都是 SelectorEventLoop:

import asyncio

loop = asyncio.get_event_loop()
print(loop)

Windows:

04.png

Linux:

05.png

事实上,在 Python 3.7 以及之前的版本中, 所有平台默认使用的都是 SelectorEventLoop,在 Python 3.8 以及以后的版本中,Unix 平台默认使用的是 SelectorEventLoop,Windows 平台默认使用的是 ProactorEventLoop,这个差异可以在官方文档中看到。

06.png

说了这么多,这和 ValueError: too many file descriptors in select() 的报错问题有什么关系呢?select 到底是什么东西呢?

I/O 多路复用

要了解 select,我们还要了解一下什么是 I/O 多路复用(I/O multiplexing),服务器端编程经常需要构造高性能的 I/O 模型,常见的 I/O 模型有同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用等;当需要同时处理多个客户端接入请求时,可以利用多线程或者 I/O 多路复用技术进行处理,I/O 多路复用技术就是为了解决进程或线程阻塞到某个 I/O 系统调用而出现的技术,使进程不阻塞于某个特定的 I/O 系统调用。

select,poll,epoll 等都是 I/O 多路复用的一种机制,其中后两个在 Linux 中可用,Windows 仅支持 select,I/O 多路复用通过这种机制,可以监视多个描述符,一旦某个描述符就绪,一般是读就绪或者写就绪,就是在这个文件描述符进行读写操作之前,能够通知程序进行相应的读写操作。

select 的缺点

I/O 多路复用这个概念被提出来以后, select 是第一个实现这个概念的,select 被实现以后,很快就暴露出了很多问题,其中一个缺点就是 select 在 Windows 中限制了文件描述符数量为 512 个,在 Linux 中限制为 1024 个,那么在前面的 dome 中,使用的是 Python 3.5,这个版本的 asyncio 默认使用了 SelectorEventLoop,底层调用的是 select,受 select 缺点的影响,并发量过高,就出现了 ValueError: too many file descriptors in select() 的报错信息。

解决方法

1.更换事件循环选择器

如果你使用的是 Python 3.7 及以下的版本,那么在 Windows 平台,可以使用 ProactorEventLoop。在 Linux 平台可以使用 PollSelector。

注意:如果你使用了 ProactorEventLoop,那么你将无法使用代理!这是 asyncio 的 bug,早在 2020 年 1 月就有人提过 issue,目前仍然可以看到类似的 issue,官方貌似也还没办法解决,所以,如果您必须要使用代理,则可以参考后面的解决办法。

import selectors
import asyncio
import sys

if sys.platform == 'win32':
    loop = asyncio.ProactorEventLoop()
    asyncio.set_event_loop(loop)
else:
    selector = selectors.PollSelector()
    loop = asyncio.SelectorEventLoop(selector)
    asyncio.set_event_loop(loop)

2.限制并发量

可以使用方法 asyncio.Semaphore() 来限制并发量,Semaphore 就是信号量的意思,Semaphore 管理一个内部计数器,该计数器在每次调用 acquire() 方法时递减,每次调用 release() 方法时递增,计数器永远不会低于零,当方法 acquire() 发现它为零时,它会阻塞,等待其他线程调用 release() 方法。通过限制并发量的方法来解决报错问题是个不错的选择。

import aiohttp
import asyncio


num = 0


async def main(url, semaphore):
    async with semaphore:
        async with aiohttp.ClientSession() as session:
            async with session.get(url) as response:
                global num
                num += 1
                print('%s ——> %s' % (str(num), response.status))


def tasks():
    semaphore = asyncio.Semaphore(300)                         # 限制并发量为 300
    url = 'https://www.baidu.com/s?ie=UTF-8&wd=%s'
    task = [main(url % i, semaphore) for i in range(10000)]    # #总共 10000 任务
    return task


loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks()))

3.修改最大文件描述符限制

Windows

在 Windows 中,最大文件描述符限制在 C 语言的头文件 Winsock2.h 中使用变量 FD_SETSIZE 进行定义,如果要修改它,可以通过在包含 Winsock2.h 之前将 FD_SETSIZE 定义为另一个值来修改,如果我们使用的编程语言是 Python 的话,是不太好对这个值进行修改的,可以参考微软官方文档:https://docs.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-select

Linux

在 Linux 平台,可以使用 ulimit 命令来修改最大文件描述符限制:

  • 查看当前会话最大文件描述符限制(默认1024):ulimit -n

  • 临时修改限制,只对当前的会话有效:ulimit -SHn 65536

  • 永久修改限制,在 /etc/security/limits.conf 文件里新增以下内容:

    * hard nofile 65536
    * soft nofile 65536

ulimit 命令参考:

-S	使用软 (soft) 资源限制
-H	使用硬 (hard) 资源限制
-a	所有当前限制都被报告
-b	套接字缓存尺寸
-c	创建的核文件的最大尺寸
-d	一个进程的数据区的最大尺寸
-e	最高的调度优先级 (nice)
-f	有 shell 及其子进程可以写的最大文件尺寸
-i	最多的可以挂起的信号数
-k	分配给此进程的最大 kqueue 数量
-l	一个进程可以锁定的最大内存尺寸
-m	最大的内存进驻尺寸
-n	最多的打开的文件描述符个数
-p	管道缓冲区尺寸
-q	POSIX 信息队列的最大字节数
-r	实时调度的最大优先级
-s	最大栈尺寸
-t	最大的CPU时间,以秒为单位
-u	最大用户进程数
-v	虚拟内存尺寸
-x	最大的文件锁数量
-P	最大伪终端数量
-T	最大线程数量

总结

asyncio 事件循环选择器,在 Python 3.7 以及之前的版本中,所有平台默认使用的都是 SelectorEventLoop,在 Python 3.8 以及以后的版本中,Unix 平台默认使用的是 SelectorEventLoop,Windows 平台默认使用的是 ProactorEventLoop。

select 在 Windows 中限制了文件描述符最大数量为 512 个,在 Linux 中限制为 1024 个。

要解决 ValueError: too many file descriptors in select() 的报错问题,根据您的平台和业务要求选择合理的解决方法:

Windows

  1. 通过 asyncio.Semaphore() 方法来限制并发量,通常设置在 300-500 比较合理,这是最优的做法;

  2. 更换 asyncio 的事件循环选择器为 ProactorEventLoop,注意:这将导致无法使用代理!

Linux

  1. 通过 asyncio.Semaphore() 方法来限制并发量,通常设置在 800-1000 比较合理;

  2. 通过 ulimit 命令来修改最大文件描述符限制;

  3. 更换 asyncio 的事件循环选择器为 PollSelector。