I/O复用
1、select
include <sys/select,h>
int select( int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
1)nfds参数指定被监听的文件描述符的总数。它通常被设置为 select监听的所有文件描述符中的最大值加1,因为文件描述符是从0开始计数的。
2) readfds、 writefds和 exceptfds参数分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用 select函数时,通过这3个参数传人自己感兴趣的文件描述符。select调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。
fd_set结构体仅包含一个整型数组,该数组的每个元素的每一位(bit)标记一个文件描述符。 fd_set能容纳的文件描述符数量由 FD_SETSIZE指定,这就限制了select能同时处理的文件描述符的总量,最大值是1024。
应该使用下面的一系列宏来访问 fd_set结构体中的位:
include <sys/select.h>
FD_ZERO(fd_set *fdset ) //清除 fast的所有位
FD_SET( int fd, fd_set *fdset) // 设置 fast的位fd
FD_CLR( int fd, fd_set *fdset) //·清除 fast的位fd
int FD_ISSET( int fd, fd_set* faset); //测试 fast的位fd是否被设置
2、poll
#include <poll.h>
int poll( struct pollfd* fds, nfds_t nfds, int timeout);
1)fds参数是一个 polled结构类型的数组,它指定所有我们感兴趣的文件描述符上发生的可读、可写和异常等事件。 polled结构体的定义如下:
struct pollfd{
int fd; //文件描述符
short events; //注册的事件
short revents; //实际发生的事件,由内核填充
}
其中,fd成员指定文件描述符;
events成员告诉poll监听fd上的哪些事件,它是一系列事件的按位或;
revents成员则由内核修改,以通知应用程序fd上实际发生了哪些事件。
poll支持的事件类型如下表所示。
事件 | 描述 | 是否可作为输入 | 是否可作为输出 |
---|---|---|---|
POLLIN | 数据(包括普通数据和优先数据)可读 | 是 | 是 |
POLLRDNORM | 普通数据可读 | 是 | 是 |
POLLRDBAND | 优先级带数据可读(Linux不支持) | 是 | 是 |
POLLPRI | 高优先级数据可读,比如TCP带外数据 | 是 | 是 |
POLLOUT | 数据(包括普通数据和优先数据)可写 | 是 | 是 |
POLLWRNORM | 普通数据可写 | 是 | 是 |
POLLWRBAND | 优先级带数据可写 | 是 | 是 |
POLLRDHUP | TCP连接被对方关闭,或者对方关闭了写操作,它由GNU引入 | 是 | 是 |
POLLERR | 错误 | 否 | 是 |
POLLHUP | 挂起。比如管道的写端被关闭后,读端描述符上将收到 POLLHUP事件 | 否 | 是 |
POLLNVAL | 文件描述符没有打开 | 否 | 是 |
2) nfds参数指定被监听事件集合fds的大小。其类型 nfds t的定义如下
typedef unsigned long int nfds_t
3) timeout参数指定pol.的超时值,单位是毫秒。
3、epoll
1)内核时间表
epoll是 Linux特有的I/O复用函数。它在实现和使用上与 select、pol有很大差异。
首先,epoll使用一组函数来完成任务,而不是单个函数。
其次, epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像 select和poll那样每次调用都要重复传入文件描述符集或事件集。但 epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。
这个文件描述符使用如下 epoll_create函数来创建:
#include <sys/epoll.h>
int epoll_create(int size);
size参数现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回描述符将用作其他所有 epoll系统调用的第一个参数,以指定要访问的内核事件表。
epoll_ctl用来操作epoll的内核时间表:
#include <sys/epoll.h>
int epoll_ctl( int epfd, int op, int fd, struct epoll_event *event);
fd参数是要操作的文件描述符,op参数则指定操作类型。操作类型有如下3种:
①EPOLL_CTL_ADD,往事件表中注册fd上的事件。
②EPOLL_CTL_MOD,修改fd上的注册事件
③ EPOLL_CTL_DEL,删除fd上的注册事件。
event参数指定事件,它是 epoll_event结构指针类型。 epoll_event的定义如下:
struct epoll_event{
__uint32_t events;/*epo11事件*/
epoll_data_t data;/*用户数据*/
};
其中 events成员描述事件类型。epoll支持的事件类型和poll基本相同。表示epoll事件类型的宏是在pol对应的宏前加上“E”,比如epoll的数据可读事件是 EPOLLIN。但epoll有两个额外的事件类型一 EPOLLET和 EPOLLONESHOT。
data成员用于存储用户数据,其类型 epoll_data t的定义如下:
typedef union epoll_data{
void* ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
fd指定事件所从属的目标文件描述符。
ptr成员可用来指定与fd相关的用户数据。
由于epoll_data_t是一个联合体,我们不能同时使用其ptr成员和fd成员。
2)epoll_wait
epoll_wait函数在一段超时时间内等待一组文件描述符上的事件,其原型如下:
#include <sys/epoll.h>
int epoll_wait( int epfd, struct epoll_event* events, int maxevents ,int timeout );
maxevents参数指定最多监听多少个事件,它必须大于0。
epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表(由epfd参数指定)中复制到它的第二个参数 events指向的数组中。这个数组只用于输出 epoll_wait检测到的就绪事件,而不像 select和poll的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率。
3)LT和ET模式
epoll对文件描述符的操作有两种模式:LT( Level trigger.,电平触发)模式和ET(Edge Trigger,边沿触发)模式。LT模式是默认的工作模式,这种模式下epoll相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的 EPOLLET事件时,epoll将以ET模式来操作该文件描述符。ET模式是epoll高效工作模式。
对于采用LT工作模式的文件描述符,当 epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时, epoll_wait还会再次向应用程序通告此事件,直到该事件被处理。
而对于采用ET工作模式的文件描述符,当 epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的 epoll_wait调用将不再向应用程序通知这一事件。
可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。
4、三种方法比较
select的参数类型 fd_set没有将文件描述符和事件绑定,它仅仅是一个文件描述符集合,因此 select需要提供3个这种类型的参数来分别传入和输出可读、可写及异常等事件。这一方面使得 select不能处理更多类型的事件,另一方面由于内核对 fd_set集合的在线修改,应用程序下次调用 select前不得不重置这3个fd_set集合。
poll的参数类型pollfd则多少“聪明”一些。它把文件描述符和事件都定义其中,任何事件都被统一处理,从而使得编程接口简洁得多。并且内核每次修改的是 polled结构体的 revents成员,而 events成员保持不变,因此下次调用poll时应用程序无须重置 polled类型的事件集参数。由于每次 select和poll调用都返回整个用户注册的事件集合(其中包括就绪的和未就绪的),所以应用程序索引就绪文件描述符的时间复杂度为O(n)。
epoll则采用与 select和poll完全不同的方式来管理用户注册的事件。它在内核中维护一个事件表,并提供了一个独立的系统调用 epoll_ctl来控制往其中添加、删除、修改事件。这样,每次epoll_wait调用都直接从该内核事件表中取得用户注册的事件,而无须反复从用户空间读入这些事件。 epoll_wait系统调用的 events参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度达到O(1)。
从实现原理上来说:
select和poll用的都是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法的时间复杂度是O(n)。
epoll_wait则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此epoll_wait无须轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度是O(1)。
但是,当活动连接比较多的时候, epoll_wait的效率未必比 select和poll高,因为此时回调函数被触发得过于频繁。所以 epoll_wait适用于连接数量多,但活动连接较少的情况。
5、补充
1)select的通常流程
代码
先准备一个数组(下面代码中的fds),让fds存放着所有需要监视的socket。然后调用select,如果fds中的所有socket都没有数据,select会阻塞,直到有一个socket接收到数据,select返回,唤醒进程。用户可以遍历fds,通过FD_ISSET判断具体哪个socket收到数据,然后做出处理。
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int fds[] = 存放需要监听的socket
while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}
流程图解
假如程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select之后,操作系统把进程A分别加入这三个socket的等待队列中。
当任何一个socket收到数据后,中断程序将唤起进程。下图展示了sock2接收到了数据的处理流程。
所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。如下图所示。
经由这些步骤,当进程A被唤醒后,它知道至少有一个socket接收了数据。程序只需遍历一遍socket列表,就可以得到就绪的socket。
缺点
①每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。
②进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。
2)epoll设计思想
功能分离
select低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。如下图所示,每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程。显而易见的,效率就能得到提升。
代码
先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。
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){
//处理
}
}
就绪列表
select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。如下图所示,计算机共有三个socket,收到数据的sock2和sock3被rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。
创建epoll对象
如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。创建一个代表该epoll的eventpoll对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为eventpoll的成员。
维护监视列表
创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。
接收数据
当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。
eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。
当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。
阻塞和唤醒
假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。