前言
内存和其他设备进行数据交互的过程叫做IO,IO可以理解成两个过程,等待和拷贝数据。其中等待的当作消耗的时间相对较长,提高IO效率的思路就是减少等待时间所占的比重。
概念区分:
阻塞和非阻塞:
阻塞是指调用结果如果没有返回,当前线程会被挂起进行等待。
非阻塞指在不能立刻得到结果时候,该调用不会阻塞当前线程。
同步IO/异步IO:
同步IO指IO发起者参与等待或数据拷贝的过程,异步IO指等待和数据拷贝的任务由系统完成,IO发起者只等待最终完成结果的通知。
下文介绍高级IO中的非阻塞IO和多路复用
一、五种IO模型
- 阻塞IO
在内核将数据准备好之前,系统调用会一直等待。所有的套接字,默认IO方式都是阻塞式。
阻塞IO是最常见的IO模型
- 非阻塞IO
如果内核未将数据准备好, 系统调用直接返回, 并且返回EWOULDBLOCK错误码。
所以需要以循环的方式反复读,也叫轮询,比较浪费CPU资源
- 信号驱动IO
内核将数据准备好的时候,使用SIGIO信号通知进程进行IO操作
-
多路复用
和阻塞IO的过程有点相似,但是可以同时等待多个文件描述符就绪。 -
异步IO
数据拷贝由内核完成,内核在数据拷贝完成时, 通知进程。
二、非阻塞IO
文件描述符默认是阻塞IO
设置文件描述符为非阻塞:
//自定义函数
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL);
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
使用F_GETFL将当前的文件描述符的属性取出来(这是一个位图).
然后再使用F_SETFL将文件描述符设置回去. 设置回去的同时, 加上一个O_NONBLOCK参数.
三、多路复用
select
多路复用的一种方式,调用select可以同时关心多个文件描述符,程序会等待直到关心的文件描述里有状态发生改变的。
函数原型:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数:
nfds为关心的文件描述符里的最大值+1
接下来的三个参数分别代表关心的读文件描述符集合、写文件描述符集合、异常文件描述符集合。都是输入输出型参数,用户通过这个集合告诉内核自己关心的文件描述符有哪些,内核也通过这个集合告诉用户哪些文件描述符就绪了。
timeout参数用来设置等待时间
timeout取值:
NULL:select将一直被阻塞,直到某个文件描述符上发生了事件;
0:仅检测描述符集合的状态,然后立即返回;
特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回。
fd_set结构:
直观地看fd_set是一个整数数组,实际上是用位图的形式存储关心的文件描述符。
操作fd_set的接口:
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位
select返回值:
执行成功则返回文件描述符状态已改变的个数
如果返回0代表已超过timeout时间
当有错误发生时则返回-1,错误原因存于errno
select特点:
可关心的文件描述符个数有上限,和fd_set的容量有关
要额外使用一个数组保存关心的文件描述符,数组的功能如下
- 方便select返回时候判断哪个文件描述符就绪了。
- 方便下一次调用select时候设置关心的文件描述符。
- 方便获取select第一个参数。
select的缺点:
- 每次调用select都要手动设置关心的文件描述符集合,使用不方便。
- 每次调用select都要把文件描述符集合拷贝到内核态,内核也要遍历文件描述符集合,文件描述符比较多时候开销较大。
- 可以关心的文件描述符数量有限。
poll
poll也是多路复用的一种方式
函数原型如下:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
参数:
fds是pollfd类型的数组,也是输入输出型参数
nfds表示数组长度
timeout表示poll函数的超时时间(毫秒)
pollfd结构:
fd代表文件描述符,events表示关心的事件集合,revents表示返回的就绪事件的集合
事件的取值:
函数返回值:
小于0, 表示出错
等于0, 表示poll函数等待超时
大于0, 表示就绪的文件描述符个数
poll的优缺点:
- 和select比起来,通过pollfd数组记录关心的文件描述符和对应的事件,不需要每次调用都重新设置,接口使用更加方便。
- 每次调用poll都要把pollfd数组拷贝到内核,poll返回后也需要遍历pollfd数组获取就绪的文件描述符
- 可以关心的文件描述符没有数量限制,但是同一时间连接的客户端可能只有少数就绪,文件描述符数量多的时候效率是不理想的。
epoll
epoll是为了处理大量文件描述符而改进的poll,是一种性能比较好的多路复用方式。epoll适用于有多个连接但是只有一部分连接活跃的场景。
epoll的三个系统调用:
1.创建epoll模型
int epoll_create(int size);
返回值是一个文件描述符
2.注册关心的事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
epfd为刚才创建的epoll文件描述符
op为本次操作的类型,有增删改三种
EPOLL_CTL_ADD :注册新的fd到epfd中;
EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
EPOLL_CTL_DEL :从epfd中删除一个fd;
fd为事件对应的文件描述符
第四个参数是epoll_event类型的数组,代表关心fd上的哪些事件
epoll_event结构:
events可以是以下几个宏的集合:
EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
EPOLLOUT : 表示对应的文件描述符可以写;
EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
EPOLLERR : 表示对应的文件描述符发生错误;
EPOLLHUP : 表示对应的文件描述符被挂断;
EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里
3.获取已经就绪的事件
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
第二个和第三个参数都是输出型参数,events为就绪的事件的数组,不可以为空,内核会把就绪事件拷贝到这里,maxevents代表数组有效元素个数。
timeout(毫秒,0会立即返回,-1是永久阻塞)
函数调用成功,返回就绪的文件描述符数目,如返回0表示超时, 返回小于0表示调用失败
epoll模型:
调用epoll_create时,Linux内核会创建一个eventpoll结构体
struct eventpoll{
....
/*红黑树的根节点*/
struct rb_root rbr;
/*双链表就绪队列*/
struct list_head rdlist;
....
};
调用epoll_ctl,就是修改红黑树的节点,设置回调方法
调用epoll_wait,就是从双链表就绪队列里读取就绪的事件
epoll的优点:
- 和select相比不需要每次调用都设置关心的文件描述符,可关心的文件描述符个数也没有上限
- 不需要每次都把文件描述符数组拷贝到内核,只在注册新事件的时候进行拷贝
- select和poll都要求操作系统去检测文件描述符上的某些事件是否就绪,会在没有就绪的文件描述符和事件上浪费一定时间,而epoll是在底层为关心的文件描述符和时间注册回调方法,哪一个就绪了就添加到就绪队列,效率较高。
epoll工作方式:
epoll的工作方式有水平触发(LT)和边缘触发(ET)
LT:
epoll默认是LT方式,当epoll检测到某个文件描述符上的事件就绪,我们可以不处理或者处理一部分,下次调用epoll_wait时候仍然会通知该事件就绪直到我们把事件处理完。支持阻塞和非阻塞读写。select和poll是LT工作方式。
ET:
epoll检测到某事件就绪,我们必须立刻处理完,因为下次调用epoll_wait不会再通知该事件就绪。所以epoll_wait返回的次数少了很多,性能更高,但编写程序的复杂度也相对提高了一些。ET模式下必须使用非阻塞读写,因为我们不知道底层数据有多少,是循环地一部分一部分地读取,最后一次读到的数据量肯定小于等于我们期望这一次读到的数据量,如果小于我们可以知道这次读完就结束了,如果等于我们不能知道已经读完,所以采用阻塞读取这里就会被挂起进行等待。