5种IO模型
当我们想要进行IO操作时,不一定有数据让我们读取,也不一定有空间放我们写入的数据,所以IO操作除了要拷贝数据,还要等待时机,即等待数据或者空间就绪。
IO效率高指的是单位时间内传输的数据量大。IO效率低下一般是由于等待的时间过长。**单位时间内IO操作的等待时长越少,IO就越高效。**因此,阻塞IO和非阻塞IO的IO效率是没有区别的,只是后者可以利用等待的时间去做其它事,提高了整理的效率,但没有提高IO的效率。
IO肯定需要等待时机,但是不同的IO模型处理等待的方式不同。阻塞IO 很简单,就是一直检测是否就绪,一直等,非阻塞IO 则是每次只检测一次,若失败了就直接返回,先去执行其它任务,执行完了再检测,如此循环。信号驱动IO 直接连检测都不检测,让内核通过信号通知,收到信号再处理。多路复用 比较特殊,它一次检测多个IO通道,哪个先好就先处理哪个。前面4种由进程亲自处理IO操作的IO模型都属于同步IO,异步IO则是直接让内核去进行IO操作,完成了再通知。
阻塞式IO非常简单,信号驱动IO和异步IO已经很少使用,我们主要讲多路转接和非阻塞IO
非阻塞IO
Linux系统下,套接字socket、管道pipe、命名管道FIFO等都是被视为文件管理的,而用户通过文件描述符对其进行IO,所以,一个文件描述符就对应一个IO通道 。我们知道,用户通过文件描述符进行IO操作,其底层就是通过file结构体进行IO。file结构体的成员中有一个文件状态标记,记录了该文件是否使用阻塞IO。通过接口 fcntl 获取并修改文件状态标记,再设置回去,就可以将 对该文件的IO操作 设置为非阻塞,从而进行非阻塞IO。
fcntl接口如下,第一个参数是要操作的文件的文件描述符,传入op的值不同,后面追加的参数就不同。表示获取和设置文件状态标记的op值分别为F_GETFL和F_SETFL。

使用示例如下,第3行调用fcntl时,会获取文件状态标记(是一个位图),并通过返回值返回。
cpp
//将fd对应的文件设置为非阻塞
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL); //获取文件状态标记
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);//增设非阻塞
}
将文件设置为非阻塞后,IO接口的使用也要有所调整。例如使用read读取非阻塞式的文件时,在很多种情况下返回的都是-1,如读取出错和数据未就绪,为了区分,read会修改全局变量errno,即设置一个错误码,通过错误码的值即可区分返回-1时的具体情况。在读取非阻塞式文件时,要根据errno的值对各个情况分别处理。
多路转接
select
基本用法
多路转接的系统调用接口是select,它会一次检测多个fd(文件描述符),在设定的时间内有fd就绪时就会返回,通知调用方有哪些fd已经就绪了。

第一个参数nfds 是要关心的fd中,最大的fd的值+1,用于底层遍历文件描述符表。
最后一个参数 timeout 是用timeval结构体表示的时间,如下图,单位分别是秒和微秒。如果在timeout表示的时间内没有一个fd就绪,select就会立即返回0,表示超时。如果将timeout的两个成员设置为0,即等待时间为0,select会将每个fd检测一遍,如果没有就绪就立即返回,即非阻塞式检测;如果为nullptr,则select会一直等待直到有fd就绪,即阻塞式检测。

select的返回值有三种,大于0时表示的是就绪的fd个数,等于0表示的是超时,即在timeout指定的时间内没有fd就绪,小于0为报错。
我们知道不同的IO操作等待的事件不同,读取操作等待的是底层有数据,这个被称为读事件就绪 ,写入操作等待的是底层有空间,它被称为写事件就绪 。在select接口中,第二个参数readfds 就用于指定 要检测读事件就绪的fd,第三个参数writefds则是指定 要检测写事件就绪的fd。而第四个参数比较特殊,它用于指定 要检测异常状态的fd,如TCP协议的socket收到紧急数据时就会进入异常状态,这个暂时不管。
这三个参数即是输入型参数也是输出型参数,它们的类型都是fd_set*,fd_set是位图结构。以readfds为例,输入时,位图第一位为1表示0号fd要检测读事件就绪,为0则不管该fd,第二位对应的是1号fd,第三位对应2号,以此类推,如下图

检测到有fd就绪,select返回时,位图的某一位为1就表示对应的文件描述符已就绪,不过具体是哪一位需要我们自己遍历一遍位图才知道。其它两个的用法也是类似。
fd_set不能直接用位操作修改,要用系统提供的宏,如下。
cpp
void FD_CLR(int fd, fd_set *set); //移除set中对fd的标记,即对应位设为0
int FD_ISSET(int fd, fd_set *set); //检测set中是否标记了fd
void FD_SET(int fd, fd_set *set); //将fd加入位图set,即对应位设为1
void FD_ZERO(fd_set *set); //清空set,将位图所有位设为0
注意事项
使用select需要注意 几点,select会修改位图,所以要用一个辅助数组存储所有要关心的文件描述符。每次调用select之前,通过辅助数组更新select参数的位图。其次,如果要通过select实现多路转接,那么进行读取操作时,默认读事件不就绪 ,必须先由select等待,就绪了再进行读取。其它多路转接的接口也是如此。
简单来说就是所有要进行读取的文件描述符都要让select关心,待其返回后再根据位图确认是哪个fd就绪,再调用对应的处理函数进行读取。如TCP的accept获取连接,底层要有连接才能获取并返回,本质就是在等待读事件,所以监听套接字也要让select关心。只要想读取,就要用select确认就绪。在TCP通信中,由于不需要等待用户发送数据,即使是单进程,selectserver也可以处理多个连接。
写入则有些不同 ,多路转接中,一般默认写事件 是就绪 的,在网络通信中就是默认发送缓冲区有空间,不一定要设置进selet或其它多路转接的接口进行等待,按需设置即可。这是因为发送缓冲区一般不会写满,如果设置关心写事件,那么基本每次调用多路转接的接口,就会有写事件就绪。所以,直接写入即可,如果写满了,再把写关心设置进epoll。这样一次可能无法完成写入,需要循环写入,并根据实际写入的大小,删除或跳过缓冲中已经写入的部分。还要注意设置写事件关心后,如果后面完成了发送,要把去掉写事件关心。
select 有许多缺点, 最主要的有两点,其一是由于fd_set的大小有限,所以select能关心的fd数量(每次等待的fd数量 )有限。最多1024个。其二是由于输入和输出位置重叠,每次调用select前都需要重新更新位图。为了更高效地使用多路转接,后来又引入了poll,在poll之后还有更高级的epoll。
poll
poll也是一个多路转接的接口如下,第一个参数fds是数组,用于设置要关心的fd和事件,后面展开讲。第二个参数nfds则是数组fds中元素的个数。timeout用于表示超时时间(单位:ms),设为-1表示阻塞式等待。

struct pollfd定义如下,我们使用poll时,在外部创建好struct pollfd数组,为每一个要关心的fd初始化数组中的sruct pollfd,完成后将数组传入poll的参数fds,参数nfds填写数组中有效的pollfd数量即可。
cpp
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 要关心的事件*/
short revents; /* poll返回时,就绪的事件*/
};
poll内部会根据数组中每一个pollfd的events确定该fd要关心的事件,各个值对应的事件如下,一般只需注意最上面的POLLIN和最下面的POLLOUT即可,分别表示关心读事件和写事件。如果把short类型看成位图,下表中不同事件的值只占位图中的一位。所以要关心一个fd的多个事件时,就用按位或进行合并。

poll返回时,会将就绪事件的值设置进文件描述符对应的pollfd的revent成员中。我们在调用前,初始化数组时不用管这个成员。
不难发现,相比于select,poll将输入和输出的位置分离了,调用前不需要什么重置操作,更加方便。并且由于使用的是数组,所以等待的fd个数没有上限,但是当数量过大时性能仍然会下降。其次,每次调用poll时,需要把大量的pollfd结构从用户态拷贝到内核中(poll是系统调用接口,内核和用户的内存是相互隔离的)。而且大量fd在同一时刻可能只有少数处于就绪状态,随着要监视的描述符数量增长,poll的效率也会不断下降。为此,后来又引入了epoll。
epoll
epoll底层维护了一个红黑树和一个就绪队列,并且不再是使用单一的接口,我们先了解主要的几个接口的大致用法。
epoll_create创建epoll
创建epoll使用的是epoll_create,返回的是epoll结构本身的文件描述符,用于后面管理epoll。参数size现在在epoll_create内部已经被忽略了,不用管,随便设置一个正整数即可。

epoll_ctl控制epoll
控制epoll(就是操作其内部的fd)的接口是epoll_ctl ,该接口成功返回0失败返回-1,第一个参数传入前面epoll_create返回的epoll文件描述符,第二个是操作选项,有三种,分别是增添fd的EPOLL_CTL_ADD 、修改fd要关心的事件的EPOLL_CTL_MOD 、移除关心的fd的EPOLL_CTL_DEL(此时event可以为NULL)。要注意,用epoll_ctl移除对fd的关心时,fd必须是有效的,不能关闭fd后才调用epoll_ctl移除关心。
第三个参数用于指定要操作的fd,最后一个参数用于指定要操作的事件(如读就绪、写就绪)。

struct epoll_event的定义如下,event用于指定事件,具体的值及其解释在后面。成员data是epoll_data联合体类型,用来存储epoll_event的相关信息,由用户自己设置,epoll内部不会修改也不会使用,最主要的就是存储epoll_event属于哪个fd。后面调用epoll_wait获取就绪的事件时,只会返回epoll_event,此时就需要通过data来确认是哪个fd,在TCP通信中,可能还需要保存客户端信息。需要存储多种信息时,可以定义一个结构体,把data设置为该结构体的指针。
cpp
struct epoll_event {
uint32_t events; /* 用于指定事件 */
epoll_data_t data; /*用户自定义的数据,内核(epoll)不会修改 */
};
union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
};
成员events用法如下,知道最上面两个即可。
| 宏 | 值(十六进制) | 意义 |
|---|---|---|
EPOLLIN |
0x001 | 文件描述符可读(包括对端关闭连接、有新数据到达) |
EPOLLOUT |
0x004 | 文件描述符可写(发送缓冲区有空间,可以写入数据) |
EPOLLRDHUP |
0x2000 | 对端关闭连接或半关闭(TCP 收到 FIN,需要 2.6.17+ 内核) |
EPOLLPRI |
0x002 | 有紧急数据可读(如 TCP 带外数据) |
EPOLLERR |
0x008 | 错误发生(默认监听,无需显式添加) |
EPOLLHUP |
0x010 | 挂起(对端主动关闭连接,管道或 socket 被挂断,默认监听) |
EPOLLET |
0x80000000 | 边缘触发模式(Edge Triggered,默认是水平触发 LT) |
EPOLLONESHOT |
0x40000000 | 一次性触发(事件触发一次后自动从监听集合中移除) |
epoll_wait等待事件
等待事件的接口是epoll_wait,第一个参数传入epoll自己的文件描述符,第二个参数events是输出型参数,需要用户创建数组并传入,epoll_wait会在events内填写已就绪的文件描述符,maxevents用于表示数组的大小,它决定了一次最多能返回多少事件。
timeout用于设置超时时间(单位:ms),返回值有三种,大于0时表示的是就绪的fd个数,等于0表示的是超时,即在timeout指定的时间内没有fd就绪,小于0为报错。

IO就绪时的通知机制
在了解epoll底层之前,我们需要先了解Linux读写事件就绪的通知机制。我们知道,当文件可读或可写时,需要通知上层进行相应的IO操作。但是要明确的是,对普通文件的读写几乎不会阻塞,想IO就可以IO,不需要等待,更不需要通知,这里的文件指的是socket的接收、发送缓冲区、pipe、IO设备(如键盘)的缓冲区等被封装成文件的IO对象,下同。
在内核中,每个文件都有自己的等待队列,如果有进程要对该文件进行阻塞式IO,对应的IO系统调用(如select)会创建一个节点,里面存放回调方法,将其接入该文件的等待队列,然后进入阻塞等待,此时进程就阻塞住了。文件就绪时,内核就会调用等待队列中节点的回调方法,唤醒对应的进程,完成IO操作。
前面提到可以通过修改file结构体的文件状态标记,将对该文件的IO设置为非阻塞。但是如果参数指定要进行阻塞等待(timeout != 0),即使文件本身是非阻塞模式,select、epoll_wait等多路转接接口仍然会按照其 timeout 参数的要求阻塞等待事件。文件非阻塞模式不影响多路转接的阻塞行为。 最后,一个文件有多个等待队列,例如读取和写入的等待队列就不是同一个。
epoll模型
epoll底层是一个红黑树和一个就绪队列,红黑树中一个节点对应一个要关心的fd,而就绪队列中的一个节点表示一个就绪的fd。调用epoll_create时就会创建eventpoll结构体(下图右上角),其内部包含了红黑树的根节点指针和就绪队列的头节点指针,一开始没有关心的fd时,两个数据结构都是空的。当我们调用epoll_ctl添加要关心的fd时,其内部会为该fd创建一个结构体epitem,将其成员rbn接入红黑树。
当调用epoll_wait时,其内部就是在检查就绪队列是否为空,如果不为空,就将就绪队列中的节点对应的epitem拷贝到用户从参数传入的缓冲区,如果为空,则直接阻塞。

前面提到,对文件进行阻塞式IO时,需要对应的系统调用向其等待队列添加节点,文件就绪时通过调用节点的回调方法唤醒进程,进行IO。我们调用epoll_ctl添加关心的fd时,其内部就会进行上述的操作,比较特殊的是,epoll_ctl在等待队列中注册的回调方法会唤醒进程(如果文件就绪时进程被epoll_wait阻塞),但不会进行IO,毕竟epoll_ctl 就不是用来读写的。这个回调方法会将红黑树中,对应节点的epitem的rdllink成员接入就绪队列。简单来说就是让epitem同时处于红黑树和就绪队列(一开始只在红黑树里),这个操作我们简称激活节点。
那么怎么知道要激活哪个节点呢?在调用epoll_ctl时,其底层除了创建epitem结构体,将其成员rbn设为红黑树的节点,还会为该节点注册一个回调函数,这个回调函数专门用于激活该节点,将该回调函数接入对应的等待队列后,文件就绪、调用回调时就会激活红黑树的对应节点。 这个红黑树+就绪队列+回调机制 的模型就是epoll模型。
这种将就绪的数据放入缓存队列的机制和生产者消费者相似,不仅如此,和生产者消费者模型一样,epoll结构也是线程安全的。
select和poll效率低下,因为其原理是遍历一遍用户传入的fd,向所有对应的等待队列注册回调,文件就绪后还要将回调方法从等待队列中移除,否则如果下次用户要关心的fd不同了,进程会被不再关心的fd唤醒。并且,每次调用都需要把用户传入的fd信息全部拷贝到内核的内存。因为select和poll是系统调用接口,系统调用使用的是内核的内存,与用户的内存是相互分隔的。
而epoll只有用户调用epoll_clt增加要关心的节点时,才会将fd信息拷贝到内核,epoll_wait每次只需查看就绪队列,不用遍历所有fd,并且只有用户调用epoll_ctl删除fd时,才会移除fd对应文件的等待队列的回调方法。
epoll工作模式
epoll有两种工作模式,一种是水平触发(LT,Level Triggerred),一种是边缘触发(ET,Edge Triggered)。
在LT模式下,用户调用epoll_wait获取就绪队列中的就绪事件后,只有当用户处理完就绪事件,内核才会将对应的事件从就绪队列中移除。例如当socket的接收缓冲区收到数据后,内核调用等待队列的回调,将epoll红黑树的对应epitem节点激活到就绪队列,epoll_wait将就绪队列的节点对应的事件返回,此时内核不会立即将就绪队列中的节点移除,而是动态检查底层的接收缓冲区中的数据是否读取完毕,读取完毕了才会移除,否则不会。没有移除时,用户再次调用epoll_wait,由于就绪队列中仍有该事件的节点,所以epoll_wait仍会返回该事件。那么此时在用户看来,LT模式下,就算一次没有把数据读取完,下次epoll_wait还会返回该事件,可以慢慢处理数据。
如果是在ET模式下则相反,epoll_wait将就绪队列中的事件返回后,不管用户会不会处理完,内核会立即将队列中对应的节点移除,下次调用epoll_wait时,epoll_wait就不会把该事件返回了,这就迫使用户必须一次将数据读取完 ,由于缓冲区中的数据可能会很多,用户用于暂存数据的缓存不够,所以必须循环读取。而写入操作相比与LT模式则没什么变化,因为一次写入没有把数据写完是底层缓冲区的问题,不是用户的问题,缓冲区空间不够了才会一次写不完,把缓冲区写满也算处理完写事件。
需要补充的是,epoll结构是在内核中维护,内核随时可以操作就绪队列,所以即使用户没有再调用epoll_wait或者其它的接口,内核仍然可以选择是否移除就绪队列中的节点。
使用ET模式需要注意,要让epoll关心的所有fd必须设置为非阻塞。我们知道ET模式下是循环读取的,如果文件是阻塞式的,那么当缓冲区读完时,用于读取数据的接口(如read、recv)就会阻塞住,只要进程被IO接口阻塞了,那就不是多路转接。
ET与LT相比看起来没什么优势,但它可以通过独特的机制让程序员必须一次处理完所有数据,在TCP通信中,这样可以高效利用缓冲区空间、提高发送量,进而提高TCP的IO效率。