IO多路复用——select,poll,epoll
IO多路复用是一种操作系统技术,旨在提高系统处理多个输入输出操作的性能和资源利用率。与传统的多线程或多进程模型相比,IO多路复用避免了因阻塞IO而导致的资源浪费和低效率问题。它通过将多个IO操作合并到一个系统调用中,允许程序同时等待多个文件描述符(如sockets、文件句柄等)变为可读或可写状态,然后再执行实际的IO操作。
在IO多路复用的实现中,常用的系统调用包括select()
、poll()
和epoll()
。这些机制允许程序监视多个描述符,一旦某个描述符就绪(通常是读就绪或写就绪),程序就会被通知进行相应的读写操作。这个过程通常涉及两个阶段:
-
等待数据到达:程序等待数据从IO设备传输到内核空间。在这个阶段,IO多路复用的系统调用会阻塞,直到至少有一个描述符准备好进行IO操作。
-
数据复制:当一个或多个描述符就绪时,程序负责将数据从内核空间复制到用户空间(进程或线程的缓冲区)。这第二个阶段是实际的读写操作,它在IO多路复用的上下文中是同步的,因为程序需要自己执行数据的读写。
尽管select()
、poll()
和epoll()
都是同步IO操作,但它们提供了一种有效的方式来处理并发IO,降低了系统开销,并提高了并发处理能力。与此不同,异步IO(AIO)模型进一步简化了IO操作,因为它允许操作系统自动处理数据从内核到用户空间的复制过程,无需程序显式调用读写操作。这意味着在异步IO模型中,读写操作由操作系统在后台完成,从而进一步提高了应用程序的效率和响应性。
select
概述
-
系统提供了select函数来实现多路复用输入/输出模型
-
select系统调用是用来让我们的程序监视多个文件描述符的状态变化的
-
程序会停在select函数等待,直到被监视的文件描述符有一个或者多个发生了状态改变。
函数
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
-
函数参数:
参数 | 说明 |
---|---|
nfds | 是需要监视的最大的文件描述符值+1 |
readfds | 需要检测的可读文件描述符的集合 |
writefds | 需要检测的可写文件描述符的集合 |
exceptfds | 需要检测的异常文件描述符的集合 |
timeout | 当timeout等于NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件; 当timeout为0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。 当timeout为特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回。 |
返回 | —— |
> 0 | 返回文件描述词状态已改变的个数 |
== 0 | 代表在描述词状态改变前已超过timeout时间,没有返回 |
< 0 | 错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测,错误值可能为: EBADF:文件描述词为无效的或该文件已关闭 EINTR:此调用被信号所中断 EINVAL:参数n 为负值 ENOMEM:核心内存不足 |
-
其中:可读,可写,异常文件描述符的集合是一个fd_set类型,fd_set是系统提供的位图类型,位图的位置是否是1,表示是否关系该事件。例如:
输入时:假如我们要关心 0 1 2 3 文件描述符
0000 0000->0000 1111 比特位的位置,表示文件描述符的编号
比特位的内容 0 or 1 表示是否需要内核关心
输出时:
0000 0100->此时表示文件描述符的编号
比特位的内容 0 or 1哪些用户关心的fd 上面的读事件已经就绪了,这里表示2描述符就绪了
-
系统提供了关于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的全部位
执行流程:
-
执行fd_set set; FD_ZERO(&set);则set用位表示是0000,0000。
-
若fd=5,执行FD_SET(fd,&set);后set变为0001,0000(第5位置为1) 。
-
若再加入fd=2,fd=1,则set变为0001,0011 。
-
执行select(6,&set,0,0,0)阻塞等待,表示最大文件描述符+1是6,监控可读事件,立即返回。
-
若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011。注意:没有事件发生的fd=5被清空。
优缺点
-
优点:
-
可监控的文件描述符个数取决与sizeof(fd_set)的值。一般大小是1024,但是fd_set的大小可以调整。
-
将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd。①是用于再select 返回后,array作为源数据和fd_set进行FD_ISSET判断。②是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。
缺点:
-
每次调用select, 都需要手动设置fd集合, 从接口使用角度来说也非常不便。
-
每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
-
同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
-
select支持的文件描述符数量太小。
实例
#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/select.h>
const static int MAXLINE = 1024;
const static int SERV_PORT = 10001;
int main()
{
int i , maxi , maxfd, listenfd , connfd , sockfd ;
/*nready 描述字的数量*/
int nready ,client[FD_SETSIZE];
int n ;
/*创建描述字集合,由于select函数会把未有事件发生的描述字清零,所以我们设置两个集合*/
fd_set rset , allset;
char buf[MAXLINE];
socklen_t clilen;
struct sockaddr_in cliaddr , servaddr;
/*创建socket*/
listenfd = socket(AF_INET , SOCK_STREAM , 0);
/*定义sockaddr_in*/
memset(&servaddr , 0 ,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(listenfd, (struct sockaddr *) & servaddr , sizeof(servaddr));
listen(listenfd , 100);
/*listenfd 是第一个描述字*/
/*最大的描述字,用于select函数的第一个参数*/
maxfd = listenfd;
/*client的数量,用于轮询*/
maxi = -1;
/*init*/
for(i=0 ;i<FD_SETSIZE ; i++)
client[i] = -1;
FD_ZERO(&allset);
FD_SET(listenfd, &allset);
for (;;)
{
rset = allset;
/*只select出用于读的描述字,阻塞无timeout*/
nready = select(maxfd+1 , &rset , NULL , NULL , NULL);
if(FD_ISSET(listenfd,&rset))
{
clilen = sizeof(cliaddr);
connfd = accept(listenfd , (struct sockaddr *) & cliaddr , &clilen);
/*寻找第一个能放置新的描述字的位置*/
for (i=0;i<FD_SETSIZE;i++)
{
if(client[i]<0)
{
client[i] = connfd;
break;
}
}
/*找不到,说明client已经满了*/
if(i==FD_SETSIZE)
{
printf("Too many clients , over stack .\n");
return -1;
}
FD_SET(connfd,&allset);//设置fd
/*更新相关参数*/
if(connfd > maxfd) maxfd = connfd;
if(i>maxi) maxi = i;
if(nready<=1) continue;
else nready --;
}
for(i=0 ; i<=maxi ; i++)
{
if (client[i]<0) continue;
sockfd = client[i];
if(FD_ISSET(sockfd,&rset))
{
n = read(sockfd , buf , MAXLINE);
if (n==0)
{
/*当对方关闭的时候,server关闭描述字,并将set的sockfd清空*/
close(sockfd);
FD_CLR(sockfd,&allset);
client[i] = -1;
}
else
{
buf[n]='\0';
printf("Socket %d said : %s\n",sockfd,buf);
write(sockfd,buf,n); //Write back to client
}
nready --;
if(nready<=0) break;
}
}
}
return 0;
}
poll
概述
-
poll和select实现原理基本类似
-
poll只为了解决select的两个硬伤:①等待的fd是有上限的,(底层类似链表储存实现,而不是位图)。②每次要对关心的fd进行事件重置,(pollfd结构包含了要监视的event和发生的event,使用前后不用初始化fd_set)。
函数
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 | 是一个poll函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合 |
nfds | 表示fds数组的长度 |
timeout | 表示poll函数的超时时间, 单位是毫秒(ms) |
返回 | —— |
> 0 | 表示poll由于监听的文件描述符就绪而返回 |
== 0 | 表示poll函数等待超时 |
< 0 | 表示出错 |
优缺点
-
优点:
-
pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式. 接口使用比 select更方便。
-
poll并没有最大数量限制 (但是数量过大后性能也是会下降)。
缺点:
-
和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
-
每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中。
-
同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效率也会线性下降。
实例
#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <poll.h>
#define MAXLINE 1024
#define OPEN_MAX 16 //一些系统会定义这些宏
#define SERV_PORT 10001
int main()
{
int i , maxi ,listenfd , connfd , sockfd ;
int nready;
int n;
char buf[MAXLINE];
socklen_t clilen;
struct pollfd client[OPEN_MAX];
struct sockaddr_in cliaddr , servaddr;
listenfd = socket(AF_INET , SOCK_STREAM , 0);
memset(&servaddr,0,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(listenfd , (struct sockaddr *) & servaddr, sizeof(servaddr));
listen(listenfd,10);
client[0].fd = listenfd;
client[0].events = POLLRDNORM;
for(i=1;i<OPEN_MAX;i++)
{
client[i].fd = -1;
}
maxi = 0;
for(;;)
{
nready = poll(client,maxi+1,INFTIM);
if (client[0].revents & POLLRDNORM)
{
clilen = sizeof(cliaddr);
connfd = accept(listenfd , (struct sockaddr *)&cliaddr, &clilen);
for(i=1;i<OPEN_MAX;i++)
{
if(client[i].fd<0)
{
client[i].fd = connfd;
client[i].events = POLLRDNORM;
break;
}
}
if(i==OPEN_MAX)
{
printf("too many clients! \n");
}
if(i>maxi) maxi = i;
nready--;
if(nready<=0) continue;
}
for(i=1;i<=maxi;i++)
{
if(client[i].fd<0) continue;
sockfd = client[i].fd;
if(client[i].revents & (POLLRDNORM|POLLERR))
{
n = read(client[i].fd,buf,MAXLINE);
if(n<=0)
{
close(client[i].fd);
client[i].fd = -1;
}
else
{
buf[n]='\0';
printf("Socket %d said : %s\n",sockfd,buf);
write(sockfd,buf,n); //Write back to client
}
nready--;
if(nready<=0) break; //no more readable descriptors
}
}
}
return 0;
}
epoll
概述
-
epoll:是为处理大批量句柄而作了改进的poll(真的是大改进)
-
epoll是IO多路复用技术,在实现上维护了一个用于返回触发事件的Socket的链表和一个记录监听事件的红黑树,epoll的高效体现在:
-
对监听事件的修改是 logN(红黑树)。
-
用户程序无需遍历所有的Socket(发生事件的Socket被放到链表中直接返回)。
-
内核无需遍历所有的套接字,内核使用回调函数在事件发生时直接转到对应的处理函数。
函数
-
epoll_create:创建一个epoll的句柄,用完之后, 必须调用close()关闭。
int epoll_create(int size);
-
epoll_ctl:它不同于select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型。
int epoll_ctl(int epfd, int op, int fd, struct epoll_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_data_t data;
} EPOLL_PACKED;
-
events参数的宏集合:
EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭)。
EPOLLOUT : 表示对应的文件描述符可以写。
EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来)。
EPOLLERR : 表示对应的文件描述符发生错误。
EPOLLHUP : 表示对应的文件描述符被挂断。
EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里
-
函数参数:
参数 | 说明 |
---|---|
epfd | epoll_create()的返回值(epoll的句柄) |
op | 表示动作,用三个宏来表示: EPOLL_CTL_ADD :注册新的fd到epfd中 EPOLL_CTL_MOD :修改已经注册的fd的监听事件 EPOLL_CTL_DEL :从epfd中删除一个fd |
fd | 需要监听的fd |
event | 内核需要监听的事件 |
-
epoll_wait:收集在epoll监控的事件中已经发送的事件
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
参数 | 说明 |
---|---|
epfd | epoll_create()的返回值(epoll的句柄) |
events | 是分配好的epoll_event结构体数组。epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存) |
maxevents | 通知内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size |
timeout | 超时时间 (毫秒,0会立即返回,-1是永久阻塞) |
返回 | —— |
> 0 | 返回对应I/O上已准备好的文件描述符数目 |
== 0 | 表示已超时 |
< 0 | 表示失败 |
执行流程:
-
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。
-
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。
-
这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。
-
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法。
-
这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。
-
在epoll中,对于每一个事件,都会建立一个epitem结构体。
-
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。
-
如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户. 这个操作的时间复杂度是O(1)。
优缺点
-
优点:
-
接口使用方便: 虽然拆分成了三个函数,但是反而使用起来更方便高效,不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离开。
-
数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中,这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)。
-
事件回调机制: 避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪,这个操作时间复杂度O(1),即使文件描述符数目很多,效率也不会受到影响。
-
没有数量限制: 文件描述符数目无上限。
缺点:
-
不能跨平台,epoll 是 Linux 特有的 API,不太容易移植到其他操作系统上
实例
#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <poll.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/time.h>
#include <sys/resource.h>
#define MAXLINE 1024
#define OPEN_MAX 16 //一些系统会定义这些宏
#define SERV_PORT 10001
int main()
{
int i , maxi ,listenfd , connfd , sockfd ,epfd, nfds;
int n;
char buf[MAXLINE];
struct epoll_event ev, events[20];
socklen_t clilen;
struct pollfd client[OPEN_MAX];
struct sockaddr_in cliaddr , servaddr;
listenfd = socket(AF_INET , SOCK_STREAM , 0);
memset(&servaddr,0,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(listenfd , (struct sockaddr *) & servaddr, sizeof(servaddr));
listen(listenfd,10);
epfd = epoll_create(256);
ev.data.fd=listenfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
for(;;)
{
nfds=epoll_wait(epfd,events,20,500);
for(i=0; i<nfds; i++)
{
if (listenfd == events[i].data.fd)
{
clilen = sizeof(cliaddr);
connfd = accept(listenfd , (struct sockaddr *)&cliaddr, &clilen);
if(connfd < 0)
{
perror("connfd < 0");
exit(1);
}
ev.data.fd=connfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
}
else if (events[i].events & EPOLLIN)
{
if ( (sockfd = events[i].data.fd) < 0)
continue;
n = recv(sockfd,buf,MAXLINE,0);
if (n <= 0)
{
close(sockfd);
events[i].data.fd = -1;
}
else
{
buf[n]='\0';
printf("Socket %d said : %s\n",sockfd,buf);
ev.data.fd=sockfd;
ev.events=EPOLLOUT|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,connfd,&ev);
}
}
else if( events[i].events&EPOLLOUT )
{
sockfd = events[i].data.fd;
send(sockfd, "Hello!", 7, 0);
ev.data.fd=sockfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
}
else
{
printf("This is not avaible!");
}
}
}
close(epfd);
return 0;
}
总结
-
select
和poll
是两种传统的 I/O 多路复用技术,它们允许服务器应用程序同时监控多个网络连接,以便在连接准备就绪时进行读写操作。尽管这两种技术在处理大量并发连接时非常有用,但随着连接数的增加,它们的性能会逐渐下降,因为它们需要在每次调用时遍历整个文件描述符集合,这在连接数非常多时会导致效率问题。 -
为了解决这个问题,
epoll
作为select
和poll
的一种改进方案,在 Linux 系统中被引入。epoll
提供了一种更为高效的事件驱动模型,它可以显著提高处理大量并发连接的性能。与select
和poll
不同,epoll
不会对整个文件描述符集合进行线性遍历,而是使用一组特殊的数据结构来跟踪哪些文件描述符已经准备好 I/O 操作。这种机制使得epoll
能够快速地通知应用程序哪些连接是活跃的,而无需对所有连接进行不必要的检查。 -
epoll
的另一个优点是它能够处理大量文件描述符而不会显著增加资源消耗,这使得它非常适合需要处理成千上万甚至更多并发连接的高性能网络服务器。因此,在 Linux 系统上,epoll
常被视为select
和poll
的替代方案,特别是在构建高性能网络应用程序时。
- END -
往期推荐:点击图片即可跳转阅读
《YY3568 Debian11+RT-Thread混合内核部署》
《YY3568多核异构(Linux+RT-Thread)--启动流程》