在linux中有三个IO复用的函数或者说模型。这个就是poll,select/poll。他们为了节约CPU的资源,避免大量的时间进行等待IO的数据,为了让CPU可以同时服务多个IO口,然后就先后出现了select poll,epoll等三中IO复用的方式。
IO复用就是CPU的分时复用去处理IO数据,由于CPU的速度比IO读取速度快很多。所以,有的情境性下,当CPU需要处理多个IO的时候,就类似于一个保安监控多个地区的情况一样,那里有状况就去处理,没有状况就在监控室喝茶休息。IO复用也是这样的,平时CPU监控IO的状态,如果都没有准备就绪的数据,CPU就等待或者干其他的事情,一旦有了数据就开始处理数据。IO的数据通过网络或者是通过其他的输入设备进行输入,输入后进行DMA传输到内存,然后对应的设备就会通过异常中的中断的方式通知CPU有描述符准备就绪了。
一开始的时候,通过一种简单的方式来处理IO复用的情况,这个就select函数,通过,传入一组等待监控的文件描述符到CPU的内核中,每个文件描述符的状态分为等待和就绪,通过一个数的不同位数来表示。某个描述符对应的位数为高,就说明准备好了,如果为低,就说明没有准备好。让CPU循环的检测每个文件描述的状态,一旦有一个或者多个的文件描述符准备就绪就返回当前准备就绪的数量,并且将对应的位置高。然后用户就一个个的检查每个位数看看是否准备就绪。poll的过程和select是类似的,只不过传入的是一个struct pollfd结构体数组,CPU的内核也是一个个的轮训检查,如果有一个或者多个准备就绪也返回。
使用的函数有这么几个:
FD_ZERO(&read_set); //清空所有的位数
FD_SET(STDIN_FILENO,&read_set); //添加描述符
FD_SET(s,&read_set); //添加描述符
select(s+!, read_set,NULL,NULL,NULL);
由于每次返回后会对传入的监听集合进行修改,所以要 ready_set=read_set;保存一下
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
fd_set ready_set,read_set;
FD_ZERO(&read_set); //清空所有的位数
FD_SET(STDIN_FILENO,&read_set); //添加描述符
FD_SET(s,&read_set); //添加描述符
while(1){
ready_set=read_set; //因为传入后会修改原本的数据,所以要保存一下
int n = select(s+!, read_set,NULL,NULL,NULL);
for(int i=0; i < fds.count; i++){
if(FD_ISSET(read_set[i], ...)){
//ready_set[i]的数据处理
}
}
epoll就不太一样,epoll使用前需要用epoll_create()创建一个eventpoll 对象,这对象维护这一个红黑树和链表,红黑树上面是被监听的文件描述符,可以进行增删查找,链表上面是挂着准备就绪的描述符,是一个双向的链表,方便删除和增加。每次某个IO准备就绪的时候触发的时候,就可以通过红黑树查找到该文件描述符,然后在准备就绪的链表中挂上,同时唤醒对应的进程。进程被激活之后也可以通过这个链表直接指导有哪些的描述符是准备就绪的。
epoll的触发方式,可以分为两种,一个是水平触发,一个是边沿触发,和数电里面的比较像。
水平触发就是说只要缓冲器里面有数据,就说明这个描述符是可以读取或者是写入的,就会触发中断,
边沿触发对于读操作来说:(1)由不可读变成可以读取,(2)有新的数据来的时候,缓冲器变大了(3)当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时。对于写操作(1)当缓冲区由不可写变为可写时。(2)当有旧数据被发送走,即缓冲区中的内容变少的时候。(3)当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLOUT事件时。
epoll的大致流程如下:
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1){
int n = epoll_wait(...)
for(接收到数据的socket){
//处理
}
对比三个不同函数可以发现:
1. 用户态将文件描述符传入内核的方式
(1)select:创建3个文件描述符集并拷贝到内核中,分别监听读、写、异常动作。这里受到单个进程可以打开的fd数量限制,默认是1024。
(2)poll:将传入的struct pollfd结构体数组拷贝到内核中进行监听。
(3)epoll:执行epoll_create会在内核的高速cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点。
2. 内核态检测文件描述符读写状态的方式
(1)select:采用轮询方式,遍历所有fd,最后返回一个描述符读写操作是否就绪的mask掩码,根据这个掩码给fd_set赋值。
(2)poll:同样采用轮询方式,查询每个fd的状态,如果就绪则在等待队列中加入一项并继续遍历。
(3)epoll:采用回调机制。在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。
3. 找到就绪的文件描述符并传递给用户态的方式
(1)select:将之前传入的fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
(2)poll:将之前传入的fd数组拷贝传出用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
(3)epoll:epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可。这里返回的文件描述符是通过mmap让内核和用户空间共享同一块内存实现传递的,减少了不必要的拷贝
4. 重复监听的处理方式
(1)select:将新的监听文件描述符集合拷贝传入内核中,继续以上步骤。
(2)poll:将新的struct pollfd结构体数组拷贝传入内核中,继续以上步骤。
(3)epoll:无需重新构建红黑树,直接沿用已存在的即可
5.epoll更高效的原因
(1)select和poll的动作基本一致,只是poll采用链表来进行文件描述符的存储,而select采用fd标注位来存放,所以select会受到最大连接数的限制,而poll不会。
(2)select、poll、epoll虽然都会返回就绪的文件描述符数量。但是select和poll并不会明确指出是哪些文件描述符就绪,而epoll会。造成的区别就是,系统调用返回后,调用select和poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪,而epoll则直接处理即可。(3)select、poll都需要将有关文件描述符的数据结构拷贝进内核,最后再拷贝出来。而epoll创建的有关文件描述符的数据结构本身就存于内核态中,系统调用返回时利用mmap()文件映射内存加速与内核空间的消息传递:即epoll使用mmap减少复制开销。select、poll采用轮询的方式来检查文件描述符是否处于就绪态,而epoll采用回调机制。造成的结果就是,随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多。
(4)epoll的边缘触发模式效率高,系统不会充斥大量不关心的就绪文件描述符
虽然epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
参考文献:[1] https://baijiahao.baidu.com/s?id=1641172494287388070&wfr=spider&for=pc
参考文献:[2] https://blog.csdn.net/armlinuxww/article/details/92803381