IO多路复用技术是为了解决进程或者线程阻塞到某个IO系统调用而出现的技术,使进程不阻塞某个特定的IO系统调用。
select(),poll(),epoll()都是I/O多路复用的机制。
I/O多路复用通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪,就是这个文件描述符进行读写操作之前),能够通知程序进行相应的读写操作。但select(),poll(),epoll()本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
与多线程和多进程相比,I/O 多路复用的最大优势是系统开销小,系统不需要建立新的进程或者线程,也不必维护这些线程和进程。
头文件:
#include
#include
#include
#include
函数原型:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数列表:
nfds: 要监视的文件描述符的范围,一般取监视的描述符数的最大值+1,如这里写 10, 这样的话,描述符 0,1, 2 …… 9 都会被监视,在 Linux 上最大值一般为1024。
readfd: 监视的可读描述符集合,只要有文件描述符即将进行读操作,这个文件描述符就存储到这。
writefds: 监视的可写描述符集合。
exceptfds: 监视的错误异常描述符集合
中间的三个参数 readfds、writefds 和 exceptfds 指定我们要让内核监测读、写和异常条件的描述字。如果不需要使用某一个的条件,就可以把它设为空指针( NULL )。
函数操作:
//清空集合
void FD_ZERO(fd_set *fdset);
//将一个给定的文件描述符加入集合之中
void FD_SET(int fd, fd_set *fdset);
//将一个给定的文件描述符从集合中删除
void FD_CLR(int fd, fd_set *fdset);
// 检查集合中指定的文件描述符是否可以读写
int FD_ISSET(int fd, fd_set *fdset);
timeout: 超时时间,它告知内核等待所指定描述字中的任何一个就绪可花多少时间。其 timeval 结构用于指定这段时间的秒数和微秒数。
//timecal结构体
struct timeval
{
time_t tv_sec; /* 秒 */
suseconds_t tv_usec; /* 微秒 */
};
这个参数有三种可能:
1)永远等待下去:仅在有一个描述字准备好 I/O 时才返回。为此,把该参数设置为空指针 NULL。
2)等待固定时间:在指定的固定时间( timeval 结构中指定的秒数和微秒数)内,在有一个描述字准备好 I/O 时返回,如果时间到了,就算没有文件描述符发生变化,这个函数会返回 0。
3)根本不等待(不阻塞):检查描述字后立即返回,这称为轮询。为此,struct timeval变量的时间值指定为 0 秒 0 微秒,文件描述符属性无变化返回 0,有变化返回准备好的描述符数量。
返回值:
成功:就绪描述符的数目,超时返回 0;
出错:-1
我们写这么一个例子,同时循环读取标准输入的内容,读取有名管道的内容,默认的情况下,标准输入没有内容,read()时会阻塞,同样的,有名管道如果没有内容,read()也会阻塞,我们如何实现循环读取这两者的内容呢?
最简单的方法是,同时监听2个文件描述符,一个循环读标准输入的内容,一个循环读有名管道的内容。
而在这里,我们通过 select() 函数实现这个功能:
#include
#include
#include
#include
#include
#include
int main(int argc, char *argv[])
{
fd_set rfds;
struct timeval tv;
int ret;
int fd;
ret = mkfifo("test_fifo", 0666); //创建有名管道
if(ret != 0){
perror("mkfifo:");
}
fd = open("test_fifo", O_RDWR); //读写方式打开管道
if(fd < 0){
perror("open fifo");
return -1;
}
ret = 0;
while(1)
{
FD_ZERO(&rfds); //清空
FD_SET(0, &rfds); //标准输入描述符 0 加入集合
FD_SET(fd, &rfds); //有名管道描述符 fd 加入集合
tv.tv_sec = 1;
tv.tv_usec = 0;
// 监视并等待多个文件(标准输入,有名管道)描述符的属性变化(是否可读)
// 没有属性变化,这个函数会阻塞,直到有变化才往下执行,这里没有设置超时
// FD_SETSIZE 为 的宏定义,值为 1024
ret = select(FD_SETSIZE, &rfds, NULL, NULL, NULL);
//ret = select(FD_SETSIZE, &rfds, NULL, NULL, &tv);
if(ret == -1) //出错
{
perror("select()");
}
else if(ret > 0) //准备就绪的文件描述符
{
char buf[100] = {0};
if( FD_ISSET(0, &rfds) ) //标准输入
{
read(0, buf, sizeof(buf));
printf("stdin buf = %s\n", buf);
}
else if( FD_ISSET(fd, &rfds) ) //有名管道
{
read(fd, buf, sizeof(buf));
printf("fifo buf = %s\n", buf);
}
}else if(0 == ret) // 超时
{
printf("time out\n");
}
}
return 0;
}
下面为上面例子的往有名管道写内容的示例代码:
#include
#include
#include
#include
#include
#include
#include
int main(int argc, char *argv[])
{
fd_set rfds;
struct timeval tv;
int ret;
int fd;
ret = mkfifo("test_fifo", 0666); //创建有名管道
if(ret != 0)
{
perror("mkfifo£º");
}
fd = open("test_fifo", O_RDWR); //读写方式打开管道
if(fd < 0)
{
perror("open fifo");
return -1;
}
while(1)
{
char *str = "this is for test";
write(fd, str, strlen(str)); //往管道里写内容
printf("after write to fifo\n");
sleep(5); //间隔5秒钟
}
return 0;
}
select()目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
select()的缺点在于:
1)每次调用 select(),都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大,同时每次调用 select() 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大。
2)单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 上一般为 1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
select() 和 poll() 系统调用的本质一样,前者在 BSD UNIX 中引入的,后者在 System V 中引入的。
poll() 的机制与 select() 类似,与 select() 在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是 poll() 没有最大文件描述符数量的限制(但是数量过大后性能也是会下降)。
poll() 和 select() 同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
所需头文件:
#include
函数原型:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
功能:
监视并等待多个文件描述符的属性变化。
参数:
fds:不同与 select() 使用三个位图来表示三个 fdset 的方式,poll() 使用一个 pollfd 的指针实现。
一个 pollfd 结构体数组,其中包括了你想测试的文件描述符和事件, 事件由结构中事件域 events 来确定,调用后实际发生的时间将被填写在结构体的 revents 域。
struct pollfd
{
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 实际发生了的事件 */
};
fd:每一个 pollfd 结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示 poll() 监视多个文件描述符。
fd是监听文件描述符的个数
events:每个结构体的 events 域是监视该文件描述符的事件掩码,由用户来设置这个域。
events 等待事件的掩码取值如下:
处理输入:
POLLIN 普通或优先级带数据可读
POLLRDNORM 普通数据可读
POLLRDBAND 优先级带数据可读
POLLPRI 最高优先级数据可读
处理输出:
POLLOUT 普通或优先级带数据可写
POLLWRNORM 普通数据可写
POLLWRBAND 最优先级带数据可写
处理错误:
POLLERR 发生错误
POLLHUP 发生挂起
POLLVAL 描述字不是一个打开的文件
poll() 处理三个级别的数据,普通 normal,优先级带 priority band,高优先级 high priority,这些都是出于流的实现。
POLLIN | POLLPRI 等价于 select() 的读事件,
POLLOUT | POLLWRBAND 等价于 select() 的写事件。
POLLIN 等价于 POLLRDNORM | POLLRDBAND,
POLLOUT 则等价于 POLLWRNORM 。
例如,要同时监视一个文件描述符是否可读和可写,我们可以设置 events 为 POLLIN | POLLOUT。
revents:revents 域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。
events 域中请求的任何事件都可能在 revents 域中返回。
每个结构体的 events 域是由用户来设置,告诉内核我们关注的是什么,而 revents 域是返回时内核设置的,以说明对该描述符发生了什么事件。
nfds: 用来指定第一个参数数组元素个数。
timeout: 指定等待的毫秒数,无论 I/O 是否准备好,poll() 都会返回。当等待时间为 0 时,poll() 函数立即返回,为 -1 则使 poll() 一直阻塞直到一个指定事件发生。
返回值:
成功时,poll() 返回结构体中 revents 域不为 0 的文件描述符个数;如果在超时前没有任何事件发生,poll()返回 0;
失败时,poll() 返回 -1,并设置 errno 为下列值之一:
EBADF:一个或多个结构体中指定的文件描述符无效。
EFAULT:fds 指针指向的地址超出进程的地址空间。
EINTR:请求的事件之前产生一个信号,调用可以重新发起。
EINVAL:nfds 参数超出 PLIMIT_NOFILE 值。
ENOMEM:可用内存不足,无法完成请求。
我们将上面的例子,改为用 poll() 实现:
#include
#include
#include
#include
#include
#include
int main(int argc, char *argv[])
{
int ret;
int fd;
struct pollfd fds[2]; //监视文件描述符结构体,2 个元素
ret = mkfifo("test_fifo", 0666); //创建有名管道
if(ret != 0)
{
perror("mkfifo£º");
}
fd = open("test_fifo", O_RDWR); //读写方式打开管道
if(fd < 0)
{
perror("open fifo");
return -1;
}
ret = 0;
//设置监听
fds[0].fd = 0; //监听标准输入
fds[1].fd = fd; //监听有名管道
fds[0].events = POLLIN; //普通或优先级带数据可读
fds[1].events = POLLIN; //普通或优先级带数据可读
while(1)
{
//监视并等待多个文件(标准输入,有名管道)描述符的属性变化(是否可读)
//没有属性变化,这个函数会阻塞,直到有变化才往下执行,这里没有设置超时
ret = poll(fds, 2, -1); //监听fd数组,监听文件描述符个数2,超时时间-1为阻塞
//ret = poll(&fd, 2, 1000);
if(ret == -1) //出错
{
perror("poll()");
}
else if(ret > 0) //准备就绪的文件描述符
{
char buf[100] = {0};
if( ( fds[0].revents & POLLIN ) == POLLIN ) //若是标准输入
{
read(0, buf, sizeof(buf));
printf("stdin buf = %s\n", buf);
}
else if( ( fds[1].revents & POLLIN ) == POLLIN ) //若是有名管道
{
read(fd, buf, sizeof(buf));
printf("fifo buf = %s\n", buf);
}
}
else if(0 == ret) //超时
{
printf("time out\n");
}
}
return 0;
}
poll() 的实现和 select() 非常相似,只是描述 fd 集合的方式不同,poll() 使用 pollfd 结构而不是 select() 的 fd_set 结构,其他的都差不多。
epoll 是在 2.6 内核中提出的,是之前的 select() 和 poll() 的增强版本。
相对于 select() 和 poll() 来说,epoll 更加灵活,没有描述符限制。
epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。
epoll 操作过程需要三个接口,分别如下:
#include
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
int close();
int epoll_create(int size);
功能:
该函数生成一个 epoll 专用的文件描述符(创建一个 epoll 的句柄)。
参数:
size: 用来告诉内核这个监听的数目一共有多大,参数 size 并不是限制了 epoll 所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。自从 linux 2.6.8 之后,size 参数是被忽略的,也就是说可以填只有大于 0 的任意值。需要注意的是,当创建好 epoll 句柄后,它就是会占用一个 fd 值,在 linux 下如果查看 /proc/ 进程 id/fd/,是能够看到这个 fd 的,所以在使用完 epoll 后,必须调用 close() 关闭,否则可能导致 fd 被耗尽。
返回值:
成功:epoll 专用的文件描述符
失败:-1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:
epoll 的事件注册函数,它不同于 select() 是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
参数:
epfd:epoll 专用的文件描述符,epoll_create()的返回值
op: 表示动作,用三个宏来表示:
EPOLL_CTL_ADD: 注册新的 fd 到 epfd 中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL: 从 epfd 中删除一个 fd;
fd:需要监听的文件描述符
event:告诉内核要监听什么事件,为struct epoll_event结构体。
struct epoll_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 可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET :将 EPOLL 设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里。
返回值:
成功:0
失败:-1
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
功能:
等待事件的产生,收集在 epoll 监控的事件中已经发送的事件,类似于 select() 调用。
参数:
epfd:epoll 专用的文件描述符,epoll_create()的返回值
events:分配好的 epoll_event 结构体数组,epoll 将会把发生的事件赋值到events 数组中(events 不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存)。
maxevents:maxevents 告之内核这个 events 有多大 。
timeout:超时时间,单位为毫秒,为 -1 时,函数为阻塞
返回值:
成功:返回需要处理的事件数目,如返回 0 表示已超时。
失败:-1
epoll 对文件描述符的操作有两种模式:LT(level trigger)和 ET(edge trigger)。LT 模式是默认模式,
LT 模式与 ET 模式的区别如下:
LT 模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。
ET 模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。
ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
接下来,我们将上面的例子,改为用 epoll 实现:
#include
#include
#include
#include
#include
#include
#include
int main(int argc, char *argv[])
{
int ret;
int fd;
ret = mkfifo("test_fifo", 0666); //创建有名管道
if(ret != 0)
{
perror("mkfifo£º");
}
fd = open("test_fifo", O_RDWR); //读写方式打开管道
if(fd < 0)
{
perror("open fifo");
return -1;
}
ret = 0;
struct epoll_event event; //告诉内核要监听什么事件
struct epoll_event wait_event;
int epfd = epoll_create(10); //创建一个 epoll 的句柄,参数要大于 0, 没有太大意义
if( -1 == epfd )
{
perror ("epoll_create");
return -1;
}
event.data.fd = 0; //标准输入
event.events = EPOLLIN; //监听读,表示对应的文件描述符可以读
//事件注册函数,将标准输入描述符 0 加入监听事件
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &event);
if(-1 == ret)
{
perror("epoll_ctl");
return -1;
}
event.data.fd = fd; //有名管道
event.events = EPOLLIN; //监听读,表示对应的文件描述符可以读
//事件注册函数,将标准输入描述符 fd 加入监听事件
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
if(-1 == ret)
{
perror("epoll_ctl");
return -1;
}
ret = 0;
while(1)
{
//监视并等待多个文件(标准输入,有名管道)描述符的属性变化(是否可读)
//没有属性变化,这个函数会阻塞,直到有变化才往下执行,这里没有设置超时
ret = epoll_wait(epfd, &wait_event, 2, -1);
//ret = epoll_wait(epfd, &wait_event, 2, 1000);
if(ret == -1) //出错
{
close(epfd);
perror("epoll");
}
else if(ret > 0) //准备就绪的文件描述符
{
char buf[100] = {0};
if( ( 0 == wait_event.data.fd ) && ( EPOLLIN == wait_event.events & EPOLLIN ) ) //标准输入
{
read(0, buf, sizeof(buf));
printf("stdin buf = %s\n", buf);
}
else if( ( fd == wait_event.data.fd ) && ( EPOLLIN == wait_event.events & EPOLLIN ) ) //有名管道
{
read(fd, buf, sizeof(buf));
printf("fifo buf = %s\n", buf);
}
}
else if(0 == ret) //超时
{
printf("time out\n");
}
}
close(epfd);
return 0;
}
在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而 epoll() 事先通过 epoll_ctl() 来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似 callback 的回调机制(软件中断),迅速激活这个文件描述符,当进程调用 epoll_wait() 时便得到通知。
epoll 的优点主要是一下几个方面:
1)监视的描述符数量不受限制,它所支持的 FD 上限是最大可以打开文件的数目,这个数字一般远大于 2048,举个例子,在 1GB 内存的机器上大约是 10 万左右,具体数目可以 cat /proc/sys/fs/file-max 察看,一般来说这个数目和系统内存关系很大。select() 的最大缺点就是进程打开的 fd 是有数量限制的。这对于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache 就是这样实现的),不过虽然 Linux 上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。
2)I/O 的效率不会随着监视 fd 的数量的增长而下降。select(),poll() 实现需要自己不断轮询所有 fd 集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而 epoll 其实也需要调用 epoll_wait() 不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪 fd 放入就绪链表中,并唤醒在 epoll_wait() 中进入睡眠的进程。虽然都要睡眠和交替,但是 select() 和 poll() 在“醒着”的时候要遍历整个 fd 集合,而 epoll 在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的 CPU 时间。这就是回调机制带来的性能提升。
3)select(),poll() 每次调用都要把 fd 集合从用户态往内核态拷贝一次,而 epoll 只要一次拷贝,这也能节省不少的开销。