目录
近期在学完多进程、多线程编程后学习了多路复用select,poll,epoll,将三者做个对比总结。
一. 三者各自定义及使用
1. select
1.1 函数定义
int select(int max_fd, fd_set *readset, fd_set *writeset, fd_set *exceptset, struct timeval *timeout);
返回值和参数说明:
- 返回值:正常时返回就绪描述符的数目(大于0),超时返回0,有错误发生返回-1;
- max_fd:是待测试的fd的总个数,待测试的最大文件描述符+1。(因为文件描述符是从0开始的,此处max_fd为fd的总个数,需要把0的那个加上)。linux内核从0开始到max_fd-1扫描文件描述符。需要注意的是,若要检测的文件描述符是8,17,50,linux内核实际要从0开始监测到最大的fd,即实际监测0~50,所以max_fd是max(8,17,50)+1;
- readset:要让内核测试的读条件fd集合,若不需要的测试的可以设置为NULL;
- writeset:要让内核测试的写条件fd集合,若不需要的测试的可以设置为NULL;
- exceptset:要让内核测试的异常条件fd集合,若不需要的测试的可以设置为NULL;
- timeout:设置select阻塞的超时时间,若设为NULL则永不超时。
PS:
- select监视并等待多个文件描述符的属性并发生变化,监视的属性分别为readfds(文件描述符有数据到来可读),writefds(文件描述符可写),exceptfds(文件描述符异常)。
- 调用select函数会将程序阻塞在这里,直到有描述符就绪(有数据可读、可写、有错误异常)或超时(过了timeout指定时间但没有事件到来),发生函数才会返回。
- select()函数允许进程指示内核等待多个事件(文件描述符)中的任何一个发生,并只在有一个或多个事件发生或经历一段指定时间后才唤醒它,当select()函数返回后,可以通过遍历fdset来找到究竟是哪些文件描述符就绪,并进行相应的处理。
在Linux内核有个参数_FD_SETSIZE定义了每个FD_SET的句柄个数中,这也意味着select所用到的FD_SET是有限的,也正是这个原因select()默认只能同时处理1024个客户端的连接请求:
/linux/posix_types.h:
#define _FD_SETSIZE 1024
1.2 一些相关内容
//相关头文件
#include <sys/select.h>
#include <sys/time.h>struct timeval
{
long tv_sec;l l seconds
long tv_usec; l /microseconds
};//相关函数,也即使用顺序
FD_ZERO(fd_set* fds) //先清空集合
FD_SET(int fd,fd_set* fds) //将给定的描述符加入集合
FD_IsSET(int fd,fd_set* fds) //判断指定描述符是否在集合中FD_CLR(int fd,fd_set* fds) //将给定的描述符从文件中删除
//设定完后在程序中调用select
int select(int max_fd,fd_set *readset,fd_set *writeset,fd_set *exceptset,struct timeval *timeout);
1.3 使用过程伪代码
#include <sys/select.h>
#include <sys/time.h>
fd_set rdset;
...
FD_ZERO(&rdset); //先清空集合
FD_SET(testfd1,&rdset); //将给定的描述符加入集合
/* 视情况使用 */
FD_CLR(int fd,fd_set* fds);//将给定的描述符从文件中删除
rv = select(maxfd+1, &rdset, NULL, NULL, NULL); //select进行阻塞监测
if( FD_IsSET(testfd1,&rdset) )//判断出文件描述符,进行相应操作
{
...
}
else if( FD_IsSET(testfd2,&rdset) )
{
...
}
2. poll
2.1 poll函数的原型说明
#include <poll.h>
struct pollfd
{
int fd; //文件描述符
short events; //等待的事件,自己设定
short revents; //实际发生了的事件,内核处理后放到这里
};int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
① fds:指向一个struct pollfd类型的数组,每一个pollfd结构体指定了一个被监视的文件描述符,指示poll()监视多个文件描述符。
- fd:被监视的文件描述符;
- events域:监视该文件描述符的事件掩码,由用户来设置这个域;
- revents域:文件描述符的操作结果事件掩码,内核在调用返回时设置这个域,events域中请求的任何事件都可能在revents域中返回。下表列出指定events标志及测试revents标志的一些常值。
如果要同时监听多个事件多个fd,可将fds设为一个数组(struct pollfd fds_array[1024])
② nfds:指定数组中监听的元素个数。
③ timeout:
- timeout>0:指定等待的毫秒数。无论I/O是否准备好,poll都会返回;
- timeout<0:无限超时,使poll()一直挂起直到一个指定事件发生;
- timeout为0:poll调用立即返回并列出准备好I/O的文件描述符,但并不等待其他的事件。
PS:
- 该函数成功调用时,poll()返回结构体中revents域不为0的文件描述符个数;
- 如果在超时前没有任何事件发生,poll()返回0;
- 失败时,poll()返回-1,并设置errno为下列值之一:
- EBADF 一个或多个结构体中指定的文件描述符无效。
- EFAULTfds 指针指向的地址超出进程的地址空间。
- EINTR 请求的事件之前产生一个信号,调用可以重新发起。
- EINVALnfds 参数超出PLIMIT_NOFILE值。
- ENOMEM 可用内存不足,无法完成请求。
2.2 使用过程伪代码
#include <poll.h>
#define ARRAY_SIZE(x) (sizeof(x)/sizeof(x[0]))
struct pollfd fds_array[1024];
for( i=0; i<ARRAY_SIZE(fds_array); i++ ) //个人习惯先都设为负数
{
fds_array[i].fd=-1;
}
fds_array[0].fd = fd1;
fds_array[0].events = POLLIN; //设置多个可以写为POLLIN|POLLRDNORM
rv = poll(fds_array, max+1, -1);
if (fds_array[0].revents & POLLIN)
{
...
}
else
{
...
}
3.epoll
3.1 使用过程(因有点复杂这部分放前边)
epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。另一点原因就是获取事件的时候,它无须遍历整个被监听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。
epoll的设计和实现与select完全不同。
epoll通过在Linux内核中申请一个简易的文件系统,把原先的select/poll调用分成了3 个部分:
- 调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源);
- 调用epoll_ctl()向epoll对象中添加这100万个连接的套接字;
- 调用epoll_wait()收集发生的事件的连接。
3.2 相关函数
(1)创建epoll实例:epoll_create()
#include <sys/epoll.h>
int epoll_create(int size); //创建一个新的epoll实例,其对应的兴趣列表初始化为空
返回值:
若成功返回文件描述符,若出错返回-1。
作为函数返回值,epoll_create()返回了代表新创建的epoll实例的文件描述符。这个文件描述符在其他几个epoll系统调用中用来表示epoll实例。当这个文件描述符不再需要时,应该通过close()来关闭。当所有与epoll实例相关的文件描述符都被关闭时,实例被销毁,相关的资源都返还给系统。
参数说明:
- size:指定想要通过epoll实例来检查的文件描述符的个数。该参数并不是一个上限,而是告诉内核应该如何为内部数据结构划分初始大小。从Linux2.6.8版以来,size参数被忽略不用,该参数相当于无用。
PS:
从2.6.27版内核以来,Linux支持了一个新的系统调用epoll_create1()。该系统调用执行的任务同epoll_create()一样,但是去掉了无用的参数size,并增加了一个可用来修改系统调用行为的flags参数。目前只支持一个flag标志:EPOLL_CLOEXEC,它使得内核在新的文件描述符上启动了执行即关闭标志。
(2)修改epoll的兴趣列表:epoll_ctl()
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev);
//修改由文件描述符epfd所代表的epoll实例中的兴趣列表
返回值:
若成功返回0,若出错返回-1。
参数说明:
① epfd:是epoll_create()的返回值;
② op:指定需要执行的操作,它可以是如下几种值:
- EPOLL_CTL_ADD:将描述符fd添加到epoll实例中的兴趣列表中去。对于fd上我们感兴趣的事件,都指定在ev所指向的结构体中。如果我们试图向兴趣列表中添加一个已存在的文件描述符,epoll_ctl()将出现EEXIST错误;
- EPOLL_CTL_MOD:修改描述符上设定的事件,需要用到由ev所指向的结构体中的信息。如果我们试图修改不在兴趣列表中的文件描述符,epoll_ctl()将出现ENOENT错误;
- EPOLL_CTL_DEL:将文件描述符fd从epfd的兴趣列表中移除,该操作忽略参数ev。如果我们试图移除一个不在epfd的兴趣列表中的文件描述符,epoll_ctl()将出现ENOENT错误。关闭一个文件描述符会自动将其从所有的epoll实例的兴趣列表移除;
③ fd:指明了要修改兴趣列表中的哪一个文件描述符的设定。该参数可以是代表管道、FIFO、套接字、POSIX消息队 列、inotify实例、终端、设备,甚至是另一个epoll实例的文件描述符。但是,这里fd不能作为普通文件或目录的文件描述符;
④ ev:指向结构体epoll_event的指针,结构体的定义如下:
struct epoll_event {
uint32_t events; /* epoll events(bit mask) */
epoll_data_t data; /* User data */
};
typedef union epoll_data {
void *ptr; /* Pointer to user-defind data */
int fd; /* File descriptor */
uint32_t u32; /* 32-bit integer */
uint64_t u64; /* 64-bit integer */
} epoll_data_t;
参数ev为文件描述符fd所做的设置(epoll_event)如下:
- events字段:是一个位掩码,它指定了我们为待检查的描述符fd上所感兴趣的事件集合;
- data字段:是一个联合体,当描述符fd稍后称为就绪态时,联合的成员可用来指定传回给调用进程的信息。
(3)事件等待:epoll_wait
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
//系统调用epoll_wait()返回epoll实例中处于就绪态的文件描述符信息,单个epoll_wait()调用能够返回多个就绪态文件描述符的信息。
返回值:
调用成功后epoll_wait()返回数组evlist中的元素个数;
如果在timeout超时间隔内没有任何文件描述符处于就绪态的话就返回0;
出错时返回-1并在errno中设定错误码以表示错误原因。
参数说明:
① epfd:epoll_create()的返回值;
② evlist:所指向的结构体数组中返回的是有关就绪态文件描述符的信息,数组evlist的空间由调用者负责申请;
③ maxevents:指定evlist数组里包含的元素个数;
④ timeout:确定epoll_wait()的阻塞行为,有如下几种:
- timeout为-1,调用将一直阻塞,直到兴趣列表中的文件描述符上有事件产生或者直到捕获到一个信号为止。
- timeout为0,执行一次非阻塞式地检查,看兴趣列表中的描述符上产生了哪个事件。
- timeout>0,调用将阻塞至多timeout毫秒,直到文件描述符上有事件发生,或者直到捕获到一个信号为止。
数组evlist中,每个元素返回的都是单个就绪态文件描述符的信息。events字段返回了在该描述符上已经发生的事件掩码。 data字段返回的是我们在描述符上使用epoll_ctl()注册感兴趣的事件时在ev.data中所指定的值。注意,data字段是唯一可获知同这个事件相关的文件描述符的途径。因此,当我们调用epoll_ctl()将文件描述符添加到感兴趣列表中时,应该要么将ev.date.fd设为文件描述符号,要么将ev.date.ptr设为指向包含文件描述符号的结构体。
3.3 epoll提供的触发方式
epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。
LT(level triggered 水平触发)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。这种模式编程出错误可能性要小一点,传统的select/poll都是这种模型的代表。
ET (edge-triggered 边缘触发)是高速工作方式,只支持non-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通 过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些 操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一 个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的 通知(only once),不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认。
LT事件不会丢弃,只要读buffer里面有数据可以让用户读,则不断的通知你。而ET则只在事 件发生之时通知。可以简单理解为LT是水平触发,而ET则为边缘触发。LT模式只要有事件未处理就会触发,而ET则只在高低电平变换时(即状态从1到0或者0到1)触发。
3.4 使用过程伪代码
#include <sys/epoll.h>
#define MAX_EVENTS 512
/*
struct epoll_event
{
uint32_t events; /* epoll events(bit mask) */
epoll_data_t data; /* User data */
};
typedef union epoll_data
{
void *ptr; /* Pointer to user-defind data */
int fd; /* File descriptor */
uint32_t u32; /* 32-bit integer */
uint64_t u64; /* 64-bit integer */
} epoll_data_t;
*/
int epollfd; //新创建的epoll实例的文件描述符
struct epoll_event event; //感兴趣的事件添加在这个结构体中
struct epoll_event event_array[MAX_EVENTS]; //epoll_wait()返回有关就绪态文件描述符的信息(上文所谓数组evlist的空间由调用者负责申请指的就是像这里的定义数组)
int events; //存放在调用epoll_wait()后,数组event_array中的元素个数
if( ( epollfd = epoll_create(MAX_EVENTS) ) < 0 ) //创建epoll对象,返回值小于0则失败
{
...
}
//event.events = EPOLLIN|EPOLLET;
event.events = EPOLLIN; //向event中添加感兴趣的事件
event.data.fd = fd1; //添加监听的文件描述符
if( epoll_ctl(epollfd, EPOLL_CTL_ADD, fd1, &event) < 0) //修改epoll的兴趣列表,将fd1添加到epoll实例epollfd中去,返回小于0则失败
{
...
}
events = epoll_wait(epollfd, event_array, MAX_EVENTS, -1); //MAX_EVENTS是event_array数组里包含的元素个数
if( events < 0) //出错
{
...
}
else if( events == 0 ) //超时
{
...
}
else //成功后epoll_wait()返回数组event_array中的元素个数
{
for(i=0; i<events; i++)
{
if ( (event_array[i].events & EPOLLERR) || (event_array[i].events & EPOLLHUP) )
//出现异常情况,注意相应的关闭文件描述符之类的
{
printf("epoll_wait error: %s\n", strerror(errno));
epoll_ctl(epollfd, EPOLL_CTL_DEL, event_array[i].data.fd, NULL);
close(event_array[i].data.fd);
}
if( event_array[i].data.fd == fd1 ) //判断是哪个文件描述符发生事件
{
//做对应的操作
event.data.fd = connfd;
//event.events = EPOLLIN|EPOLLET;
event.events = EPOLLIN;
if( epoll_ctl(epollfd, EPOLL_CTL_ADD, connfd, &event) < 0 )
{
printf("epoll add failure: %s\n", strerror(errno));
close(event_array[i].data.fd);
...
}
}
else if( event_array[i].data.fd == fd2 )
{
...
}
}
}
二.对比总结
在linux 没有实现epoll事件驱动机制之前,我们一般选择用select或者poll等IO多路复用的方法来实现并发服务程序。自 Linux 2.6内核正式引入epoll以来,epoll已经成为了目前实现高性能网络服务器的必备技术,在大数据、高并发、集群等一些名 词唱得火热之年代,select和poll的用武之地越来越有限,风头已经被epoll占尽。
select的缺点:
- 单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫 描文件描述符,文件描述符数量越多,性能越差;
- 内核 / 用户空间内存拷贝问题,select需要复制大量的句柄数据结构,产生巨大的开销;
- select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
- select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调 用还是会将这些文件描述符通知进程。
相比select模型,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但其他三个缺点依然存在。拿select模型 为例,假设我们的服务器需要支持100万的并发连接,则在__FD_SETSIZE 为1024的情况下,则我们至少需要开辟1k个进程才能 实现100万的并发连接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的无脑内存拷贝、数组轮询等,是系统难以 承受的。因此,基于select模型的服务器程序,要达到10万级别的并发访问,是一个很难完成的任务。