前言
python中的并发框架 tornado 异步方式基于epoll的io多路复用模式保证 单线程内的高并发,本篇对 io多路复用
模式做个总结
一、io多路复用优势
在 socket 编程中, 有阻塞 io模型和 非阻塞io模型,两者的区别在于 connect
,accept
, accept
, recv
等 一些列socket操作是否阻塞,那么两种模式有什么区别呢?
-
在阻塞io方式下, 一般采用多线程或者多进程的方式保证服务器的并发, 这种方式有个很大的弊端就是占用很多系统资源以及cpu上下文切换(并发数 > cpu核心数), 众所周知 python 编译器的特性,
GIL
锁的存在, 多线程是采用的 cpu线程切换完成的并发操作, 所以此时在一般并发要求不是很高的场景下可以采用这种方式来进行服务端的开发; -
而对于 非阻塞 io模式, 则可以采用如下方式进行 socket 编程
import socket # 创建 socket 对象 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # bind 服务 sock.bind(('127.0.0.1', 8081)) # 开启服务端socket监听 sock.listen(1) # 设置 100 个 连接上限 sock.setblocking(False) readalbe_list = [] # 可读队列 writeable_list = [] # 可写队列 del_list = [] while 1: try: client_socket, client_addr = sock.accept() # 接收客户端 connect 返回 客户端 socket和 客户端的 地址 print(f"接收到新 连接加入可写队列, 客户端socket: {client_socket} \n 客户端地址: {client_addr}") readalbe_list.append(client_socket) except BlockingIOError as err: pass for client_socket in readalbe_list: try: response = "" chunk = client_socket.recv(1024) while chunk: response += chunk chunk = client_socket.recv(1024) writeable_list.append(client_socket) except BlockingIOError as err: pass for client_socket in writeable_list: if client_socket in readalbe_list: readalbe_list.remove(client_socket) try: client_socket.send(f'欢迎你,服务端已收到信息'.encode()) except BlockingIOError as err: pass else: del_list.append(client_socket) client_socket.close() for client_socket in del_list: writeable_list.remove(client_socket) del_list.clear()
可以看到 我们的 定义了一个短连接的服务端 维护了 两个队列 一个 可读队列
readable_list
一个可写队列writeable_list
, 各个原来的阻塞调用全部采用非阻塞的方式来调用, 此种方式可以在单线程内完成一个服务端的并发。
弊端很明显, 虽然采用的是一个非阻塞的方式来进行服务端的socket操作,但是大量的 cpu时间用在了轮询和处理底层可以忽略的 异常 BlockingIOError, 所以我们可以采用 linux 内核提供的 select/poll/epoll 将这种轮询交给内核来做,以下->。
二、 三种io多路复用模型
-
1. select函数格式及源码分析
-
函数定义
- int select (int maxfd+1, fd_set *readfds, fd_set *writefds, fd_set *exceptionfds, struct timeval *timeout
-
五个参数
- maxfd: 整数, 集合中文件描述符的最大值+1, 如最大的文件描述符为9 则此处入参 10, 设这个值是为提高效率,使函数不必检查fd_set的所有1024位(结构: bitmap 默认: 1024位 由 FD_SETSIZE 限制)
- readset: 分别对应于需要检测的可读文件描述符的集合;
- writeset: 可写文件描述符的集合;
- exceptionset: 异常文件描述符的集合;
- timeout: 用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为0。;
- timeout=NULL, select阻塞,直到某个文件描述符上发生了事件
- timeout=固定时间, select阻塞, 等待固定时间:如果在指定的时间段里有事件发生或者时间耗尽,函数均返回
- timeout=0 select 非阻塞, 仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生
-
五个宏
- FD_SETSIZE: 限制fd_set值, 默认 1024;
- FD_ZERO(fd_set *fdset) : 将指定的文件描述符集清空,在对文件描述符集合进行设置前,必须对其进行初始化;
- FD_SET(fd_set *fdset): 文件描述符集合中增加一个新的文件描述符;
- FD_CLR(fd_set *fdset): 文件描述符集合中删除一个文件描述符;
- FD_ISSET(int fd,fd_set *fdset): 检查指定的文件描述符是否在该集合中。
-
一个array [保存放到
fd_set
的fd]- 用于 select 返回后通过 array作为源数据和fd_set 进行
FD_ISSET
判断; - 每次select返回后会清0无事件发生的fd, 所以每次 select之前会先进行
FD_ZERO
归零, 然后再通过FD_SET
将 fd重新设置到fd_set
中去
- 用于 select 返回后通过 array作为源数据和fd_set 进行
-
返回值
返回有事件发生的fd总数 -
使用select模型伪代码分析
# 说明方便将要监听的 fd 设为5 1. fd_set; bitmap数据, 如果有一个文件描述符 5 那么这个 bitmap 就是 ,..0010000 第五位是 1,这个bitmap最大可以表示1024个 fd; 2. 将 fd=5加入array中, 假定原来还有两个文件描述符 1和2; 2. FD_ZERO(fd_set) 将fd_set 按照 文件描述符 fd 进行归零操作; 3. 执行 FD_SET(fd_set) 将 array中的 fd 在fd_set中进行置位 即 ...0010011 表示文件描述符 1, 2, 5; 4. 调用 select() 传参 maxfd 给定 这个array中最大的 fd+1 此时5是最大fd 那么 maxfd=6; 第二个传参 fd_set; 其他传NULL; 5. 阻塞直到发生一个事件发生;假定此时fd=5有事件发生,那么这个 fd_set 将变为 ...0010000 无事件发生位置为0,即情空, 此时select函数返回 1代表 有1个fd发生了事件; 6. FD_ISSET 根据 array 对 重新置位后的 fd_set 进行遍历,找出发生事件的 fd进行后续操作;根据select返回找到 1 个fd之后结束遍历; 7. 一轮循环结束; 按步骤进入下一轮循环 归零 - 置位 - select() - 处理事件 .....
-
select 函数内部实现
- 遍历用户态传入的fd_set 把
当前线程
挂(实际是调用poll函数(此poll非彼poll))到 每个fd的wait_queue (每个fd 都有个 wait_queue 即等待队列, 在程序运行中产生的 阻塞,如 I/O 阻塞都是将 线程挂到等待队列之中, 等I/O就绪后,通过中断的唤醒线程重新占用cpu时间片),并检查并记录fd此时的状态(是否已经事件发生), 在遍历完所有fd, 如果发现有设备就绪直接返回,否则当前线程进入阻塞状态,直到任一fd事件发生; - 在设置的 timeout 超时时间间隔内,注册到select()上的 任一 fd发生事件,则 fd的等待队列中的用户线程会重新唤醒; 此时会重新调用poll函数拿到所有发生事件的fd,此时不再加入等待队列(不管是设备准备就绪唤醒的还是超时时间到唤醒的都会执行这个操作);
- select()更改 fd_set 对bitmap数据进行置位,将发生事件的fd置位1其余 置为0, 并移除 所有fd的等待队列(select会记录fd的等待队列),然后返回发生事件的fd总数
- 遍历用户态传入的fd_set 把
-
selector的缺点
- 1. 调用select函数会进行至少3次遍历,① 调用前遍历array生成bitmap ② select遍历bitmap挂起当前线程 ③调用后遍历array 通过FD_ISSET拿出发生事件的fd进行后续操作,即使用select的算法复杂度为 O(n);
- 2. 事件发生后并不知道具体是哪个fd发生的事件,还需要进行一次遍历
- 3. 经历两次用户态和内核态的数据拷贝;第一次是调用阶段 将 1024位的 fd_set 从用户态拷贝到内核态,第二次是内核态将重新置位后的fd_set 拷贝给用户空间
- 4. 每次调用都要初始化重新置位后的fd_set
- 5. 最重要的是 select有最大连接数的限制 1024
-
内核态跟用户态几次数据交换
TODO
-
-
2. poll函数格式及源码分析
- 类比select - fd_set采用 链表方式 理论上对 连接无上限, 算法复杂度仍然为 O(n)
-
3. epoll函数格式及源码分析
TODO
link -
总结
在select/poll中,服务器进程每次调用select都需要把这100万个连接告诉操作系统(从用户态拷贝到内核态)。让操作系统检测这些套接字是否有时间发生。轮询完之后,再将这些句柄数据复制到操作系统中,让服务器进程轮询处理已发生的网络时间!
而在 epoll 模型中,采用如下方式:
1)调用epoll_create建立一个epoll对象,这个对象包含了一个红黑树和一个双向链表。并与底层建立回调机制。
2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字
3)调用epoll_wait收集发生事件的连接。