目录
问题一:从实现角度来解释为什么epoll的效率要远远高于poll/select
阻塞I/O模型
阻塞I模式/O
即在读写read()/write()数据过程中会发生阻塞现象。阻塞IO操作只能对单个文件描述符进行操作。
当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block状态。如果数据没有就绪,就会一直阻塞在read方法。
阻塞会带来一定的问题,比如在调用send()的时候,线程被阻塞,无法响应任何的网络请求,无法适应多客户机、多业务逻辑的网络编程。所以,对于多客户机的网络应用,可以在服务端使用多线程,这样任何一个连接的阻塞都不会影响其他的连接。
非阻塞模式I/O
非阻塞IO通过进程反复调用IO函数(多次系统调用,并马上返回);在数据拷贝的过程中,进程是阻塞的
我们在发起IO时,通过对文件描述符设置O_NONBLOCK flag来指定该文件描述符的IO操作为非阻塞。非阻塞IO通常发生在一个for循环当中,因为每次进行IO操作时要么IO操作成功,要么当IO操作会阻塞时返回错误EWOULDBLOCK/EAGAIN,然后再根据需要进行下一次的for循环操作,这种类似轮询的方式会浪费很多不必要的CPU资源,是一种糟糕的设计。和阻塞IO一样,非阻塞IO也是通过调用read或write来进行操作的,也只能对单个描述符进行操作。
多路复用I/O模型
I/O复用模型
多路IO转接服务器也叫多任务IO服务器。该类服务器实现的主旨思想是,不再由应用程序自己监视客户端连接,取而代之由内核替应用程序监视文件。
I/O复用模型会用到select、poll、epoll函数,这几个函数也会使进程阻塞,但是和阻塞I/O所不同的的,select和epoll两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。
select函数
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
其中:
nfds:监控的文件描述符集里最大文件描述符加1,因此参数会告诉内核检测前多少个文件描述符的状态
readfds:监控读数据到达文件描述符集合,传入传出参数
writefds:监控写数据到达文件描述符集合,传入传出参数
exceptfds:监控异常发生文件描述符集合,如外数据到达异常,传入传出参数
timeout:定时阻塞监控时间,3种情况
<1>NULL,永远等待,阻塞监听
<2>设置timeval,等待固定时间,设置监听超时时长
<3>设置timeval里时间均为0,检查描述字后立即返回,非阻塞监听,轮询
返回值:
> 0 :所有监听集合(3个)中,满足对应事件的总数
0:没有满足监听条件的文件描述符
-1:errno
select相关函数
//从集合中剔除一个文件描述符
void FD_CLR(int fd, fd_set *set);
//判断文件描述符是否在集合当中
int FD_ISSET(int fd, fd_set *set);
//将集合中增加一个文件描述符
void FD_SET(int fd, fd_set *set);
//位图置0
void FD_ZERO(fd_set *set);
select优缺点
缺点:监听上限受文件描述符限制。最大1024
检测满足条件的fd,默认通过轮询的方式去遍历,若最大文件描述符为1023,将轮询1024遍
优化方案:创建一个数组去存满足条件的fd,直接遍历给数组(数组设置上限防止数组越界,最大上限值为1024)
优点: 跨平台。win、liunx、macOS、Unix、类Unix都一样
优化后的select实现I/O转接服务器
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <ctype.h>
#include "wrap.h"
#define SERV_PORT 6666
int main(int argc, char *argv[])
{
int i, j, n, maxi;
int nready, client[FD_SETSIZE];
/* 自定义数组client, 防止遍历1024个文件描述符FD_SETSIZE默认为1024 */
int maxfd, listenfd, connfd, sockfd;
char buf[BUFSIZ], str[INET_ADDRSTRLEN]; /* #define INET_ADDRSTRLEN 16 */
struct sockaddr_in clie_addr, serv_addr;
socklen_t clie_addr_len;
fd_set rset, allset; /* rset 读事件文件描述符集合 allset用来暂存 */
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
//设置端口复用
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family= AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port= htons(SERV_PORT);
Bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
Listen(listenfd, 128);
maxfd = listenfd;/* 起初 listenfd 即为最大文件描述符 */
maxi = -1; /* 将来用作client[]的下标, 初始值指向0个元素之前下标位置 */
for (i = 0; i < FD_SETSIZE; i++)
client[i] = -1; /* 用-1初始化client[] */
FD_ZERO(&allset);
FD_SET(listenfd, &allset);/* 构造select监控文件描述符集 */
while (1) {
rset = allset; /*每次循环时都从新设置select监控信号集*/
nready = select(maxfd+1, &rset, NULL, NULL, NULL); //2 1--lfd 1--connfd
if (nready < 0)
perr_exit("select error");
if (FD_ISSET(listenfd, &rset)) { /* 说明有新的客户端链接请求 */
clie_addr_len = sizeof(clie_addr);
// Accept 不会阻塞
connfd = Accept(listenfd, (struct sockaddr *)&clie_addr, &clie_addr_len);
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &clie_addr.sin_addr, str, sizeof(str)),
ntohs(clie_addr.sin_port));
for (i = 0; i < FD_SETSIZE; i++)
if (client[i] < 0) { //找client[]中没有使用的位置
client[i] = connfd; //保存accept返回的文件描述符到client[]里
break;
}
if (i == FD_SETSIZE) {/* 达到select能监控的文件个数上限 1024 */
fputs("too many clients\n", stderr);
exit(1);
}
//向监控文件描述符集合allset添加新的文件描述符connfd
FD_SET(connfd, &allset);
if (connfd > maxfd)
maxfd = connfd; /* select第一个参数需要 */
if (i > maxi)
/* 保证maxi存的总是client[]最后一个元素下标 */
maxi = i;
if (--nready == 0)
continue;
}
for (i = 0; i <= maxi; i++) {
/* 检测哪个clients 有数据就绪 */
if ((sockfd = client[i]) < 0)
continue;
if (FD_ISSET(sockfd, &rset)) {
if ((n = Read(sockfd, buf, sizeof(buf))) == 0) {
/*当client关闭链接时,服务器端也关闭对应链接 */
Close(sockfd);
FD_CLR(sockfd, &allset); /* 解除select对此文件描述符的监控 */
client[i] = -1;
} else if (n > 0) {
for (j = 0; j < n; j++)
buf[j] = toupper(buf[j]);
Write(sockfd, buf, n);
Write(STDOUT_FILENO, buf, n);
}
if (--nready == 0)
break; /*跳出for, 但还在while中 */
}
}
}
Close(listenfd);
return 0;
}
poll函数
#include <poll.h>
/*
fds:监听的文件描述符【数组】
nfds:监听数组的,实际有效监听个数
timeout: 超时时长,单位:毫秒
-1:阻塞等待
0:立即返回,不阻塞进程
>0:等待指定毫秒数,若当前系统时间精度不够毫秒,向上取值
*/
//返回值:返回满足对应监听事件的文件描述符 总个数。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor 待监听的文件描述符*/
short events; /* requested events 待监听的文件描述符对应的监听事件
取值:POLLIN、POLLOUT、POLLERR
*/
short revents; /* returned events
传入时,给0,若满足对应事件的话,
返回非0 --> POLLIN、POLLOUT、POLLERR
*/
};
突破1024限制
可以使用cat命令查看一个进程可以打开的socket描述符上限
//当前计算机所能打开的最大文件个数。受硬件影响
cat /proc/sys/fs/file-max
//当前用户下的进程,默认打开文件描述符个数,open files为1024
ulimit -a
若有需要,可以通过修改配置文件的方式修改该上限值,修改完毕,注销用户,使配置生效
sudo vi /etc/security/limits.conf
在文件尾部写入以下配置,soft软限制,hard硬限制
soft nofile 65536
soft nofile 100000
poll优缺点
优点:自带数组结构。可以将监听事件集合和返回事件集合分离。
拓展监听上限。可以超出1024限制,能监控的最大上限数可使用配置文件调整
缺点:不能跨平台,只能在Linux·和 Unix上使用
无法直接定位满足监听事件的文件描述符,编码难度较大
epoll函数
epoll是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不是迫使开发者每次等待时间之前都必须重新准备要被监听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被监听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了
epoll除了提供select/poll那种IO事件的电平触发(Level Triggered)外,还提供了边沿触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。
epoll机制是通过红黑树和双向链表实现的,大致工作流程如下:
<1>首先epoll_create创建一个epoll文件描述符,底层同时创建一个红黑树,和一个就绪链表
<2>红黑树存储所监控的文件描述符的节点数据,就绪链表存储就绪的文件描述符的节点数据
<3>epoll_ctl将会添加新的描述符,首先判断是红黑树上是否有此文件描述符节点,如果有,则立即返回。如果没有, 则在树干上插入新的节点,并且告知内核注册回调函数。
<4>当接收到某个文件描述符过来数据时,那么内核将该节点插入到就绪链表里面。
<5>epoll_wait将会接收到消息,并且将数据拷贝到用户空间,清空链表
对于LT模式,epoll_wait清空就绪链表之后会检查该文件描述符是哪一种模式,如果为LT模式,且必须该节点确实有事件未处理,那么就会把该节点重新放入到刚刚删除掉的且刚准备好的就绪链表,epoll_wait马上返回。ET模式不会检查,只会调用一次
epoll_create:创建一棵监听红黑树
#include <sys/epoll.h>
/*
size:创建的红黑树的监听节点数量
返回值:指向新创建的红黑树的根节点的fd
失败:-1 errno
*/
int epoll_create(int size);
当某一进程调用epoll_create时,Linux内核会创建一个eventpoll结构体,后续如果我们再调用epoll_ctl和epoll_wait等,都是对这个eventpoll数据进行操作,这部分数据会被保存在epoll_create创建的匿名文件file的private_data字段中,即可快速定位到eventpoll对象啦。
struct eventpoll {
/* Protect the access to this structure */
spinlock_t lock;
/*
* This mutex is used to ensure that files are not removed
* while epoll is using them. This is held during the event
* collection loop, the file cleanup path, the epoll file exit
* code and the ctl operations.
*/
struct mutex mtx;
/* Wait queue used by sys_epoll_wait() */
//这个队列里存放的是执行epoll_wait从而等待的进程队列
wait_queue_head_t wq;
/* Wait queue used by file->poll() */
//这个队列里存放的是该eventloop作为poll对象的一个实例,加入到等待的队列
//这是因为eventpoll本身也是一个file, 所以也会有poll操作
wait_queue_head_t poll_wait;
/* List of ready file descriptors */
//双向链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件,
//链表的每个元素是下面的epitem!!!!!!!
struct list_head rdllist;
/* RB tree root used to store monitored fd structs */
//这是用来快速查找fd的红黑树!!!!!!
//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监控的事件!!
struct rb_root_cached rbr;
/*
* This is a single linked list that chains all the "struct epitem" that
* happened while transferring ready events to userspace w/out
* holding ->lock.
*/
struct epitem *ovflist;
/* wakeup_source used when ep_scan_ready_list is running */
struct wakeup_source *ws;
/* The user that created the eventpoll descriptor */
struct user_struct *user;
//这是eventloop对应的匿名文件,充分体现了Linux下一切皆文件的思想
struct file *file;
/* used to optimize loop detection check */
int visited;
struct list_head visited_list_link;
#ifdef CONFIG_NET_RX_BUSY_POLL
/* used to track busy poll napi_id */
unsigned int napi_id;
#endif
};
epoll_ctl:操作监听红黑树
#include <sys/epoll.h>
/*
epfd: epoll_create函数的返回值
op:对该监听红黑树所做的操作
EPOLL_CTL_ADD:添加fd到监听红黑树
EPOLL_CTL_MOD:修改fd在监听红黑树上的监听事件
EPOLL_CTL_DEL:将一个fd从监听红黑树上摘下(取消监听)
fd:待监听的fd
event:本质:struct epoll_event结构体的地址
events:确定事件
data:联合体:
fd:对应监听事件的fd
ptr:泛型指针,可用于构造具有回调函数(自定义结构体)
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//event结构体
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events确定事件
每当我们调用epoll_ctl增加一个fd时,内核就会为我们创建出一个epitem实例,并且把这个实例作为红黑树的一个子节点,增加到eventpoll结构体中的红黑树中,这之后,查找每一个fd上是否有事件发生都是通过红黑树上的epitem来操作。
struct epitem {
union {
/* RB tree node links this structure to the eventpoll RB tree */
struct rb_node rbn;
/* Used to free the struct epitem */
struct rcu_head rcu;
};
//将这个epitem连接到eventpoll 里面的rdllist的list指针
struct list_head rdllink;
/*
* Works together "struct eventpoll"->ovflist in keeping the
* single linked chain of items.
*/
struct epitem *next;
/* The file descriptor information this item refers to */
//epoll监听的文件描述符fd
struct epoll_filefd ffd;
/* Number of active wait queue attached to poll operations */
//一个文件可以被多个epoll实例所监听,这里就记录了当前文件被监听的次数
int nwait;
/* List containing poll wait queues */
struct list_head pwqlist;
/* The "container" of this item */
//当前epollitem所属的eventpoll
struct eventpoll *ep;
/* List header used to link this item to the "struct file" items list */
struct list_head fllink;
/* wakeup_source used when EPOLLWAKEUP is set */
struct wakeup_source __rcu *ws;
/* The structure that describe the interested events and the source fd */
struct epoll_event event;
};
epoll_wait:阻塞监听
#include <sys/epoll.h>
/*
epfd:epoll_create()返回值
events:用来存内核得到事件的集合,可简单看作【数组】,满足监听条件的那些fd结构体
maxevents:告知内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size
time:超时时间
-1:阻塞
0:立即返回,非阻塞
>0:指定毫秒
返回值:成功:返回有多少文件描述符就绪。可以用作循环上限
时间到时返回0,出错返回-1
*/
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
当我们调用 epoll_wait 的时候,调用进程被挂起,在内核看来调用进程陷入休眠。如果该 epoll 实例上对应描述字有事件发生,这个休眠进程应该被唤醒,以便及时处理事件。源码中使用wake_up_locked 函数唤醒当前 eventpoll 上的等待进程。
epoll实现I/O转接服务器
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <ctype.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <string.h>
#include <unistd.h>
#include "wrap.h"
#define MAXLINE 8192
#define SERV_PORT 6666
#define OPEN_MAX 1024
int main(int argc, char *argv[]){
int listenfd, i, cfd, n;
int num = 0;
//epfd,监听红黑树的树根
ssize_t nready, epfd, res;
char buf[BUFSIZ], str[INET_ADDRSTRLEN];
//tep,用来设置单个fd属性,ep是epoll_wait()传出的满足监听事件的数组
struct epoll_event tep, ep[OPEN_MAX];
struct sockaddr_in serv_addr,clit_addr;
socklen_t clit_addr_len;
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); //端口复用
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(SERV_PORT);
Bind(listenfd ,(struct sockaddr *)&serv_addr ,sizeof(serv_addr));
Listen(listenfd, 20);
epfd = epoll_create(OPEN_MAX);
if(epfd == -1)
perr_exit("epoll_create error");
//初始化,listenfd的监听属性
tep.events = EPOLLIN;
tep.data.fd = listenfd;
//将listenfd添加到监听红黑树上
res = epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &tep);
if(res == -1){
perr_exit("epoll_ctl error");
}
while(1){
nready = epoll_wait(epfd, ep, OPEN_MAX, -1);
if(nready == -1){
perr_exit("epoll_wait error");
}
for(i = 0;i < nready; i++){
if(!(ep[i].events & EPOLLIN))
continue;
if(ep[i].data.fd == listenfd){
clit_addr_len = sizeof(clit_addr);
cfd = Accept(ep[i].data.fd,(struct sockaddr *)&clit_addr,&clit_addr_len);
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET,&clit_addr.sin_addr,str,sizeof(str)),
ntohs(clit_addr.sin_port));
printf("cfd %d---client %d\n", cfd, ++num);
tep.events = EPOLLIN; tep.data.fd = cfd;
res = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &tep);
if(res == -1){
perr_exit("epoll_ctl error");
}
}else{
n = read(ep[i].data.fd, buf, sizeof(buf));
if(n == 0){
res = epoll_ctl(epfd, EPOLL_CTL_DEL, ep[i].data.fd, NULL);
if(res == -1){
perr_exit("epoll_ctl error");
}
close(ep[i].data.fd);
}else if(n < 0){
perror("read n < 0 error: ");
res = epoll_ctl(epfd, EPOLL_CTL_DEL, ep[i].data.fd, NULL);
close(ep[i].data.fd);
}else{
for(int j = 0;j<n;j++){
buf[j] = toupper(buf[j]);
}
write(ep[i].data.fd,buf,n);
}
}
}
}
close(listenfd);
close(epfd);
return 0;
}
epoll进阶概念
epoll的实现原理和流程
创建epoll对象
内核创建eventpoll对象
当进程调用epoll_create方法时,内核会创建一个eventpoll对象,也就是应用程序中的 epfd 所代表的对象。eventpoll对象也是文件系统中的一员(Linux中一切设备皆文件),和socket一样也拥有一个“等待队列”。
维护监视列表
创建epoll对象eventpoll之后,可以使用epoll_ctl添加或者删除所要监听的socket。
以添加socket为例,如果要对sock1、sock2、sock3进行监视,内核会将eventpoll添加到这三个socket的等待队列中。
当socket收到数据后,中断回调程序会操作eventpoll对象,而不是直接操作进程。
接收数据
select的低效原因之一在于应用程序不知道哪些socket收到数据,只能一个个的遍历。如果内核维护一个“就绪列表”,在就绪列表中引用收到数据的socket,就能避免遍历。
在eventpoll对象中就实现了一个“就绪列表” (rdlist)。
当socket收到数据,中断回调程序会给eventpoll的“就绪列表”添加socket的引用,如左图所示:
eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。
epoll_wait的返回条件也是根据rdlist的状态进行判断:
如果rdlist已经引用了socket,那么epoll_wait直接返回;
如果rdlist为空,阻塞进程。
阻塞和唤醒进程
如左图,假设当进程A运行到epoll_wait()时,操作系统会将进程A放入到eventpoll对象的等待队列中,阻塞进程。
(对于epoll,操作系统只需要将进程A放入eventpoll这一个对象的等待队列中;而对于select,操作系统则需要将进程A放入到socket列表中的所有socket对象的等待队列中。)
当socket接收到数据时,中断回调程序一方面修改rdlist“就绪列表”,另一方面唤醒eventpoll等待队列中的进程A。
也因为rdlist就绪列表的存在,进程A可以在重新进入运行态后准确知道哪些socket上发生了变化。
触发事件模型
EPOLL事件有两种模型:
<1> Edge Triggered(ET)边缘触发只有数据到来才触发,不管缓存区中是否还有数据,缓冲区剩余未读尽的数据不会导致epoll_wait返回触发,新的事件满足(产生),才会触发
一个事件从无到有时才会触发
ET常用场景:当一个文件为500M,项目开发只需要200M,考虑设置为ET模式
<2> Level Triggered(LT)水平触发只要有数据都会触发 ,缓冲区剩余未读尽的数据会导致epoll_wait返回触发
一个事件只要有,就会一直触发
ET与LT比较
LT(level triggered):LT是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行I/O操作。若你不作任何操作,内核还是会继续通知你的,所以这种模式出错误可能性要小一点。传统的select/poll都是这种模型的代表
ET(edge-triggered):ET是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。请注意,若一直不对这个fd作I/O操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。
EPOLLOUT事件:
内核中的socket发送缓冲区满 ------ 高电平
内核中的socket发送缓冲区不满 ------- 低电平
EPOLLIN事件:
内核中的socket接收缓冲区为空 ------- 低电平
内核中的socket接收缓冲区不为空 ------- 高电平
epoll的ET模式只支持非阻塞方式 ---- 忙轮询
event.events = EPOLLIN | EPOLLET; //ET边沿触发
event.events = EPOLLIN;//水平触发,默认为水平触发
//修改cfd为非阻塞读
flag = fcntl(cfd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(cfd, F_SETFL, flag);
epoll优缺点
优点:高效、突破1024文件描述符
缺点:不能跨平台,只支持Linux!!!
epoll、poll、select总结
系统调用 | select | poll | epoll |
事件集合 | 用户通过3个参数分别传入感兴趣的可读、可写及异常事件,内核通过对这些参数的在线修改来反馈其中的就绪事件。这使得用户每次调用select都要重置这3个参数 | 统一处理所有事件类型,因此只需一个事件集参数。用户通过pollfd.events传入感兴趣的事件,内核通过修改pollfd.revents反馈其中就绪的事件 | 内核通过一个事件表直接管理用户感兴趣的所有事件。因此每次调用epoll_wait时,无须反复传入用户感兴趣的事件,epoll_wait系统调用的参数events仅用来反馈就绪的事件 |
应用程序索引就绪文件 描述符的时间复杂度 | O(n) | O(n) | O(1) |
最大支持文件描述符数 | 一般是1024 | 65535 | 65535 |
工作模式 | LT | LT | LT/ET(高效模式) |
内核实现和工作效率 | 采用轮询方式来检测就绪事件,算法时间复杂度为O(n) | 采用轮询方式来检测就绪事件,算法时间复杂度为O(n) | 采用回调方式来检测就绪事件,算法时间复杂度为O(1) |
问题一:从实现角度来解释为什么epoll的效率要远远高于poll/select
首先,poll/select 先将要监听的 fd 从用户空间拷贝到内核空间, 然后在内核空间里面进行处理之后,再拷贝给用户空间。这里就涉及到内核空间申请内存,释放内存等等过程,这在大量 fd 情况下,是非常耗时的。而 epoll 维护了一个红黑树,通过对这棵黑红树进行操作,可以避免大量的内存申请和释放的操作,而且查找速度非常快。
如下代码为poll/select在内核空间申请内存的展示。可以看到select是先尝试申请栈上资源,若需要监听的fd比较多,就会去申请堆空间的资源。
int core_sys_select(int n, fd_set __user *inp, fd_set __user *outp,
fd_set __user *exp, struct timespec64 *end_time)
{
fd_set_bits fds;
void *bits;
int ret, max_fds;
size_t size, alloc_size;
struct fdtable *fdt;
/* Allocate small arguments on the stack to save memory and be faster */
long stack_fds[SELECT_STACK_ALLOC/sizeof(long)];
ret = -EINVAL;
if (n < 0)
goto out_nofds;
/* max_fds can increase, so grab it once to avoid race */
rcu_read_lock();
fdt = files_fdtable(current->files);
max_fds = fdt->max_fds;
rcu_read_unlock();
if (n > max_fds)
n = max_fds;
/*
* We need 6 bitmaps (in/out/ex for both incoming and outgoing),
* since we used fdset we need to allocate memory in units of
* long-words.
*/
size = FDS_BYTES(n);
bits = stack_fds;
if (size > sizeof(stack_fds) / 6) {
/* Not enough space in on-stack array; must use kmalloc */
ret = -ENOMEM;
if (size > (SIZE_MAX / 6))
goto out_nofds;
alloc_size = 6 * size;
bits = kvmalloc(alloc_size, GFP_KERNEL);
if (!bits)
goto out_nofds;
}
fds.in = bits;
fds.out = bits + size;
fds.ex = bits + 2*size;
fds.res_in = bits + 3*size;
fds.res_out = bits + 4*size;
fds.res_ex = bits + 5*size;
...
第二,select/poll从休眠中被唤醒时,若监听多个fd, 只要其中有一个fd有事件发生,内核就会遍历内部的list去检查到底是哪一个事件到达,并没有像epoll一样,通过fd直接关联eventpoll对象,快速地把fd直接加入到eventpoll的就绪列表中。
static int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
{
...
retval = 0;
for (;;) {
unsigned long *rinp, *routp, *rexp, *inp, *outp, *exp;
bool can_busy_loop = false;
inp = fds->in; outp = fds->out; exp = fds->ex;
rinp = fds->res_in; routp = fds->res_out; rexp = fds->res_ex;
for (i = 0; i < n; ++rinp, ++routp, ++rexp) {
unsigned long in, out, ex, all_bits, bit = 1, mask, j;
unsigned long res_in = 0, res_out = 0, res_ex = 0;
in = *inp++; out = *outp++; ex = *exp++;
all_bits = in | out | ex;
if (all_bits == 0) {
i += BITS_PER_LONG;
continue;
}
if (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE,
to, slack))
timed_out = 1;
...
假设进程A中调用了select函数,去监听sock1、sock2、sock3,首先将进程A从工作队列移除,然后这三个socket的等待队列都会加入进程A,进程A处于阻塞状态。注意这一步,需要监听多少socket,就需要将进程A加入多少个等待队列,由于需要去遍历所有的socket,因此时间话费较大。
一旦任何一个socket有了数据,就需要将进程A从所有的socket的等待队列移除,并加入到工作队列,进程A得以继续执行。这里也是需要去遍历所有的socket,因此开销也较大。
由于其中一个socket有数据到达,进程A被唤醒,重新加入到工作队列。需要注意的是,进程A被唤醒后,只知道有数据到达,但是并不知道哪些socket有数据到达,因此调用select函数之后,为了处理有数据的socket还得再遍历一遍所有的socket。
通过上面的过程分析,select效率较低最要是需要反复遍历所有的socket:
<1>调用select函数,进程阻塞,需要将进程加入到所有socket的等待队列,遍历所有的socket;
<2>当有数据到达时,进程被唤醒,将进程从所有的socket的等待队列移除,遍历所有的socket;
<3>为了处理有数据到达的socket,又得遍历所有的socket。