I/O多路复用
I/O多路复用
I/O多路复用机制是指一个线程兼顾地同时处理多个IO流,详细来说就是在只有一个线程的i情况下,I/O多路复用允许内核中同时存在多个监听套接字(listening socket)和已连接套接字(connected socket),内核会持续监听这些套接字,一旦接收到某套接字的连接请求或数据等,就会将这个事件交给主线程去处理,不同于一个线程阻塞的监听等待一个套接字,I/O多路复用的方式实现了一个线程同时可以处理多个IO流的效果
常见的I/O多路复用方式有select/poll和epoll,它们都是I/O多路复用,但各有不同的实现方式,windows上只能使用select,MacOS上能使用select和poll而Linux上select、poll和epoll都可以使用,默认使用epoll
这里简单列举一下三者的区别,然后在后面介绍一下优势最大的epoll实现过程
- select在监听时需要将所有要监听的文件描述符拷贝到内核让内核遍历找到相应的套接字查看是否有事件发生,没有事件发生则等待,有事件发生则找到对应的事件并返回对应的事件,即readable、writeable、exptional
- polll在select之上做了一定的改进,取消了最多同时只能监听1024个文件描述符的限制,其他基本上没有什么改变
- epoll在给描述符增加、删除和修改事件时的管理使用了非常高效的红黑树数据结构,红黑树的操作元素的时间复杂度为O(logN),然后改进了select的缺点,具体在实现和说明了epoll的细节之后再说明
多路复用之epoll
# server.py
# -*- coding: utf-8 -*-
from collections import defaultdict
import select
import socket
from queue import Queue
from select import (
EPOLLIN, # 可读/可接收消息
EPOLLOUT, # 可写/可发送消息
EPOLLERR, # 出错
EPOLLHUP, # 读关闭/连接中断
)
server = socket.socket()
server.bind(("localhost", 12345))
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.setblocking(False)
server.listen(5)
# 文件描述符FD为键,套接字SOCKET为值,建立映射关系,通过文件描述符找到对应的套接字
fd2skt = {server.fileno(): server} # fileno()将对象的FD返回
# 保存套接字接收到的客户端发过来的消息,每个FD会拥有一个独立的队列,里面存放的消息会按先进先出的顺序被处理
message_queues: defaultdict[int, Queue] = defaultdict(Queue) # key不存在时调用会默认有一个空的队列: message_queues[123].put("a")
epoll = select.epoll()
epoll.register(server, EPOLLIN | EPOLLERR) # server视为一个FD,如果
while True:
events = epoll.poll() # poll方法还可以传入一个超时参数,设置监听消息的超时时间
# 消息正常被接收时,会返回一个文件描述符和一个具体的事件
for fd, event in events:
if fd == server.fileno(): # 最开始进来是服务端的socket,开始做连接准备
conn, addr = fd2skt[fd].accept()
print(f"与{addr}建立连接成功")
# 连接conn和server一样是一个套接字,这里让对epoll注册conn,之后的循环过程中会对其进行监听
# epoll.register(conn, EPOLLIN | EPOLLERR)
epoll.register(conn, EPOLLIN | EPOLLHUP | EPOLLERR)
# 加入连接维护队列
conn_fd = conn.fileno()
fd2skt[conn_fd] = conn
elif event & EPOLLIN: # 轮询到可读消息
data = fd2skt[fd].recv(1024)
addr = fd2skt[fd].getpeername()
if data:
print(f"接收到{addr}发来的消息:{data.decode('utf-8')}")
# 前面已经注册过了,只能注册一次,因此要先取消注册
epoll.unregister(fd)
# 客户端发送消息,服务端需要回复,因此通过register告诉epoll可回消息
epoll.register(fd, EPOLLOUT | EPOLLHUP | EPOLLERR)
# 将消息加入队列,等待被主线程处理即可
message_queues[fd].put(data)
else: # 连接中断(客户端发送空字节串b'')
# 如果连接一直没有响应怎么办?可以使用socket.setdefaulttimeout(int)设置recv等待回应的时长,超时时回抛出一个timeout异常,将其捕获并进行其他处理即可
print(f"客户端{addr}已断开连接")
# 注销事件和连接,停止监听和处理
epoll.unregister(fd)
# 关闭连接
fd2skt[fd].close()
# 从字典中移除该连接
fd2skt.pop(fd)
elif event & EPOLLOUT: # 套接字可写(可发送消息)
msg_queue = message_queues[fd]
if msg_queue.empty(): # 这个连接的剩余消息为空
# 取消监听(所有类型的事件),当下一次客户端发来消息时,又会变得可写(会register)
epoll.unregister(fd)
# 上面的unregister会将读取在内的所有事件都取消,这会让后续收到消息也无法继续处理,因此要重新注册读相关事件
epoll.register(fd, EPOLLIN | EPOLLHUP | EPOLLERR)
# 这里也可以使用modify一个方法代替:
# epoll.modify(fd, EPOLLIN | EPOLLHUP | EPOLLERR)
else:
data = msg_queue.get()
fd2skt[fd].send(f"你好,服务端已接收到你发送的:{data}".encode("utf-8"))
elif event & EPOLLERR: # 发生异常,需要注销相关事件,停止监听和处理
addr = fd2skt[fd].getpeername()
print(f"与客户端{addr}的连接发生错误...")
epoll.unregister(fd)
fd2skt[fd].close()
fd2skt.pop(fd)
message_queues.pop(fd)
响应测试,运行了上面的server.py之后运行下面的client.py得到服务器的返回响应
# client.py
# -*- coding:utf-8 -*-
import socket
def main():
# ip = socket.gethostname()
ip = "localhost"
port = 12345
ipaddress = (ip, port)
sk = socket.socket()
sk.connect(ipaddress)
sk.send(b"Hello")
data = sk.recv(1024)
print(f"receive data from server: {data.decode()}")
sk.close()
if __name__ == "__main__":
main()
前面说到了epoll改进了select的缺点,没有具体细说,这里继续解释
- select每次都要将描述符从用户空间全部拷贝到内核空间去处理,实际上并不每次都需要全部拷贝,在epoll给套接字注册事件的时候拷贝到内核空间,而不是在监听的时候在拷贝,这样每个描述符只需要拷贝一次
- select在有事件发生时,需要遍历所有描述符,一个一个检查套接字看哪些是有事件发生的,epoll则是通过回调事件实现的事件通知,也就是说当事件触发了之后会接收到一个相应的回调告诉我什么事件发生了,显而易见这样做会节省很多开销
- epoll使用高效的红黑树结构存储文件描述符,可存储的数量非常多,并且操作高效,这点也不用多说