20、第七周-网络编程 - IO多路复用及select、poll、epoll模式详解

一、select、poll、epoll模式对比

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

1、IO多路复用的三种方式,对比:

  1、select--->效率最低,但有最大描述符限制,在linux为1024。

  2、poll  ---->和select一样,但没有最大描述符限制

  3、epoll  --->效率最高,没有最大描述符限制,支持水平触发与边缘触发

2、IO多路复用的优势同时可以监听多个连接,用的是单线程,利用空闲时间实现并发。

3、三者之间的区别:

  • Linux系统支持: select、poll、epoll
  • Windows系统支持:select
  • Mac系统支持:select、poll

二、select\poll\epoll在Python中的应用

I/O多路复用:

  • I/O多路复用是用于提升效率,单个进程可以同时监听多个网络连接IO
  • I/O是指Input/Output
  • I/O多路复用,通过一种机制,可以监视多个文件描述符,一旦描述符就绪(读就绪和写就绪),能通知程序进行相应的读写操作。
  • I/O多路复用避免阻塞在io上,原本为多进程或多线程来接收多个连接的消息变为单进程或单线程保存多个socket的状态后轮询处理。

1、select 的应用

  select是通过系统调用来监视一组由多个文件描述符组成的数组,通过调用select()返回结果,数组中就绪的文件描述符会被内核标记出来,然后进程就可以获得这些文件描述符,然后进行相应的读写操作

select的实际执行过程如下:

  • select需要提供要监控的数组,然后由用户态拷贝到内核态
  • 内核态线性循环监控数组,每次都需要遍历整个数组
  • 内核发现文件描述符状态符合操作结果,将其返回

所以对于我们监控的socket都要设置为非阻塞的,只有这样才能保证不会被阻塞。

优点:

  • 基本各个平台都支持

缺点:

  • 每次调用select,都需要把fd集合由用户态拷贝到内核态,在fd多的时候开销会很大
  • 单个进程能够监控的fd数量存在最大限制,因为其使用的数据结构是数组。
  • 每次select都是线性遍历整个数组,当fd很大的时候,遍历的开销也很大

Python使用select

语法:r, w, e = select.select( rlist, wlist, errlist [,timeout] )

rlist,wlist和errlist均是waitable object; 都是文件描述符,就是一个整数,或者一个拥有返回文件描述符的函数fileno()的对象。解析如下:

  • rlist: 等待读就绪的文件描述符数组
  • wlist: 等待写就绪的文件描述符数组
  • errlist: 等待异常的数组

在linux下这三个列表可以是空列表,但是在windows上不行

  • 当rlist数组中的文件描述符发生可读时(调用accept或者read函数),则获取文件描述符并添加到r数组中。
  • 当wlist数组中的文件描述符发生可写时,则获取文件描述符添加到w数组中
  • 当errlist数组中的文件描述符发生错误时,将会将文件描述符添加到e队列中

当超时时间没有设置时,如果监听的文件描述符没有任何变化,将会一直阻塞到发生变化为止。
当超时时间设置为1时,如果监听的描述符没有变化,则select会阻塞1秒,之后返回三个空列表。 如果由变化,则直接执行并返回。

举例如下:

服务端:(select 单线程支持多并发案例)

import select
import queue
import socket

server = socket.socket()
server.bind(("localhost",10002))
server.listen(5)

server.setblocking(False) #不阻塞

inputs = [server,]
outputs = []
msg_dic = {}

while True:
    readable,writeable,exceptional = select.select(inputs,outputs,inputs)
    #1、inputs让内核监测5个链接,如果有一个活动就返回,全部放到inputs。
    #2、outputs是内核监测有异常的线程,
    #3、inputs 处理异常的链接,看那些是断了,重新监听。
    print(readable,writeable,exceptional)

    for r in readable:
        if r is server:#代表来了个新链接
            conn,addr = server.accept()
            print("来了新链接",addr)
            inputs.append(conn) #是因为这个新建的链接还没发数据过来,现在接收程序就报错,所以想实现这个客户端发数据过来
    #server端能知道,就需要让select再次监测
            msg_dic[conn] = queue.Queue() #初始化一个队列,后面存要返回给这个客户端的数据。
        else:
            data = r.recv(1024)
            print("收到数据",data)
            msg_dic[r].put(data)
            outputs.append(r) #放入返回的连接队列里

    for w in writeable: #要返回给客户端的连接列表
        data_to_client = msg_dic[w].get()
        w.send(data_to_client)
        outputs.remove(w) #确保下次循环的时候writeable,不返回已经处理完的连接

    for e in exceptional: #删除数据,避免重复运行。
        if  e in outputs:
            outputs.remove(e)
        inputs.remove(e)
        del msg_dic[e]

 客户端:

import socket
#客户端
client = socket.socket()  #声明socket 类型,同时生成socket链接对象
client.connect(('127.0.0.1',10002))

while True:
    msg = input(">>:").strip()
    if len(msg) == 0:continue
    client.send(msg.encode("utf-8"))
    data = client.recv(10240)
    print ("recv:",data.decode())

client.close()

2、poll的应用

  poll本质上与select基本相同,只不过监控的最大连接数上相较于select没有了限制,因为poll使用的数据结构是链表,而select使用的是数组,数组是要初始化长度大小的,且不能改变。

poll原理:

  • 将fd列表,由用户态拷贝到内核态
  • 内核态遍历,发现fd状态变为就绪后,返回fd列表

poll状态:

  • POLLIN 有数据读取
  • POLLPRT 有数据紧急读取
  • POLLOUT 准备输出:输出不会阻塞
  • POLLERR 某些错误情况出现
  • POLLHUP 挂起
  • POLLNVAL 无效请求:描述无法打开

 优点:

  • 跨平台使用

缺点:

  • 每次调用select,都需要把fd集合由用户态拷贝到内核态,在fd多的时候开销会很大
  • 每次select都是线性遍历整个列表,当fd很大的时候,遍历的开销也很大

python使用poll方法:

  • register,将要监控的文件描述符注册到poll中,并添加监控的事件类型
  • unregister,注销文件描述符监控
  • modify, 修改文件描述符监控事件类型
  • poll([timeout]),轮训注册监控的文件描述符,返回元祖列表,元祖内容是一个文件描述符及监控类型( POLLIN,POLLOUT等等),如果设置了timeout,则会阻塞timeout秒,然后返回控列表,如果没有设置timeout 微秒,则会阻塞到有返回值为止

举例:

服务端:

import select
import socket
import datetime

server_sock =  socket.socket()
server_sock.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
# 需要设置socket选项时,需要先将socketlevel设置为SOL_SOCKET  SOL=socket option level
# SO_REUSEADDR代表重用地址reuse addr

server_sock.bind(("localhost",10005))
server_sock.listen(5)
server_sock.setblocking(False) #设置为非阻塞

poll = select.poll()
poll.register(server_sock,select.POLLIN)

connections = {}

#遍历被监控的文件描述符
while True:
    #print(datetime.datetime.now())

    for fd,envent in poll.poll(100):
        if envent == select.POLLIN:
            if fd == server_sock.fileno():
                #如果是当前sock,则接收请求
                conn,addr = server_sock.accept()
                poll.register(conn.fileno(),select.POLLIN)
                connections[conn.fileno()] = conn
            else:
                conn = connections[fd]
                data = conn.recv(1024)
                if data:
                    print("%s accept %s" % (fd,data))
                    poll.modify(fd,select.POLLIN)
        else:
            conn = connections[fd]
            try:
                conn.send(b"Hello,%d" % fd)
                print("conn>>:",conn)
            finally:
                poll.unregister(conn)
                connections.pop(fd)
                conn.close

客户端:

import socket
#客户端
client = socket.socket()  #声明socket 类型,同时生成socket链接对象
client.connect(('127.0.0.1',10005))

while True:
    msg = input(">>:").strip()
    if len(msg) == 0:continue
    client.send(msg.encode("utf-8"))
    data = client.recv(1024)
    print ("recv:",data.decode())

client.close()

3、epoll的应用

epoll相当于是linux内核支持的方法,而epoll主要是解决select,poll的一些缺点

数组长度限制:
  解决方案:fd上限是最大可以打开文件的数目,具体数目可以查看/proc/sys/fs/file-max。一般会和内存有关

需要每次轮询将数组全部拷贝到内核态:
  解决方案:每次注册事件的时候,会把fd拷贝到内核态,而不是每次poll的时候拷贝,这样就保证每个fd只需要拷贝一次。

每次遍历都需要列表线性遍历:
  解决方案:不再采用遍历的方案,给每个fd指定一个回调函数,fd就绪时,调用回调函数,这个回调函数会把fd加入到就绪的fd列表中,所以epoll只需要遍历就绪的list即可。

epoll操作过程

epoll操作过程需要三个接口:

int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

A、int epoll_create(int size);
  创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
  当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

B、int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数是对指定描述符fd执行op操作。
- epfd:是epoll_create()的返回值。
- op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
- fd:是需要监听的fd(文件描述符)
- epoll_event:是告诉内核需要监听什么事

C、int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents个事件。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

 举例:

服务端:

#_*_coding:utf-8_*_
import socket, logging
import select, errno

logger = logging.getLogger("network-server")

def InitLog():
    logger.setLevel(logging.DEBUG)

    fh = logging.FileHandler("network-server.log")
    fh.setLevel(logging.DEBUG)
    ch = logging.StreamHandler()
    ch.setLevel(logging.ERROR)

    formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
    ch.setFormatter(formatter)
    fh.setFormatter(formatter)

    logger.addHandler(fh)
    logger.addHandler(ch)


if __name__ == "__main__":
    InitLog()

    try:
        # 创建 TCP socket 作为监听 socket
        listen_fd = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
    except socket.error as  msg:
        logger.error("create socket failed")

    try:
        # 设置 SO_REUSEADDR 选项
        listen_fd.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    except socket.error as  msg:
        logger.error("setsocketopt SO_REUSEADDR failed")

    try:
        # 进行 bind -- 此处未指定 ip 地址,即 bind 了全部网卡 ip 上
        listen_fd.bind(('', 2003))
    except socket.error as  msg:
        logger.error("bind failed")

    try:
        # 设置 listen 的 backlog 数
        listen_fd.listen(10)
    except socket.error as  msg:
        logger.error(msg)

    try:
        # 创建 epoll 句柄
        epoll_fd = select.epoll()
        # 向 epoll 句柄中注册 监听 socket 的 可读 事件
        epoll_fd.register(listen_fd.fileno(), select.EPOLLIN)
    except select.error as  msg:
        logger.error(msg)

    connections = {}
    addresses = {}
    datalist = {}
    while True:
        # epoll 进行 fd 扫描的地方 -- 未指定超时时间则为阻塞等待
        epoll_list = epoll_fd.poll()

        for fd, events in epoll_list:
            # 若为监听 fd 被激活
            if fd == listen_fd.fileno():
                # 进行 accept -- 获得连接上来 client 的 ip 和 port,以及 socket 句柄
                conn, addr = listen_fd.accept()
                logger.debug("accept connection from %s, %d, fd = %d" % (addr[0], addr[1], conn.fileno()))
                # 将连接 socket 设置为 非阻塞
                conn.setblocking(0)
                # 向 epoll 句柄中注册 连接 socket 的 可读 事件
                epoll_fd.register(conn.fileno(), select.EPOLLIN | select.EPOLLET)
                # 将 conn 和 addr 信息分别保存起来
                connections[conn.fileno()] = conn
                addresses[conn.fileno()] = addr
            elif select.EPOLLIN & events:
                # 有 可读 事件激活
                datas = ''
                while True:
                    try:
                        # 从激活 fd 上 recv 10 字节数据
                        data = connections[fd].recv(10)
                        # 若当前没有接收到数据,并且之前的累计数据也没有
                        if not data and not datas:
                            # 从 epoll 句柄中移除该 连接 fd
                            epoll_fd.unregister(fd)
                            # server 侧主动关闭该 连接 fd
                            connections[fd].close()
                            logger.debug("%s, %d closed" % (addresses[fd][0], addresses[fd][1]))
                            break
                        else:
                            # 将接收到的数据拼接保存在 datas 中
                            datas += data
                    except socket.error as  msg:
                        # 在 非阻塞 socket 上进行 recv 需要处理 读穿 的情况
                        # 这里实际上是利用 读穿 出 异常 的方式跳到这里进行后续处理
                        if msg.errno == errno.EAGAIN:
                            logger.debug("%s receive %s" % (fd, datas))
                            # 将已接收数据保存起来
                            datalist[fd] = datas
                            # 更新 epoll 句柄中连接d 注册事件为 可写
                            epoll_fd.modify(fd, select.EPOLLET | select.EPOLLOUT)
                            break
                        else:
                            # 出错处理
                            epoll_fd.unregister(fd)
                            connections[fd].close()
                            logger.error(msg)
                            break
            elif select.EPOLLHUP & events:
                # 有 HUP 事件激活
                epoll_fd.unregister(fd)
                connections[fd].close()
                logger.debug("%s, %d closed" % (addresses[fd][0], addresses[fd][1]))
            elif select.EPOLLOUT & events:
                # 有 可写 事件激活
                sendLen = 0
                # 通过 while 循环确保将 buf 中的数据全部发送出去
                while True:
                    # 将之前收到的数据发回 client -- 通过 sendLen 来控制发送位置
                    sendLen += connections[fd].send(datalist[fd][sendLen:])
                    # 在全部发送完毕后退出 while 循环
                    if sendLen == len(datalist[fd]):
                        break
                # 更新 epoll 句柄中连接 fd 注册事件为 可读
                epoll_fd.modify(fd, select.EPOLLIN | select.EPOLLET)
            else:
                # 其他 epoll 事件不进行处理
                continue

  注:在实际环境中,底层的select\poll\epoll 用得都比较少,后面会讲到一个模块:selectors 。selectors模块已经把 select、poll、epoll 进行封装。只有在做游戏开发的时候epoll用的才比较频繁。 

三、IO多路复用两种触发模式

IO多路复用中的两种触发方式:

  水平触发:如果文件描述符已经就绪可以非阻塞的执行IO操作了,此时会触发通知.允许在任意时刻重复检测IO的状态, 没有必要每次描述符就绪后尽可能多的执行IO。select,poll就属于水平触发。
  边缘触发:如果文件描述符自上次状态改变后有新的IO活动到来,此时会触发通知.在收到一个IO事件通知后要尽可能多的执行IO操作,因为如果在一次通知中没有执行完IO那么就需要等到下一次新的IO活动到来才能获取到就绪的描述符,信号驱动式IO就属于边缘触发。

  epoll:即可以采用水平触发,也可以采用边缘触发。

 1、水平触发:

 只有高电频或低电频的时候才触发

  • 1-----高电频---触发
  • 0-----低电频---不触发

 举例:

服务端:

#水平触发
import socket
import select
sk=socket.socket()
sk.bind(("127.0.0.1",9904))
sk.listen(5)

while True:
    r,w,e=select.select([sk,],[],[],5)  #input输入列表,output输出列表,erron错误列表,5: 是监听5秒
    for i in r:   #[sk,]
        print("hello")

    print('>>>>>>')

客户端:

import socket

sk=socket.socket()

sk.connect(("127.0.0.1",9904))

while 1:
    inp=input(">>").strip()
    sk.send(inp.encode("utf8"))
    data=sk.recv(1024)
    print(data.decode("utf8"))

2、边缘触发

  • 1---------高电频--------触发
  • 0---------低电频--------触发

 IO多路复用优势同时可以监听多个连接

举例:select可以监控多个对象

服务端:

#优势
import socket
import select
sk=socket.socket()
sk.bind(("127.0.0.1",9904))
sk.listen(5)
inp=[sk,]

while True:
    r,w,e=select.select(inp,[],[],5)  #[sk,conn],5是每隔几秒监听一次

    for i in r:   #[sk,]
        conn,add=i.accept()  #发送系统调用
        print(conn)
        print("hello")
        inp.append(conn)
        # conn.recv(1024)
    print('>>>>>>')

客户端:

import socket

sk=socket.socket()

sk.connect(("127.0.0.1",9904))

while 1:
    inp=input(">>").strip()
    sk.send(inp.encode("utf8"))
    data=sk.recv(1024)
    print(data.decode("utf8"))

猜你喜欢

转载自www.cnblogs.com/chen170615/p/8932188.html