IO复用
目录
为什么要IO复用
问题:如果单线程需要处理两个描述符,它只能同时阻塞于一个描述符。如果线程阻塞于A描述符,B描述符中有很重要的信息,也必须A描述符解除阻塞后才可以看到B描述符的信息
- 解决方法一:多线程分别处理一个描述符
- 解决方法二:IO复用(一个线程同时阻塞多个描述符)
IO复用就是把需要处理的描述符提前告知内核,内核发现一个或多个描述符就绪,就通知进程
- 应用场合:总来来说就是要同时处理多个描述符或套接字就使用IO复用
IO模型
将IO分为两个阶段,等待就绪阶段,拷贝阶段
阻塞IO
默认情况下所有描述符都是阻塞的,两个阶段的阻塞是连续的
- 等待就绪阶段:阻塞
- 拷贝阶段:阻塞
缺点:
- 多线程所有IO都阻塞,CPU没有线程可以执行
- CPU花大量时间进行线程切换
- 并发连接数受系统支持线程数限制
应用场景:
- 只使用单个描述符
非阻塞IO
通过fcntl函数把一个描述符设置为非阻塞的
- 等待就绪阶段:轮询
- 拷贝阶段:阻塞
缺点:
- 轮询机制,需要配合IO复用或信号驱动IO有IO通知机制的模型使用才能提高效率
- 轮询机制,不一定有IO就绪发生,浪费CPU
- 大量系统调用上下文切换
应用场景:
- 配合IO通知机制
IO复用
利用IO复用的系统调用,处理多个描述符
- 等待就绪阶段:阻塞
- 拷贝阶段:阻塞
- 和阻塞IO不同的是,等待就绪阶段的阻塞,是阻塞在IO复用系统调用
优点:
- 一个线程阻塞处理多个描述符,减少多线程阻塞的切换开销
- 由内核负责监听,进程可以及时处理
- 提高非阻塞IO的效率
缺点:
- 两次系统调用,不过要看和什么IO模型进行对比。和非阻塞IO对比减少了系统调用次数,和阻塞IO比增加了系统调用次数(但能处理多个描述符)
应用场合:
- 总来来说就是要同时处理多个描述符或套接字就使用IO复用
信号驱动IO
- 建立SIGIO信号处理函数,利用信号处理函数进行 ①IO操作再通知主循环完成 或者 ②直接通知主循环进行IO操作
- 通过fcntl函数设置描述符的属主进程
- 通过fcntl函数开启描述符的信号驱动IO(即描述符可进行IO操作时就发送SIGIO信号)
- 等待就绪阶段:进程正常执行
- 拷贝阶段:阻塞
优点:
- 等待就绪阶段不阻塞,进程可以继续执行
缺点:
- 不适用于连接socket,比如读写同时就绪的情况,信号处理函数无法判断
- 不适合于通知条件的场景,信号处理函数不一定能分辨
应用场景:
- UDP套接字,信号通知条件只有数据报到达和异步错误
- 用于监听socket
异步IO
- 通过异步IO函数通知内核进行IO操作,异步IO函数中指定信号
- 建立对应的信号处理函数,接收IO完成信号
- 等待就绪阶段:进程正常执行
- 拷贝阶段:进程正常执行
优点:
- 两个阶段,进程都可以正常执行,不会阻塞
- 避免非阻塞IO多次系统调用的开销
缺点:
- 需要系统支持
- 占用内核资源
应用场景:
- mysql
各种IO模型的区别
同步IO和异步IO的区别
- 同步IO的等待就绪阶段都是不一样的
- 同步IO的拷贝阶段必定阻塞,异步IO的拷贝阶段不会阻塞
信号驱动IO和异步IO的区别:前者内核通知IO就绪,后者内核通知IO完成
三种IO复用的基础,POLL机制
select
源码流程
- select--->sys_select
- 系统调用进入内核态,将时间转换为jiffies
- sys_select--->core_sys_select
- 在内核调用kmalloc开辟一段内存作为监听数组fds(先使用栈,不够用再kmalloc),把用户空间的fd_set拷贝到fds,再将fds返回状态的部分清零
- core_sys_select-->do_select(&fds)
- 三层嵌套循环
- 第一层死循环,超时||文件描述符有事件发生||进程有信号,跳出循环;else更新超时时间并睡眠一段时间等待唤醒
- 第二层循环以32个文件描述符(位图方式组织)为一组遍历所有文件描述符,检查此轮的32个文件描述符是否需要监听事件
- 第三层循环当第二层的某个文件符需要监听事件发生才会进入第三层,循环32个文件描述符
- 跳过不需要检测的文件描述符
- 根据文件描述符可以得到file结构,根据结构体里的f_op文件操作调用poll函数(检查文件上是否有操作发生,驱动程序实现的不会阻塞)
- 将进程加入到对应文件的等待队列(只加一次,跳出第二层循环时,会将pollwqueue结构体里函数指针设为null;又或者poll操作时获得了文件就绪事件也设为null,不再将进程加入到等待队列)
- 检查对应文件描述符是否有事件就绪,更新返回结果集
- 每一次第二层循环都要先遍历所有文件描述符才会回到第一层循环判断是否跳出
- do_select-->core_sys_select
- 将fds拷贝到用户空间的各个fd_set,调用kfree释放fds的内存
- core_sys_select--->sys_select
- 更新剩余时间
- sys_select--->select
使用流程
#include<sys/select.h>
//fd_set是位图,标记各个描述符
int select(int n, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout)
FD_CLR(int fd, fd_set* fdset); //根据fdset清除某个监听事件
FD_ISSET(int fd, fd_set* fdset); //用来查看监听结果,判断监听事件是否发生
FD_SET(int fd, fd_set* fdset); //设置
FD_ZERO(fd_set* fdset); //清空,初始化
- 声明三个fd_set并调用FD_ZERO清空(也可以只声明自己需要的个数,select里传NULL即可)
- 调用FD_SET设置文件描述符到对应的fd_set
- 设置超时时间,0是非阻塞,-1是阻塞到有时间发生,>0是超时时间
- 调用select
- 调用FD_ISSET查看对应描述符是否发生该事件
- 循环重新执行2
特点
- 以事件为单位组织文件描述符
- 优点:
- 简单,移植性好
- 提高非阻塞IO的效率,避免轮询不断改变状态(这是所有IO复用的优点)
- 缺点:
- 只监听三种事件,除了读写都归异常,局限性大
- 事件集也用于结果集,每次调用select都要重新设置事件集
- 得到结果集,需要遍历所有文件描述符,即使这个文件描述符没有出现监听事件
- 每次select都要将用户空间的事件集拷贝到内核空间
- 只能为LT模式
- 用户空间和内核都是用轮询方式进行查询,文件描述符数量大,开销也大
- 有文件描述符个数限制
- select和poll的线性不安全体现在,如果你多线程一个线程close一个正在监听的fd,会有错误行为
应用场景
poll
源码流程
- poll--->sys_poll
- 系统调用进入内核态,将时间转换为jiffies
- sys_poll--->do_sys_poll
- 在内核调用kmalloc开辟一段内存作为监听链表(先使用栈,不够用再kmalloc),从用户空间的pollfd拷贝到内核空间
- do_sys_poll--->do_poll
- 三层嵌套循环(实际上应该就两层)
- 第一层死循环,时间用完或有事件发生,更新时间跳出循环;否则睡眠一段时间等待唤醒
- 第二层循环,遍历监听链表
- 第三层循环(其实相当于没有,从第二层取得的链表元素里面存放一个pollfd数组,但里面只有一个pollfd),和select一样使用poll机制,检查文件描述符是否有事件发生,更新pollfd中的结果集revents
- do_poll--->do_sys_poll
- for循环将链表上的pollfd的revents从内核空间拷贝到用户空间(4中的拷贝是完整的pollfd)
- do_sys_poll--->sys_poll
- sys_poll--->poll
使用流程
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 注册的事件,位图形式 */
short revents; /* 实际发生的事件,由内核填充 */
};
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);
特点
- 以文件描述符为单位组织事件
-
优点:
- 内核以链表形式组织pollfd,没有文件描述符个数限制
- 监听事件集和结果集分开,不需要重复设置
-
缺点:
- 每次调用都用从用户空间拷贝pollfd集合到内核空间,返回时也需要一次拷贝
- 只能为LT模式
- 轮询方式,开销大
- 查询结果集也是轮询方式
应用场景
epoll
源码流程
epoll_create
- 分配一个eventpoll内核事件表
- 内核事件表,以红黑树组织需要监听的fd,以双向链表组织有事件发生的fd
- 这里的fd并不只是一个文件描述符,内核会用结构体epitem包装这个fd,结构体还有监听事件、作为结点的一些属性等等
- 创建一个匿名fd,之后能够通过fd找到file结构体,从而找到内核事件表
- 返回fd
epoll_ctl(看了两天了,有点看不懂源码,其实select,poll太底层的地方也没看懂)
- 有添加、删除、修改几种选项
- 将epoll_event从用户空间拷贝到内核空间,
- 不允许对epoll本身的fd进行监听
- 根据操作选项对eventpoll内核事件表的红黑树部分进行更新
- 更新删除都可以直接操作节点
- 插入操作,在slab中申请内存作为epitem结点
- 用poll机制将这个进程放入对应文件的等待队列
- 插入红黑树
- 如果此时就已经有就绪事件,就把结点放入双向链表
- 这里最重要一件事就是设置等待队列的回调函数,select、poll的回调函数是系统默认的__poll_wait将进程挂在文件等待队列,而epoll不仅将进程挂在文件等待队列,并且在文件状态改变,并且是监听的事件时调用回调函数自动把结点连在双向链表上(这样就不需要epoll_wait进行遍历全部fd)
epoll_wait
- 检查双向链表上是否存在结点,有则再poll一次获取最新事件然后返回事件,只拷贝双向链表中的fd和事件回用户空间
- 双向链表上没数据,则将进程挂在epollfd的等待列表。进入死循环,检测双向链表或睡眠等待唤醒
- 根据EPOLLONESHOT是否设置。如果设置了,第一次将fd返回用户空间前,设置一个标志位,之后都不会把fd放入双向链表。直到fd对EPOLLONESHOT进行重置
- 检查LT和ET模式。LT模式,将就绪fd再次放回双向链表,下一次epoll_wait会直接返回(因为就绪链表上有东西了),会再poll一次拿最新的,假如已经读出来的话就返回0(就是空转了一次);ET模式,只有回调函数可以把fd再次放入双向链表
- LT和ET的例子,只监听读,LT在不输入情况下还是会死循环打印,ET在不输入情况下会阻塞
使用流程
int epoll_create(int 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); //指定监听多少个事件
特点
线程安全,互斥锁保护红黑树,自旋锁保护就绪链表,ctl和wait都会上互斥锁,wait上自旋锁(ctl中插入新结点,如果结点有就绪事件,也会上自旋锁)
- 优点:
- 不会像select、poll每次调用都需要把事件集、文件描述符从用户空间拷贝到内核,只需要epoll_ctl完成一次即可
- 不会像select、poll在内核监听时要遍历所有文件描述符,时间复杂度O(N);epoll利用回调函数自动把fd挂载到就绪队列,监听时只需要判断就绪队列中是否有元素,时间复杂度O(1)
- 没有文件描述符个数限制
- 支持LT和ET
- 缺点:
- 跨平台性不好,linux特有
- epoll_ctl不能批量操作,操作多个文件描述符需要多次调用,有系统调用的开销
- 高并发时,不断调用唤醒回调函数,开销大
应用场景
- 长连接数多,活跃连接少
各个方法的比较
- 长连接数多,活跃连接少,适合epoll,其他两种都是轮询
- 如果连接数少,活跃连接多,区别不大,epoll不断触发回调函数需要一定开销,select、poll更轻量,也不需要内核事件表同时不需要频繁更改内核事件表
- select可移植性好,并且为超时值提供更好的精度,是微秒usec,其他两种方法是毫秒msec
LT和ET
LT和ET的例子
源码中的体现就是一句if判断,LT情况下,把刚从双向链表中拿出来的结点再放回到链表尾
if (!(epi->event.events & EPOLLET)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
}
- LT:
- 事件就绪后,程序不操作的话,每次调用都会返回该事件
- ET:
- 事件就绪后,程序无论操作与否,都只会返回一次该事件
- 需要配合文件描述符设置为非阻塞使用(当然你阻塞的话程序也能跑,只是会出现问题而已),循环读取内容。如果为阻塞,读到最后一次时会阻塞在读操作
- 应用:
- 有些时候确实不需要操作某些事件,而LT会重复提醒
- ET减少系统调用次数,比如EPOLL使用LT,不操作的话就会导致每次一进入检查到就绪队列有元素就返回,那么调用epoll_wait的次数就会变多
- SELECT和POLL中LT的表现:事件就绪,你不操作,下次循环poll这个文件描述符,还是会返回事件就绪
- EPOLL中ET的表现:事件就绪,你不操作,但是只有事件再次改变时回调函数才会放入就绪队列。即使你事件就绪,也不会通过其他途径加入就绪队列