python异步编程--回调模型(selectors模块)

1. 前言

并发的解决方案中,因为阻塞IO调用的原因,同步模型(串行/多进程/多线程)并不适合大规模高并发.在非阻塞IO调用中,我们可以使用一个线程完成高并发的功能,不过因为非阻塞IO会立即返回,如何判断IO准备就绪以及就绪之后如何处理就变成了关键,所以我们需要附带额外的处理.

不论使用哪一种额外处理方式,核心都是为了获知IO准备就绪且执行对应的操作,额外处理方式之一就是回调+事件循环.

​OS​​已经为我们提供了​​select/poll/epoll/kqueue​​等多种底层操作系统接口用以处理IO准备就绪的通知(即通过​​OS​​提供的接口可以方便的编写事件循环).而程序还需要完成:如何在IO准备就绪的时候执行预定的操作.

​selecotrs​​模块,总代码​​611​​行,其中有​​5​​个类是同一个级别,只是根据​​OS​​的类型而有所不同.模块中还包含大量的注释,所以核心代码数量就在​​100​​行左右.​​selectors​​模块为我们提供了异步编程中的回调模型(后面还会写异步编程中的协程模型),所以我觉得对此模块的研究是很有必要的.

 

第一部分来源:​​​

selectors模块

  1、了解select,poll,epoll




IO复用:复用也就是共用的意思。在通信领域中为了充分利用网络连接的物理介质,往往在同一条网络链路上采用时分复用或频分复用的技术使其在同一链路上传输多路信号,即复用。
服务器编程的模型:客户端发来的请求服务端会产生一个进程来对其进行服务,每当来一个客户请求就产生一个进程来服务,然而进程不可能无限制的产生,因此为了解决大量客户端访问的问题,引入了IO复用技术,即:一个进程可以同时对多个客户请求进行服务。
也就是说IO复用的“介质”是进程(准确的说复用的是select和poll,因为进程也是靠调用select和poll来实现的),复用一个进程(select和poll)来对多个IO进行服务,虽然客户端发来的IO是并发的但是IO所需的读写数据多数情况下是没有准备好的,因此就可以利用一个函数(select和poll)来监听IO所需的这些数据的状态,一旦IO有数据可以进行读写了,进程就来对这样的IO进行服务。。




  2、三个API(select、poll和epoll)的区别和联系

  联系:




select,poll,epoll都是IO多路复用的机制,I/O多路复用就是通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知应用程序进行相应的读写操作。但select,poll,epoll本质上都是同I/O。
因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。




  区别:




三者的原型如下所示:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);




  1.1、select的参数介绍和调用步骤




select的第一个参数nfds为fdset集合中最大描述符值加1,fdset是一个位数组,其大小限制为__FD_SETSIZE(1024),
位数组的每一位代表其对应的描述符是否需要被检查。第二三四参数表示需要关注读、写、错误事件的文件描述符位数组,
这些参数既是输入参数也是输出参数,可能会被内核修改用于标示哪些描述符上发生了关注的事件,
所以每次调用select前都需要重新初始化fdset。timeout参数为超时时间,该结构会被内核修改,其值为超时剩余的时间。






# 调用步骤:
(1)使用copy_from_user从用户空间拷贝fdset到内核空间

(2)注册回调函数__pollwait

(3)遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll
或者datagram_poll)

(4)以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。

(5)__pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll 来说,
其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据
(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。

(6)poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。

(7)如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是 current)
进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout 指定),
还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。

(8)把fd_set从内核空间拷贝到用户空间。




  1.2、缺点:




(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

(3)select支持的文件描述符数量太小了,默认是1024




  2 、poll模块




poll与select不同,通过一个pollfd数组向内核传递需要关注的事件,故没有描述符个数的限制,pollfd中的events字段和revents分别
用于标示关注的事件和发生的事件,故pollfd数组只需要被初始化一次。

poll的实现机制与select类似,其对应内核中的sys_poll,只不过poll向内核传递pollfd数组,然后对pollfd中的每个描述符进行poll,
相比处理fdset来说,poll效率更高。poll返回后,需要对pollfd中的每个元素检查其revents值,来得指事件是否发生。




  3、epoll模块




epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll 和select和
poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函 数,
epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注 册要监听的事件类型;
epoll_wait则是等待事件的产生。




  epoll模块针对poll的三个缺点的改进



对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定 EPOLL_CTL_ADD),
会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝 一次。

  对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在 epoll_ctl时把
current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调 函数,
而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd
(利用 schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。

  对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,
在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。


 

2. 核心类

​selectors​​模块中的核心类如下:

python异步编程--回调模型(selectors模块)_等待队列

​BaseSelector​​:是一个抽象基类,定义了核心子类的函数接口.​​BaseSelector​​类定义的核心接口如下:

@abstractmethodregister(self, fileobj, events, data=None)  # 提供文件对象的注册@abstractmethodunregister(self, fileobj)  # 注销已注册的文件对象@abstractmethodselect(self, timeout=None)  # 向OS查询准备就绪的文件对象


其中,前两个函数封装了文件对象,并提供了​​data​​变量用于保存附加数据,这就提供了回调的环境.第三个函数​​select​​是对​​OS​​底层​​select/poll/epoll​​接口的封装,用以提供一个统一的对外接口.

​_BaseSelectorImpl​​:是一个实现了​​register​​和​​unregister​​的基类,注意,此基类并没有实现​​select​​函数,因为​​select​​函数在不同​​OS上​​使用的底层接口不同,所以应该在对应的子类中定义

​SelectSelector​​:使用​​windows​​时的接口

​EpollSelector​​:使用​​linux​​时的接口(其他​​3​​个类相似,只是应用于不同的​​OS​​)

​DefaultSelector​​:此为类别名,​​selectors​​模块会根据所在操作系统的类型,选择最优的接口

如下只对​​selectselector​​类的核心代码进行分析,其他对应类的代码逻辑基本一致.

3. SelectSelector核心函数代码分析

有名元祖​​selectorkey​

SelectorKey = namedtuple('SelectorKey', ['fileobj', 'fd', 'events', 'data'])


此对象是一个有名元祖,可以认为是对文件对象​​fileobj​​,对应的描述符值​​fd​​,对应的事件​​events​​,附带的数据​​data​​这几个属性的封装.此对象是核心操作对象,关联了需要监控的文件对象,关联了需要​​OS​​关注的事件,保存了附带数据(其实这里就放的回调函数)

3.1 注册

def __init__(self):
super().__init__()
self._readers = set() # 使用集合处理唯一性
self._writers = set()


首先,构造函数中定义了​​_readers​​和​​_writers​​变量用于保存需要监听的文件对象的文件描述符值,并使用集合特性来处理唯一性.

def register(self, fileobj, events, data=None):
key = super().register(fileobj, events, data) if events & EVENT_READ:
self._readers.add(key.fd) if events & EVENT_WRITE:
self._writers.add(key.fd) return key


一般我们使用​​register​​作为第一个操作的函数,代表着你需要监听的文件对象,以及,当它发生你关注的事件时,你要如何处理.

此函数有​​3​​个参数,分别是文件对象,监听事件(可读为​​1​​,可写为​​2​​),附带数据.

​fileobj​​文件对象是类文件对象,与平台强相关,在​​windows​​上只能是​​socket​​,在​​linux​​上可以是任何​​linux​​支持的文件对象.

​events​​是一个​​int​​类型的值,就是​​EVENT_ERAD​​和​​EVENT_WRITE​

​data​​是附带数据,我们可以把回调函数放在这里

此函数返回的​​key​​就是一个​​selectorkey​​有名元祖

​register​​函数将用户监听的文件对象和事件注册到有名元祖中,并加入监听集合​​_readers​​和​​_writers​​中

3.2 注销

def unregister(self, fileobj):
key = super().unregister(fileobj)
self._readers.discard(key.fd)
self._writers.discard(key.fd) return key


当我们不需要监听某一个文件对象时,使用​​unregister​​注销它.这会使得它从​​_readers​​和​​_writers​​中被弹出.

3.3 查询

def select(self, timeout=None):
timeout = None if timeout is None else max(timeout, 0)
ready = [] try:
r, w, _ = self._select(self._readers, self._writers, [], timeout) except InterruptedError: return ready
r = set(r)
w = set(w) for fd in r | w:
events = 0
if fd in r:
events |= EVENT_READ if fd in w:
events |= EVENT_WRITE

key = self._key_from_fd(fd) if key:
ready.append((key, events & key.events)) return ready


这段代码描述了用户向​​OS​​发起的查询逻辑.​​select​​函数的​​timeout​​参数默认是​​None​​,这意味着默认情况下,如果没有任何一个就绪事件的发生,​​select​​调用会被永远阻塞.

​select​​函数调用底层​​select/poll/epoll​​接口,此函数在​​SelectSelector​​类和​​EpollSelector​​类中的定义有所区别,会根据​​OS​​的类型调用对应接口,​​windows​​和​​linux​​实际调用的底层接口对比如下:

用户统一调用高层select函数,此函数实际调用的接口为:# windows下使用select(SelectSelector类)r, w, _ = self._select(self._readers, self._writers, [], timeout)# linux下使用epoll(EpollSelector类)fd_event_list = self._epoll.poll(timeout, max_ev)


函数使用​​ready​​变量保存准备就绪的元祖​​(key, events)​

在​​windows​​中,一旦底层​​select​​接口返回,会得到​​3​​个列表,前两个表示可读和可写的文件对象列表,并使用集合处理为唯一性.准备就绪的元祖对象会加入​​ready​​列表中返回.如果定义了​​timeout​​不为​​None​​,且发生了超时,会返回一个空列表.

4. 别名

# Choose the best implementation, roughly:#    epoll|kqueue|devpoll > poll > select.# select() also can't accept a FD > FD_SETSIZE (usually around 1024)if 'KqueueSelector' in globals():
DefaultSelector = KqueueSelectorelif 'EpollSelector' in globals():
DefaultSelector = EpollSelectorelif 'DevpollSelector' in globals():
DefaultSelector = DevpollSelectorelif 'PollSelector' in globals():
DefaultSelector = PollSelectorelse:
DefaultSelector = SelectSelector


​selectors​​模块定义了一个别名​​DefaultSelector​​用于根据​​OS​​类型自动指向最优的接口类.

5. 总结

1 操作系统提供的​​select/poll/epoll​​接口可以用于编写事件循环,而​​selectors​​模块封装了​​select​​模块,​​select​​模块是一个低级别的模块,封装了​​select/poll/epoll/kqueue​​等接口.

2 ​​selectors​​模块中定义了有名元祖​​selectorkey​​,此对象封装了文件对象/描述符值/事件/附带数据,​​selectorkey​​为我们提供了回调的环境

3 使用​​selectors​​模块可以实现使用回调模型来完成高并发的方案.

4 (非常重要)异步回调模型,大部分事件和精力都是对回调函数的设计.回调模型使得每一个涉及IO操作的地方都需要单独分割出来作为函数,这会分割代码导致可读性下降和维护难度的上升.

5 回调函数之间的通信很困难,需要通过层层函数传递.

6 回调模型很难理解

6. 代码报错问题

1. 文件描述符数量

Traceback (most recent call last):
File "F:/projects/hello/hello.py", line 119, in <module>
loop()
File "F:/projects/hello/hello.py", line 102, in loop
events = selector.select()
File "F:\projects\hello\selectors.py", line 323, in select
r, w, _ = self._select(self._readers, self._writers, [], timeout)
File "F:\projects\hello\selectors.py", line 314, in _select
r, w, x = select.select(r, w, w, timeout)
ValueError: too many file descriptors in select()


在​​windows​​上,底层使用的是​​select​​接口,可以支持的文件描述符数量理论说是​​1024​​,实际测试描述符必须小于​​512​​(我的电脑是​​win10 64bit​​)

在​​linux​​上使用的是​​epoll​​,可以支持大于​​1024​​的文件描述符数量,不过测试发现在达到​​4000​​左右的时候也会报错。

​stack overflow​​解释1:https://stackoverflow.com/questions/31321127/too-many-file-descriptors-in-select-python-in-windows

​stack overflow​​解释2:

https://stackoverflow.com/questions/47675410/python-asyncio-aiohttp-valueerror-too-many-file-descriptors-in-select-on-win

2. 监听列表是否可以为空

Traceback (most recent call last):
File "F:/projects/hello/world.py", line 407, in <module>
loop()
File "F:/projects/hello/world.py", line 378, in loop
events = selector.select()
File "F:\projects\hello\selectors.py", line 323, in select
r, w, _ = self._select(self._readers, self._writers, [], timeout)
File "F:\projects\hello\selectors.py", line 314, in _select
r, w, x = select.select(r, w, w, timeout)
OSError: [WinError 10022] 提供了一个无效的参数。


在​​windows​​上,监听的文件对象列表不可以为空:

python异步编程--回调模型(selectors模块)_数据_02

7. 关系图


python异步编程--回调模型(selectors模块)_等待队列_03

 

  以下是一个selectors模块的服务端代码示范:



#!/usr/bin/python
#Author:sean

import selectors
import socket
#selectors模块默认会用epoll,如果你的系统中没有epoll(比如windows)则会自动使用select
sel = selectors.DefaultSelector() #生成一个select对象

def accept(sock, mask):
conn, addr = sock.accept() # Should be ready
print('accepted', conn, 'from', addr)
conn.setblocking(False) #设定非阻塞
sel.register(conn, selectors.EVENT_READ, read) #新连接注册read回调函数

def read(conn, mask):
data = conn.recv(1024) # Should be ready
if data:
print('echoing', repr(data), 'to', conn)
conn.send(data)
else:
print('closing', conn)
sel.unregister(conn)
conn.close()

sock = socket.socket()
sock.bind(('localhost', 8080))
sock.listen()
sock.setblocking(False)
sel.register(sock, selectors.EVENT_READ, accept) #把刚生成的sock连接对象注册到select连接列表中,并交给accept函数处理

while True:
events = sel.select() #默认是阻塞,有活动连接就返回活动的连接列表
#这里看起来是select,其实有可能会使用epoll,如果你的系统支持epoll,那么默认就是epoll
for key, mask in events:
callback = key.data #去调accept函数
callback(key.fileobj, mask) #key.fileobj就是readable中的一个socket连接对象