三组I O复用函数比较 - Huoke/Linux-net-Programma GitHub Wiki
三组I/O复用函数的比较
前面我们讨论了select、poll和epoll三组I/O复用系统调用, 这3组系统调用都能同时监听多个文件描述符。它们将等待由timeout参数指定的超时时间,直到一个或者多个文件描述符上有事件发生时返回,返回值是就绪的文件描述符的数量。返回0 表示没有事件发生。
现在我们从事件集、最大支持文件描述符数量、工作模式和具体实现等四个方面进一步比较它们的异同。
这3组函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果。
select、poll和epoll
1、select
select的参数类型 fd_set 没有将文件描述符和事件绑定,它仅仅是一个文件描述符集合,因此select需要提供3个这种类型的参数来分别传入和输出可读、可写和异常等事件。这一方面使得select不能处理更多类型的事件,另一方面由于内核对fd_set集合的在线修改,应用程序下次调用select前不得不重置这3个fd_set集合。
2、poll
poll 的参数类型 pollfd 则多少 “ 聪明 ” 一些。把文件描述符和事件都定义其中,任何事件都被统一处理,从而使得编程接口简洁很多。并且内核每次修改的是pollfd结构体的 revents成员, 而events成员保持不变,因此下次调用poll时应用程序无须重置pollfd类型的事件集参数。
3、epoll
由于每次select 和poll 调用都返回整个用户注册的事件集合(其中包括就绪的和尾就绪的),所以应用程序索引就绪文件描述符的时间复杂度O(n)。 epoll 则采用与select 和poll 完全不同的方式来管理用户注册的事件。它在内核中维护一个事件表,并提供了一个独立的系统调用epoll_ctl来控制往内核表中添加、删除、修改事件。这样,每次epoll_wait 调用都直接从内核事件表中取得用户注册的事件,而无需反复从用户空间读入这些事件。epoll_wait 系统调用的 events 参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度到O(1)。
看看代码就好理解:
/*select */
while (1) {
memset (buf, '\n', siezof(buf));
// 每次调用select前都要重新设置字符集中的文件描述符,因为事件发生之后,文件描述符集合将被内核修改
FD_SET(connfd, &read_fds);
FD_SET(connfd, &exception_fds);
ret = select(connfd+1, &read_fds, NULL, &exception_fds, NULL);
if( ret < 0) {
printf("selection failure\n");
break;
}
// 对于可读事件,采用普通的recv函数读取数据
if (FD_ISSET( connfd, &read_fds)) {
ret = recv(connfd, buf, sizeof(buf)-1, 0 );
if(ret <= 0) {
break;
}
printf("get %d bytes of normal data:%s\n",ret, buf);
} else {
// 异常事件可以采用带MSG_OOB标志的recv函数读取带外数据
ret = recv(connfd, buf, sizeof(buf)-1, MOS_OOB);
if(ret <=0) {
break;
}
printf("get %d bytes of oob data: %s\n",ret, buf);
}
}
/*epoll*/
epoll_event events[MAX_EVENT_NUMBER];
int epollfd = epoll_create(5);
/* 注意,监听socket listendfd上是不是能注册EPOLLONESHOT 事件的, 否则应
用程序只能处理一个客户连接!因为后续的客户连续请求将不再触发listenfd
上的 EPOLLIN 事件 */
Addfd(epollfd, listenfd, false);
while(1) {
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
if( ret<0 ) {
printf("epoll failure\n");
break;
}
for(int i=0; i <ret; i++) {
int sockfd = events[i].data.fd;
if(sockfd == listenfd) {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr*)&client_address
, &client_addrlength);
// 对每个非监听文件描述符都注册EPOLLONESHOT
Addfd(epollfd, connfd, true);
} else if( events[i].events & EAGAIN) {
pthread_t thread;
Fds fdsForNewWorker;
fdsForNewWorker.epollfd = epollfd;
fdsForNewWorker.sockfd = sockfd;
/* 启动一个新的工作线程为sockfd 服务 */
pthread_create(&thread, NULL, worker, (void*)&fdsForNewWorker);
} else {
printf("something else happened\n");
}
}
}
poll 和 epoll_wait 分别用nfds 和maxevents参数指定最多监听多少个文件描述符和事件。这两个数值都能达到系统允许打开的最大文件描述符数目,即65535(cat/proc/sys/fs/file-max)。而 select 允许监听的最大文件描述符数量通常是有限制。虽然用户可以修改这个限制,但这可能导致不可预期的后果。
select 和 poll 都只能工作在相对低效的LT 模式,而 epoll 则可以工作在ET高效模式。并且epoll 还支持EPOLLONESHOT事件。该事件能进一步减少可读、可写和异常等事件被触发的次数。
从实现原理上来说,select 和poll 采用的都是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法的时间复杂度是O(n)。epoll_wait 则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此epoll_wait 无需轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度是O(1)。 但是,当活动连接比较多的时候,epoll_wait 的效率未必比 select 和 poll 高, 因为此时回调函数被触发得过于频繁。所以epoll_wait 适用于连接数量多,但活动连接较少的情况。
最后,我们将这3组I/O复用系统调用的区别总结于下表
系统调用 | select | poll | epoll |
---|---|---|---|
事件结合 | 用户通过3个参数分别传入感兴趣的可读、可写和异常等事件,内核通过对这些参数的在线修改来反馈其中的就绪事件。这些使得用户每次调用select都要重置这3个参数 | 统一处理所有事件类型,因此只需要一个事件集参数,用户通过pollfd.events传入感兴趣的事件,内核通过修改pollfd.revents反馈其中就绪的事件 | 内核通过一个事件表直接管理用户感兴趣的所有事件。因此每次调用epoll_wait时,无须反复传入用户感兴趣的事件。epoll_wait系统调用的参数events仅用来反馈就绪的事件 |
应用程序索引就绪文件描述符的时间复杂度 | O(n) | O(n) | O(1) |
最大支持文件描述符数 | 一般有最大值限制 | 65535 | 65535 |
工作模式 | LT | LT | 支持ET高效模式 |
内核实现和工作效率 | 采用轮询方式来检测就绪事件,算法时间复杂度为O(n) | 采用轮询方式来检测就绪事件,算法时间复杂度为O(n) | 采用回调方式来检测就绪事件,算法时间复杂度为O(1) |