selectors ---高级I/O复用模块详解

文章目录

    • 一、前言
      • 1.I/O多路复用
      • 2.select、poll、epoll的三者的区别:
        • ①select
        • ②poll
        • ③epoll
      • 3.水平触发和边缘触发
        • ①水平触发
        • ②边缘触发
      • 4.select和epoll的特点
    • 二、概述
    • 三、selectors库的一些使用方法
      • 1.selectors库的一些常用类和方法
      • 2.selectors库的总结
      • 3.使用selectors构建传统聊天室服务端

一、前言

1.I/O多路复用

- 大多数操作系统都支持select和poll
- Linux2.5+支持epoll
- BSD、Mac支持kqueue
- Solaris支持/dev/poll
- Windows支持IOCP

2.select、poll、epoll的三者的区别:

①select

目前几乎支持所有的平台。
默认单个进程能够监视的文件描述符的数量存在最大限制,在linux上默认只支持1024个socket
可以通过修改宏定义或重新编译内核(修改系统最大支持的端口数)的方式提升这一限制。
内核准备好数据后通知用户有数据了,但不告诉用户是哪个连接有数据,用户只能通过轮询的方式来获取数据。

假定select让内核监视100个socket连接,当有1个连接有数据后,内核就通知用户100个连接中有数据了,但是不告诉用户是哪个连接有数据了,此时用户只能通过轮询的方式一个个去检查然后获取数据。
这里是假定有100个socket连接,那么如果有上万个,上十万个呢?那你就得轮询上万次,上十万次,而你所取的结果仅仅就那么1个。这样就会浪费很多没用的开销。

只支持水平触发

每次调用select,都需要把fd(文件描述符)集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
同时每次调用select都需要在内核遍历所传递过来的所有fd,这个开销在fd很大时也会很大。

②poll

与select没有本质上的差别,仅仅是没有了最大文件描述符数量的限制

同样只支持水平触发
只是一个过渡版本,很少用。

③epoll

Linux2.6开始才出现的epoll,具备了select和poll的一切优先,是公认性能最好的多路I/O就绪通知方法

没有最大文件描述符数量的限制
同时支持水平触发和边缘触发

不支持windows平台

内核准备好数据以后会通知用户哪个连接有数据了。
I/O效率不随着fd数目的增加而线性下降。
使用mmap加速内核与用户空间的消息传递。

3.水平触发和边缘触发

①水平触发

将就绪的文件描述符告诉进程后,如果进程没有对其进行I/O操作,那么下次epoll时将再次报告这些文件描述符,称为水平触发。

②边缘触发

只告诉进程哪些文件描述符刚刚变为就绪状态,它只说一遍,如果没有采取行动,那么它将不会再次告知,这种方式称为边缘触发。

4.select和epoll的特点

1.select通过select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述从而进行后续的读写操作。
由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,这也浪费了一定的开销。

2.epoll只告知那些就绪的文件描述符,而且当我们调用epoll_wait获得就绪文件描述符时,返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一个数组中一次取得相应数量的文件描述符即可,这里也是用了内存映射技术(mmap),这样便彻底省掉了这些描述符在系统调用时复制的开销。
另一个本质的改进在意epoll采用基于时间的就绪通知方式。在select/poll中,进程只有在调用一定的方法之后,内核才对所有监视的文件描述符进行扫描,而epoll事件事先通过epoll_ctl来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait时变得到通知

二、概述

它封装了I/O对路复用中的select和epoll,能够更快,更方便的实现多并发效果。

它定义了一个BaseSelector抽象基类,以及几个具体实现(KqueueSelector, EpollSelector…),可以等待多核文件I/O的就绪通知。

三、selectors库的一些使用方法

1.selectors库的一些常用类和方法

类的层次结构

BaseSelector
+-- SelectSelector
+-- PollSelector
+-- EpollSelector
+-- DevpollSelector
+-- KqueueSelector

1.1 创建selector对象
class selectors.DefaultSelector是当前平台上可用的系统调用最有效的实现。

if 'KqueueSelector' in globals():
    DefaultSelector = KqueueSelector
elif 'EpollSelector' in globals():
    DefaultSelector = EpollSelector
elif 'DevpollSelector' in globals():
    DefaultSelector = DevpollSelector
elif 'PollSelector' in globals():
    DefaultSelector = PollSelector
else:
    DefaultSelector = SelectSelector

注意,由于这个库没有实现windows下的IOCP, 所以windows下只能退化为select

创建一个SelectSelector实例对象

In [1]: import selectors

In [2]: selector = selectors.DefaultSelector()
   ...: 

In [3]: selector
Out[3]: <selectors.SelectSelector at 0x9cd5d30>

1.2 register对I/O事件的注册

源码

class SelectSelector(_BaseSelectorImpl):
    def __init__(self):
        super().__init__()
        self._readers = set()
        self._writers = set()

    def register(self, fileobj, events, data=None):    -> selectorKey对象
        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为selector实例对象注册一个事件,目标为fileobj,监视它的I/O

register的三个参数

参数 含义
fileobj 被监视的文件对象,例如socket对象
events 该文件对象必须等待的I/O事件。
data 与此文件对象相关联的可选的opaque数据。就是这个参数在关注I/O事件产生后让操作系统select来干什么。例如关联用来存储每个客户端的会话ID,关联方法等操作。

参数events的几个常数形式解释

常数 意义
EVENT_READ 可读的
EVENT_WRITE 可写的

register的返回值

源码

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

key = SelectorKey(fileobj, self._fileobj_lookup(fileobj), events, data)

返回值这个key是一个工厂方法:具名元组,有下面四个属性

属性 含义
fileobj 注册的文件对象
fd 文件描述符
events 等待上面的文件描述符的文件对象的I/O事件,为读、写、读和写
data 注册时关联的一些操作对象

代码示范

In [4]: import socket

In [5]: sock = socket.socket()

In [6]: sock.bind(("", 9999)) # 绑定localhost和端口9999

In [7]: sock.listen()

In [8]: def accept():
   ...:     client, addr = sock.accept()

In [9]: selector.register(sock, selectors.EVENT_READ, accept)
Out[9]: SelectorKey(fileobj=<socket.socket fd=1376, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0>, fd=1376, events=1, data=<function accept at 0x0000000009CC3BF8>)

注意看上面的返回值SelectKey

1.3 select进行监视

events = selector.select()

events是一个二元组列表,里面保存着(SelectorKey, mask)这样的二元组,都是就绪状态的。

因为默认是水平触发,所以key处于就绪状态,但是如果没有对其进行I/O操作的话,他会一直报告这些文件描述符。所以一般需要采用callback函数对已经就绪的事件进行处理

for key, mask in events:
    callback = key.data
    callback(*args, **kwargs)

2.selectors库的总结

1.构建一个selector实例对象直接使用selectors.DefaultSelector(),实例对象selector的get_map的返回值是一个文件对象到选择键的映射类型对象(fd:key)

2.执行register方法都会向_key_from_fd中添加一对(key,fd),执行unregister则相反。

3.将关注的(register)的I/O操作告诉select并系统调用,进程阻塞,内核监视select关注的文件描述符,被关注的任何一个fd对应的I/O准备好了数据,select返回,在使用callback调用,避免水平触发。

4.内核监视select,用户空间并不会开辟其他线程,也就是I/O多路复用是操作系统来监管的,交给它好了。

selectors ---高级I/O复用模块详解_第1张图片

5.千万注意I/O操作的就绪态和线程的就绪态区分开。。。

3.使用selectors构建传统聊天室服务端

import threading
import selectors  # 封装了select和epoll
import socket
import multiprocessing

class ChatServer:
    def __init__(self, ip="127.0.0.1", port=9997):
        self.addr = ip, port
        self.sock = socket.socket()  # 创建socket对象
        self.sock.setblocking(False)  # 设置为非阻塞
        self.event = threading.Event()
        self.s = selectors.DefaultSelector()  # 默认选取当前平台最优selector, 返回值为SelectorKey

    def start(self):
        self.sock.bind(self.addr)
        self.sock.listen()
        key = self.s.register(self.sock, selectors.EVENT_READ, self.accept)
        threading.Thread(target=self.select, name="select", daemon=True).start()  # 设置为daemon线程,主线程退出,这个线程也会退出
        self.acceptkey = key

    def select(self):
        while not self.event.is_set():  # WDNMD!!! 这里你在做什么????!!!注意是event.is_set(),不是直接event!!!
            events = self.s.select()  # windows下默认启用select, linux下默认启用epoll,,关注的是刚才已经被注册了的IO操作,如果就绪,也就是datagram已经来到,
            for key, mask in events:
                callback = key.data
                callback(key.fileobj)

    def accept(self, sock:socket.socket):
        client, addr = sock.accept()
        print("已连接", addr)
        client.setblocking(False)  # 设置为非阻塞
        self.s.register(client, selectors.EVENT_READ, self.recv)

    def recv(self, client:socket.socket):
        data = client.recv(1024)
        if data == b"quit" or data == b"":
            print("有人退出连接")
            self.s.unregister(client)
            client.close()
            return
        for key in self.s.get_map().values():
            # if key.data == self.recv:
            #     key.fileobj.send(data)
            if self.acceptkey != key:
                key.fileobj.send(data)

    def stop(self):  #退出意味着全面关闭与unregister刚才添加到
        self.event.set()
        l = []
        for i in self.s.get_map().values(): # get_map方法得到实际上是_fd_to_key的一种mapping映射类型数据结构
            l.append(i.fileobj)
        for fj in l:
            self.s.unregister(fj) # unregister里面涉及到discard方法
        self.s.close()
        self.sock.close()


def main():
    cs = ChatServer()
    cs.start()
    while True:
        cmd = input(">>").strip()
        if cmd == "quit":
            cs.stop()
            break
        print(threading.enumerate())
        print(multiprocessing.active_children())


if __name__ == "__main__":
    main()

以上代码仅实现主要功能,细节并未处理。


对于IO的其他理解请参考python之多路复用

Python官方文档selectors—高级I/O复用库

你可能感兴趣的:(Python核心部分)