前置知识

硬中断:由外部设备产生的异步中断,可能发生在任何时间。比如网卡通过DMA将报文写入内存后将发出硬中断,来请求CPU处理报文数据。这是一种典型的事件驱动的模型。

软中断:由CPU产生,比如在程序执行过程中出现错误时,会产生软中断以此来让出CPU。

NIO、BIO:在数据传输的过程中,通信的双方在把数据交给CPU前都会先把数据放到缓冲区中,输入有输入缓冲区,输出有输出缓冲区。现在有一个A事件想向剩余空间只有5KB输出缓冲区写完10KB的数据那自然是无法写入的,也即会触发阻塞。NIO的处理方式是给A返回一个EAGAIN error,之后开始执行B事件,C事件… 等到设置的A的重试时间到了的时候再次尝试写入A,而BIO的处理方式是无法写完时,A事件就一直等待直到缓冲区有空间能完全写入10KB数据,然后再去执行B事件。

用户态与内核态的转换:在程序执行的过程中如果涉及到一些硬件调用或者遇到了错误,就会出现用户态与内核态的转换。假设申请某一资源时,CPU先会保存现场,然后触发80中断进入内核态,根据具体的资源类型来使用不同的系统调用,当调用完毕拿到资源后,恢复现场变为内核态,继续执行指令。

I/O模型:《UNIX 网络编程》里总结归纳了 5 种 I/O 模型,包括同步和异步 I/O:

  • 阻塞 I/O (Blocking I/O)
  • 非阻塞 I/O (Nonblocking I/O)
  • I/O 多路复用 (I/O multiplexing)
  • 信号驱动 I/O (Signal driven I/O)
  • 异步 I/O (Asynchronous I/O)

操作系统上的 I/O 是用户空间和内核空间的数据交互,因此 I/O 操作通常包含以下两个步骤:

  1. 等待网络数据到达网卡(读就绪)/等待网卡可写(写就绪) –> 读取/写入到内核缓冲区
  2. 从内核缓冲区复制数据 –> 用户空间(读)/从用户空间复制数据 -> 内核缓冲区(写)

而判定一个 I/O 模型是同步还是异步,主要看第二步:数据在用户和内核空间之间复制的时候是不是会阻塞当前进程,如果会,则是同步 I/O,否则,就是异步 I/O。基于这个原则,这 5 种 I/O 模型中只有一种异步 I/O 模型:Asynchronous I/O,其余都是同步 I/O 模型。

I/O多路复用

I/O 多路复用指的就是 select/poll/epoll 这一系列的多路选择器:支持单一线程同时监听多个文件描述符(I/O 事件),阻塞等待,并在其中某个文件描述符可读写时收到通知。(select/epoll的最重要的区别也就在于它们如何返回这个通知) I/O 复用其实复用的不是 I/O 连接,而是复用线程,让一个 thread of control 能够处理多个连接(I/O 事件)。

select模型

在Linux中用SELECT函数实现I/O端口的复用,传递给SELECT函数的参数会告诉内核:

  • 文件描述符
  • 文件描述符的状态
  • 需要等待的时间(传Null,BIO行为,传0,NIO行为,传>0设置阻塞时间)

SELECT函数返回后内核告知以下信息:

  • 已经准备好的描述符的个数
  • 对于三种条件哪些描述符已经做好准备(读,写,异常)

有了这些信息就可以调用合适的I/O函数(read,write),并且这些函数不会阻塞。

1
2
#include <sys/select.h>
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, struct timeval *timeout);

maxfdp规定fd_set的最大有效位

fd_set 表示需要监听的文件描述符集,read,write,except表示需要满足的条件(可读,可写,异常),是一个bitmap类型每一位代表一个fd,最大可支持1024个位。

timeval等待时间

操作fd的宏有以下四个:

1
2
3
4
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);

一个select()调用的流程是这样的:

对fd集先执行清零操作FD_ZEOR(&set) set=0000

若有一个fd为2,置FD_SET(fd, &set) set=0010

另有一个fd为3,置FD_SET(fd, &set) set=0110

执行select(4, &set, 0, 0, 0)阻塞等待

若fd=2发生了可读事件,则select返回0010

上层应用通过比较这个返回值和一开始传入的&set来知道那些fd可用了。

select模型存在的问题

为了比较select()返回前后fd集的变化,我们必须拿一个数组arr纪录调用select()之前的fd集状态,select()返回后用FD_ISSET遍历返回值和arr,但是又由于不是所有的fd都被激活,我们还得动态的更新arr,然后再调用FD_ZERO清空fd集,然后置位,继续传给select()新的fd集。

简言之,select()调用前后都需要遍历arr,把fd集从用户态拷贝到内核态,同时监听过程中要顺序扫描fd集,当fd集很大时I/O性能自然也会线性下降。

epoll模型

epoll模型的I/O模型和select模型一样,都是采用I/O多路复用,使用一个线程去处理所有等待资源的I/O事件。

epoll相比于select更加灵活,epoll使用一个epoll空间来管理多个描述符,将用户关心的文件描述符的事件放到内核中的一个事件表中,就绪后放到就绪列表中,这样上层应用直接访问就绪列表即可,用户空间和内核空间的复制只需一次。

1
2
3
4
#include <sys/epoll.h>
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);

(1)epoll_cteate是一个系统函数,函数将在内核空间开辟一块新的空间,可以理解为epoll结构空间,返回值为epoll的文件描述符编号,方便后续操作使用。

(2)epoll_ctlepoll的事件注册函数,epollselect不同,select函数调用时需要指定监听的描述符和事件,epoll先是将用户感兴趣的事件描述符注册到epoll空间内,此函数是非阻塞函数,作用仅仅是增删改epoll空间内的描述符信息。

epfdepoll空间的进程fd编号,函数将依靠该编号找到对应的epoll空间。

op:表示请求类型由三个宏定义(EPOLL_CTL_ADD:注册新的fd到epfd中)、(EPOLL_CTL_MOD:修改已经注册的fd的监听事件)、(EPOLL_CTL_DEL:从epfd中删除一个fd)

fd:需要监听的文件描述符,一般指socket_fd

event:告诉内核该fd资源感兴趣的事件。

1
2
3
4
struct epoll_event {
_uint32_t_events; /*Epoll events*/
epoll_data_t data; /*User data variable*/
}

events可以是以下几个宏的集合:

EPOLLIN、EPOLLOUT、EPOLLPRI、EPOLLERR、EPOLLHUP(挂断)、EPOLLLET(边缘触发)、EPOLLONESHOT(只监听一次,事件触发后自动从epoll列表中清除该fd)

(3)epoll_wait等待事件的产生,类似于select()调用,根据参数timeout,来决定是否阻塞。

epfd:指定监听的epoll事件列表

*events是一个指针,必须指向一个epoll_event结构数组(这个数组在用户态),当函数返回时,内核就会把就绪状态的数据拷贝到该数组中。

maxevents:标明epoll_event数组最多能接受的数据量,即本次操作最多能获取多少就绪数据。

timeout:单位为毫秒。

 0:表示立即返回,非阻塞调用
-1:阻塞调用,直到用户感兴趣的事件就绪为止
>0:阻塞调用,阻塞指定时间内如果有事件就提前返回,否则等待指定时间后返回

返回值:本次就绪的fd个数。

epoll的高效之处在于它开辟epoll空间来避免监听一次就传一次fd集。

具体实现上epoll采用红黑树来存储所有监听的fd,红黑树的特点在于插入和删除性能比较稳定,时间复杂度O(logN),通过epoll_ctl添加进来的fd存储在红黑树的某个节点中,当fd被添加后会和相应的硬件驱动程序建立回调关系,也就是在内核中断处理程序中为它注册一个回调函数,在fd相应的事件触发后,内核调用这个回调函数,该回调函数在内核中成为ep_poll_callback这个回调函数会把这个fd添加到rdllist就绪链表中。epoll_wait实际上就是去检查这个rdllist来看是否有就绪的fd,当rdllist为空时挂起当前进程,知道rdllist非空时进程被唤醒并返回。

工作模式

epoll对文件描述符有两种模式:LT(水平触发)和ET(边缘触发)。LT模式是默认模式,LT模式与ET模式的区别如下

LT(水平触发):事件就绪后,用户可以选择处理或者不处理,如果用户本次未处理,那么下次调用epoll_wait时仍然会将未处理的事件打包给你。

ET(边缘触发):事件就绪后,用户必须处理,因为内核不再纪录未处理的事件,当内核把就绪的事件打包好给你后,就把对应的就绪事件清理掉了。

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率比LT模式高。