Python 之 select 模块

转载博客:python系列之 - select        深入理解python中的select模块

  I/O多路复用是在单线程模式下实现多线程的效果,实现一个多I/O并发的效果。

看一个简单socket例子:

#服务端
import socket  

sockServer = socket.socket()  
sockServer.bind(('127.0.0.1', 8001))  
sockServer.listen(5)  
  
while True:  
    conn, addr = sockServer.accept()  
    while True:  
        recvdata = conn.recv(1024)
        if recvdata:
            print(recvdata.decode())  
        else:  
            conn.close()  
            break

客户端:

import socket  
  
Client = socket.socket()  
Client.connect(('127.0.0.1', 8888))  
while True:  
    data = input("input str:")  
    Client.send(data.encode())   

  以上为一个简单的客户端发送一个输入信息给服务端的socket通信的实例,在以上的例子中,服务端是一个单线程、阻塞模式的。如何实现多客户端连接呢?我们可以使用多线程模式,这个当然没有问题。 使用多线程、阻塞socket来处理的话,代码会很直观,但是也会有不少缺陷。它很难确保线程共享资源没有问题。而且这种编程风格的程序在只有一个CPU的电脑上面效率更低。但如果一个用户开启的线程有限的情况下,比如1024个。当第1025个客户端连接是仍然会阻塞。

  有没有一种比较好的方式呢?当然有,其一是使用异步socket。这种socket只有在一些event触发时才会阻塞。相反,程序在异步socket上面执行一个动作,会立即被告知这个动作是否成功。程序会根据这个信息决定怎么继续下面的操作由于异步socket是非阻塞的,就没有必要再来使用多线程。所有的工作都可以在一个线程中完成。这种单线程模式有它自己的挑战,但可以成为很多方案不错的选择。它也可以结合多线程一起使用:单线程使用异步socket用于处理服务器的网络部分,多线程可以用来访问其他阻塞资源,比如数据库。

  Linux的2.6内核有一系列机制来管理异步socket,其中3个有对应的Python的API:select、poll和epoll。epoll和pool比select更好,因为Python程序不需要检查每一个socket感兴趣的event。相反,它可以依赖操作系统来告诉它哪些socket可能有这些event。epoll 比 pool 更好,因为它不要求操作系统每次都去检查python程序需要的所有socket感兴趣的event。而是Linux在event发生的时候会跟踪到,并在Python需要的时候返回一个列表。因此epoll对于大量(成千上万)并发socket连接,是更有效率和可扩展的机制。

异步I/O处理模型

非阻塞式I/O编程特点

  所谓阻塞方式block,顾名思义,就是进程或是线程执行到这些函数时必须等待某个事件的发生,如果事件没有发生,进程或线程就被阻塞,函数不能立即返回;

  所谓非阻塞方式non-block,就是进程或线程执行此函数时不必非要等待事件的发生,一旦执行肯定返回,以返回值的不同来反映函数的执行情况,如果事件发生则与阻塞方式相同,若事件没有发生,则返回一个代码来告知事件未发生,而进程或线程继续执行,所以效率较高。

  1、如果一个发现I/O有输入,读取的过程中,另外一个也有了输入,这时候不会产生任何反应.这就需要你的程序语句去用到select函数的时候才知道有数据输入。
  2、程序去select的时候,如果没有数据输入,程序会一直等待,直到有数据为止,也就是程序中无需循环和sleep。

Python中有一个select模块,其中提供了:select、poll、epoll三个方法,分别调用系统的 select,poll,epoll 从而实现IO多路复用。

  Windows Python:提供: select

  Mac Python:提供: select

  Linux Python:提供: select、poll、epoll

select介绍

  select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。
  select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点,事实上从现在看来,这也是它所剩不多的优点之一。
  select的一个缺点在于,单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。

  select主要用于socket通信当中,能监视我们需要的文件描述变化。select()的机制中提供一fd_set的数据结构,实际上是一long类型的数组, 每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成, 当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一Socket或文件可读或可写。主要用于Socket通信当中。

  另外,select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。

  注意:网络操作、文件操作、终端操作等均属于IO操作,对于windows只支持Socket操作,其他系统支持其他IO操作,但是无法检测 普通文件操作 自动上次读取是否已经变化。进程指定内核监听哪些文件描述符(最多监听1024个fd)的哪些事件,当没有文件描述符事件发生时,进程被阻塞;当一个或者多个文件描述符事件发生时,进程被唤醒。

当我们调用select()时:
  1、上下文切换转换为内核态
  2、将fd从用户空间复制到内核空间
  3、内核遍历所有fd,查看其对应事件是否发生
  4、如果没发生,将进程阻塞,当设备驱动产生中断或者timeout时间后,将进程唤醒,再次进行遍历
  5、返回遍历后的fd
  6、将fd从内核空间复制到用户空间

select使用语法:

  【select可操作任何对象,但对象内必须是有fileno方法(文件句柄),其内部执行的操作就是对fileno对象做的监测。】

  句柄列表11(r), 句柄列表22(w), 句柄列表33(e) = select.select(句柄序列1, 句柄序列2, 句柄序列3, 超时时间)

  参数: 可接受四个参数(前三个必须)

  返回值:三个列表

  select方法用来监视文件句柄,如果句柄发生变化,则获取该句柄。

    1、当 参数1 序列中的句柄发生可读时(accetp和read),则获取发生变化的句柄并添加到 返回值1 序列中;

    2、当 参数2 序列中含有句柄时,则将该序列中所有的 句柄 添加到 返回值2 序列中;

    3、当 参数3 序列中的句柄发生错误时,则将该发生错误的句柄添加到 返回值3 序列中;

    4、当 超时时间 未设置,则select会一直阻塞,直到监听的句柄发生变化。

    例如:当 超时时间 = 1时,那么如果监听的句柄均无任何变化,则select会阻塞 1 秒,之后返回三个空列表,如果监听的句柄有变化,则直接执行。

实例:利用select实现一个可并发的服务端

import socket
import select
 
s = socket.socket()
s.bind(('127.0.0.1',8888))
s.listen(5)
r_list = [s,]
num = 0
while True:
 rl, wl, error = select.select(r_list,[],[],10)
 num+=1
 print('counts is %s'%num)
 print("rl's length is %s"%len(rl))
 for fd in rl:
  if fd == s:
   conn, addr = fd.accept()
   r_list.append(conn)
   msg = conn.recv(200)
   conn.sendall(('first----%s'%conn.fileno()).encode())
  else:
   try:
    msg = fd.recv(200)
    fd.sendall('second'.encode())
   except ConnectionAbortedError:
    r_list.remove(fd)
 
 
s.close()
服务端
import socket
 
flag = 1
s = socket.socket()
s.connect(('127.0.0.1',8888))
while flag:
 input_msg = input('input>>>')
 if input_msg == '0':
  break
 s.sendall(input_msg.encode())
 msg = s.recv(1024)
 print(msg.decode())
 
s.close()
客户端

在服务端我们可以看到,我们需要不停的调用select, 这就意味着:

  1  当文件描述符过多时,文件描述符在用户空间与内核空间进行copy会很费时;

  2  当文件描述符过多时,内核对文件描述符的遍历也很浪费时间;

  3  select最大仅仅支持1024个文件描述符;

poll与select相差不大,唯一的区别就是连接上限没有限制!不再介绍!

epoll方法:

epoll很好的改进了select:

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

  2、epoll会在epoll_ctl时把指定的fd遍历一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd‘’

  3、epoll对文件描述符没有额外限制。

#参数
select.epoll(sizehint=-1, flags=0) 创建epoll对象
 
 
epoll.close()
Close the control file descriptor of the epoll object.关闭epoll对象的文件描述符
 
epoll.closed
True if the epoll object is closed.检测epoll对象是否关闭
 
epoll.fileno()
Return the file descriptor number of the control fd.返回epoll对象的文件描述符
 
epoll.fromfd(fd)
Create an epoll object from a given file descriptor.根据指定的fd创建epoll对象
 
epoll.register(fd[, eventmask])
Register a fd descriptor with the epoll object.向epoll对象中注册fd和对应的事件
 
epoll.modify(fd, eventmask)
Modify a registered file descriptor.修改fd的事件
 
epoll.unregister(fd)
Remove a registered file descriptor from the epoll object.取消注册
 
epoll.poll(timeout=-1, maxevents=-1)
Wait for events. timeout in seconds (float)阻塞,直到注册的fd事件发生,会返回一个dict,格式为:{(fd1,event1),(fd2,event2),……(fdn,eventn)}


#事件
EPOLLIN Available for read 可读 状态符为1
EPOLLOUT Available for write 可写 状态符为4
EPOLLPRI Urgent data for read
EPOLLERR Error condition happened on the assoc. fd 发生错误 状态符为8
EPOLLHUP Hang up happened on the assoc. fd 挂起状态
EPOLLET Set Edge Trigger behavior, the default is Level Trigger behavior 默认为水平触发,设置该事件后则边缘触发
EPOLLONESHOT Set one-shot behavior. After one event is pulled out, the fd is internally disabled
EPOLLRDNORM Equivalent to EPOLLIN
EPOLLRDBAND Priority data band can be read.
EPOLLWRNORM Equivalent to EPOLLOUT
EPOLLWRBAND Priority data may be written.
EPOLLMSG Ignored.

一些参数的解释:

水平触发和边缘触发:

  Level_triggered(水平触发,有时也称条件触发):当被监控的文件描述符上有可读写事件发生时,epoll.poll()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll.poll()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你!!!如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率!!! 优点很明显:稳定可靠

  Edge_triggered(边缘触发,有时也称状态触发):当被监控的文件描述符上有可读写事件发生时,epoll.poll()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll.poll()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符!!!缺点:某些条件下不可靠。

select  poll   epoll比较总结

1 特点
select   select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:
1 单个进程可监视的fd数量被限制
2 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大
3 对socket进行扫描时是线性扫描
poll   poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:
大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
epoll   epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就需态,并且只会通知一次。
在前面说到的复制问题上,epoll使用mmap减少复制开销。
还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该
fd,epoll_wait便可以收到通知
2 支持一个进程所能打开的最大连接数
select   单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是32*32,同理64位机器上FD_SETSIZE为32*64),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。
poll   poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的
epoll   虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接
3 FD剧增后带来的IO效率问题
select   因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。
poll   同上
epoll   因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。
4 消息传递方式
select   内核需要将消息传递到用户空间,都需要内核拷贝动作。
poll   同上
epoll   epoll通过内核和用户空间共享一块内存来实现的。

 

示例说明:

1、下面是对开始的socket例子进行的改造:

"""select 详细解释,用线程的IO多路复用实现一个读写分离的、支持多客户端的连接请求  
"""  
import socket  
import queue  
from select import select  
  
SERVER_IP = ('127.0.0.1', 9999)  
  
# 保存客户端发送过来的消息,将消息放入队列中  
message_queue = {}  
input_list = []  
output_list = []  
  
if __name__ == "__main__":  
    server = socket.socket()  
    server.bind(SERVER_IP)  
    server.listen(10)  
    # 设置为非阻塞  
    server.setblocking(False)  
  
    # 初始化将服务端加入监听列表  
    input_list.append(server)  
  
    while True:  
        # 开始 select 监听,对input_list中的服务端server进行监听  
        stdinput, stdoutput, stderr = select(input_list, output_list, input_list)  
  
        # 循环判断是否有客户端连接进来,当有客户端连接进来时select将触发  
        for obj in stdinput:  
            # 判断当前触发的是不是服务端对象, 当触发的对象是服务端对象时,说明有新客户端连接进来了  
            if obj == server:  
                # 接收客户端的连接, 获取客户端连接对象和客户端地址信息  
                conn, addr = server.accept()  
                print("Client {0} connected! ".format(addr))#打印最新连接的客户端地址 
                # 将客户端对象也加入到监听的列表中, 当客户端发送消息时 select 将触发  
                input_list.append(conn)  
                # 为连接的客户端单独创建一个消息队列,用来保存客户端发送的消息  
                message_queue[conn] = queue.Queue()  
  
            else:  
                # 由于客户端连接进来时服务端接收客户端连接请求,将客户端加入到了监听列表中(input_list),客户端发送消息将触发  
                # 所以判断是否是客户端对象触发  
                try:  
                    recv_data = obj.recv(1024)  
                    # 客户端未断开  
                    if recv_data:  
                        print("received {0} from client {1}".format(recv_data.decode(), addr))  
                        # 将收到的消息放入到各客户端的消息队列中  
                        message_queue[obj].put(recv_data)  
  
                        # 将回复操作放到output列表中,让select监听  
                        if obj not in output_list:  
                            output_list.append(obj)  
  
                except ConnectionResetError:  
                    # 客户端断开连接了,将客户端的监听从input列表中移除  
                    input_list.remove(obj)  
                    # 移除客户端对象的消息队列  
                    del message_queue[obj]  
                    print("\n[input] Client  {0} disconnected".format(addr))  
  
        # 如果现在没有客户端请求,也没有客户端发送消息时,开始对发送消息列表进行处理,是否需要发送消息  
        for sendobj in output_list:  
            try:  
                # 如果消息队列中有消息,从消息队列中获取要发送的消息  
                if not message_queue[sendobj].empty():  
                    # 从该客户端对象的消息队列中获取要发送的消息  
                    send_data = message_queue[sendobj].get()  
                    sendobj.sendall(send_data)  
                else:  
                    # 将监听移除等待下一次客户端发送消息  
                    output_list.remove(sendobj)  
  
            except ConnectionResetError:  
                # 客户端连接断开了  
                del message_queue[sendobj]  
                output_list.remove(sendobj)  
                print("\n[output] Client  {0} disconnected".format(addr))  
select服务端使用示例

2、IO多路复用--使用socket模拟多线程,并实现读写分离

#使用socket模拟多线程,使多用户可以同时连接
import socket
import select

sk1 = socket.socket()
sk1.bind(('0.0.0.0', 8001))
sk1.listen()

inputs = [sk1, ]
outputs = []
message_dict = {}

while True:
    r_list, w_list, e_list = select.select(inputs, outputs, inputs, 1)
    print('正在监听的socket对象%d' % len(inputs))
    print(r_list)
    for sk1_or_conn in r_list:
        #每一个连接对象
        if sk1_or_conn == sk1:
            # 表示有新用户来连接
            conn, address = sk1_or_conn.accept()
            inputs.append(conn)
            message_dict[conn] = []
        else:
            # 有老用户发消息了
            try:
                data_bytes = sk1_or_conn.recv(1024)
            except Exception as ex:
                # 如果用户终止连接
                inputs.remove(sk1_or_conn)
            else:
                data_str = str(data_bytes, encoding='utf-8')
                message_dict[sk1_or_conn].append(data_str)
                outputs.append(sk1_or_conn)

    #w_list中仅仅保存了谁给我发过消息
    for conn in w_list:
        recv_str = message_dict[conn][0]
        del message_dict[conn][0]
        conn.sendall(bytes(recv_str+'', encoding='utf-8'))
        outputs.remove(conn)

    for sk in e_list:

        inputs.remove(sk)
服务端
import socket

obj = socket.socket()
obj.connect(('127.0.0.1', 8001))

while True:
    inp = input('>>>')
    obj.sendall(bytes(inp, encoding='utf-8'))
    ret = str(obj.recv(1024),encoding='utf-8')
    print(ret)

obj.close()
客户端

3、epoll实现实例

#!/usr/bin/env python  
import select  
import socket  
  
response = b''  
  
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)  
serversocket.bind(('0.0.0.0', 8080))  
serversocket.listen(1)  
# 因为socket默认是阻塞的,所以需要使用非阻塞(异步)模式。  
serversocket.setblocking(0)  
  
# 创建一个epoll对象  
epoll = select.epoll()  
# 在服务端socket上面注册对读event的关注。一个读event随时会触发服务端socket去接收一个socket连接  
epoll.register(serversocket.fileno(), select.EPOLLIN)  
  
try:  
    # 字典connections映射文件描述符(整数)到其相应的网络连接对象  
    connections = {}  
    requests = {}  
    responses = {}  
    while True:  
        # 查询epoll对象,看是否有任何关注的event被触发。参数“1”表示,我们会等待1秒来看是否有event发生。  
        # 如果有任何我们感兴趣的event发生在这次查询之前,这个查询就会带着这些event的列表立即返回  
        events = epoll.poll(1)  
        # event作为一个序列(fileno,event code)的元组返回。fileno是文件描述符的代名词,始终是一个整数。  
        for fileno, event in events:  
            # 如果是服务端产生event,表示有一个新的连接进来  
            if fileno == serversocket.fileno():  
                connection, address = serversocket.accept()  
                print('client connected:', address)  
                # 设置新的socket为非阻塞模式  
                connection.setblocking(0)  
                # 为新的socket注册对读(EPOLLIN)event的关注  
                epoll.register(connection.fileno(), select.EPOLLIN)  
                connections[connection.fileno()] = connection  
                # 初始化接收的数据  
                requests[connection.fileno()] = b''  
  
            # 如果发生一个读event,就读取从客户端发送过来的新数据  
            elif event & select.EPOLLIN:  
                print("------recvdata---------")  
                # 接收客户端发送过来的数据  
                requests[fileno] += connections[fileno].recv(1024)  
                # 如果客户端退出,关闭客户端连接,取消所有的读和写监听  
                if not requests[fileno]:  
                    connections[fileno].close()  
                    # 删除connections字典中的监听对象  
                    del connections[fileno]  
                    # 删除接收数据字典对应的句柄对象  
                    del requests[connections[fileno]]  
                    print(connections, requests)  
                    epoll.modify(fileno, 0)  
                else:  
                    # 一旦完成请求已收到,就注销对读event的关注,注册对写(EPOLLOUT)event的关注。写event发生的时候,会回复数据给客户端  
                    epoll.modify(fileno, select.EPOLLOUT)  
                    # 打印完整的请求,证明虽然与客户端的通信是交错进行的,但数据可以作为一个整体来组装和处理  
                    print('-' * 40 + '\n' + requests[fileno].decode())  
  
            # 如果一个写event在一个客户端socket上面发生,它会接受新的数据以便发送到客户端  
            elif event & select.EPOLLOUT:  
                print("-------send data---------")  
                # 每次发送一部分响应数据,直到完整的响应数据都已经发送给操作系统等待传输给客户端  
                byteswritten = connections[fileno].send(requests[fileno])  
                requests[fileno] = requests[fileno][byteswritten:]  
                if len(requests[fileno]) == 0:  
                    # 一旦完整的响应数据发送完成,就不再关注写event  
                    epoll.modify(fileno, select.EPOLLIN)  
  
            # HUP(挂起)event表明客户端socket已经断开(即关闭),所以服务端也需要关闭。  
            # 没有必要注册对HUP event的关注。在socket上面,它们总是会被epoll对象注册  
            elif event & select.EPOLLHUP:  
                print("end hup------")  
                # 注销对此socket连接的关注  
                epoll.unregister(fileno)  
                # 关闭socket连接  
                connections[fileno].close()  
                del connections[fileno]  
finally:  
    # 打开的socket连接不需要关闭,因为Python会在程序结束的时候关闭。这里显式关闭是一个好的代码习惯  
    epoll.unregister(serversocket.fileno())  
    epoll.close()  
    serversocket.close() 
epoll示例
posted @ 2017-08-30 23:44  细雨蓝枫  阅读(601)  评论(0编辑  收藏  举报