I/O多路复用支持同时在多个文件描述符上阻塞,并在其中某个可以读写时收到通知。因此I/O多路复用成为应用的关键所在,在设计上遵循如下原则。
1.I/O多路复用:当任何一个文件描述符I/O就绪时进行通知
2.在有可用的文件描述符之前一直处于睡眠状态。
3.唤醒:哪个文件描述符可用了?
4.处理所有I/O就绪的文件描述符,没有阻塞。
5.返回第一步重新开始。
原版英文:
Multiplexed I/O allows an application to concurrently block on multiple file descriptors, and receive notification when any one of them becomes ready to read or write without blocking.
Multiplexed I/O thus becomes the pivot point for the application, designed similarly to the following:
- Multiplexed I/O: Tell me when any of these file descriptors are ready for I/O.
- Sleep until one or more file descriptors are ready.
- Woken up: What is ready?
- Handle all file descriptors ready for I/O, without blocking.
- Go back to step 1, and start over.
Linux provides three multiplexed I/O solutions: the select, poll, and epoll interfaces.
示例代码中会阻塞等待stdin输入,超时时间设置为5秒,由于只监听了单个文件描述符,该例不算I/O多路复用,但它很清晰地说明了如何使用select.
select
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#define TIMEOUT 5 /* select timeout in seconds */
#define BUF_LEN 1024 /* read buffer in bytes */
int main (void)
{
struct timeval tv;
fd_set readfds;
int ret;
/* Wait on stdin for input. */
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO, &readfds);
/* Wait up to five seconds. */
tv.tv_sec = TIMEOUT;
tv.tv_usec = 0;
/* All right, now block! */
ret = select (STDIN_FILENO + 1,
&readfds,
NULL,
NULL,
&tv);
if (ret == -1)
{
perror ("select");
return 1;
} else if (!ret)
{
printf ("%d seconds elapsed.\n", TIMEOUT);
return 0;
}
/*
* Is our file descriptor ready to read?
* (It must be, as it was the only fd that
* we provided and the call returned
* nonzero, but we will humor ourselves.)
*/
if (FD_ISSET(STDIN_FILENO, &readfds))
{
char buf[BUF_LEN+1];
int len;
/* guaranteed to not block */
len = read (STDIN_FILENO, buf, BUF_LEN);
if (len == -1)
{
perror ("read");
return 1;
}
if (len)
{
buf[len] = '\0';
printf ("read: %s\n", buf);
}
return 0;
}
fprintf (stderr, "This should not happen!\n");
return 1;
}
gcc -g seletc.c
./a.out(然后输入内容,回车)
root@ubuntu:~# ./a.out
abcdefgh
read: abcdefgh
由于我们设置了超时时间为5秒,因此执行./a.out如果超过5秒没有输入,则会打印:
5 seconds elapsed.
select优点:
目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点
select缺点:
1.每次调用 select(),都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大,同时每次调用 select() 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大。
2.单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 上一般为 1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低
另一个需要注意的点是timeout参数:
On Linux, select() modifies timeout to reflect the amount of time not slept; most other implementations do not do this. (POSIX.1-2001 permits either behavior.) This causes prob‐lems both when Linux code which reads timeout is ported to other operating systems, and when code is ported to Linux that reuses a struct timeval for multiple select()s in a loop without reinitializing it. Consider timeout to be undefined after select() returns.
poll
select() 和 poll() 系统调用的本质一样,poll() 的机制与 select() 类似,与 select() 在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是 poll() 没有最大文件描述符数量的限制。poll() 和 select() 同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
#include <stdio.h>
#include <unistd.h>
#include <sys/poll.h>
#define TIMEOUT 5 /* poll timeout, in seconds */
int main (void)
{
struct pollfd fds[2];
int ret;
/* watch stdin for input */
fds[0].fd = STDIN_FILENO;
fds[0].events = POLLIN;
/* watch stdout for ability to write (almost always true) */
fds[1].fd = STDOUT_FILENO;
fds[1].events = POLLOUT;
/* All set, block! */
ret = poll (fds, 2, TIMEOUT * 1000);
if (ret == -1)
{
perror ("poll");
return 1;
}
if (!ret)
{
printf ("%d seconds elapsed.\n", TIMEOUT);
return 0;
}
if (fds[0].revents & POLLIN)
printf ("stdin is readable\n");
if (fds[1].revents & POLLOUT)
printf ("stdout is writable\n");
return 0;
}
示例程序同时检测stdin读和stdout写是否会发生阻塞,运行结果:
./a.out
stdout is writable
把一个存在的文件重定向到标准输入,可以看到如下执行结果:
./a.out <ode_to_my_parrot.txt
stdin is readable
stdout is writable
poll与select的区别
epoll精髓
对于poll()和select()每次调用时都需要所有被监听的文件描述符列表。内核必须遍历所有被监听的文件描述符。当这个文件描述符列表变得很大时,包含几百个甚至几千个文件描述符时每次调用都要遍历列表就变成规模上的瓶颈。
epoll把监听注册从实际监听中分离出来,从而解决了这个问题。一个系统调用会初始化epoll上下文,另一个从上下文中加入或删除监视的文件描述符,第三个执行真正的事件等待(event wait)。
在linux的网络编程中,很长的时间都在使用select来做事件触发。在linux新的内核中,有了一种替换它的机制,就是epoll。
相比于select,epoll最大的好处在于它不会随着监听fd数目的增长而降低效率。因为在内核中的select实现中,它是采用轮询来处理的,轮询的fd数目越多,自然耗时越多。并且,在linux/posix_types.h头文件有这样的声明:
#define __FD_SETSIZE 1024
表示select最多同时监听1024个fd,当然,可以通过修改头文件再重编译内核来扩大这个数目,但这似乎并不治本。
epoll的接口非常简单,一共就三个函数:
int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同于select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;
第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:
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队列里.
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1表示一直等到有事件发生才返回)。该函数返回需要处理的事件数目,如返回0表示已超时。
epoll的几点优点
1.监视的描述符数量不受限制,它所支持的 fd 上限是最大可以打开文件的数目,这个数字一般远大于 2048, 举个例子, 在 1GB 内存的机器上大约是 10 万左右,具体数目可以 cat /proc/sys/fs/file-max 察看, 一般来说这个数目和系统内存关系很大。select 的最大缺点就是进程打开的 fd 是有数量限制的。这对于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache 就是这样实现的),不过虽然 linux 上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。
2.IO 的效率不会随着监视 fd 的数量的增长而下降。epoll 不同于 select 和 poll 轮询的方式,而是通过每个 fd 定义的回调函数来实现的。只要有就绪的 fd 才会执行回调函数。
支持水平触发和边沿触发两种模式:
3.水平触发模式,文件描述符状态发生变化后,如果没有采取行动,它将后面反复通知,这种情况下编程相对简单,libevent 等开源库很多都是使用的这种模式。
边缘触发模式,只告诉进程哪些文件描述符刚刚变为就绪状态,只说一遍,如果没有采取行动,那么它将不会再次告知。理论上边缘触发的性能要更高一些,但是代码实现相当复杂(Nginx 使用的边缘触发)。