I/O 多路复用(IO multiplexing)它通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件)可以执行 I/O 操作时,能够通知应用程序进行相应的读写操作。
这句话表明,是先有可用的文件描述符,然后才有监视目标文件描述符这么个动作。
监视的目的是看被监视的文件描述符对应的设备是否有数据可以读写,其实主要是读操作。
I/O 多路复用一般用于并发式的非阻塞 I/O,也就是多路非阻塞 I/O,譬如程序中既要读取鼠标、又要读取键盘,多路读取,又或者用于服务器上读取多个客户端的socket连接操作等等。
可参考这篇文章体会其好处以及实现原理:
9.2 I/O 多路复用:select/poll/epoll | 小林coding (xiaolincoding.com)
select/poll/epoll 是内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合。
poll函数
我们先来看看poll函数。
poll()函数原型如下所示:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
函数参数含义如下:
fds:指向一个 struct pollfd 类型的数组(注意,是结构体数组,就是用来放需要监视的多个fd和其对应的事件),数组中的每个元素都会指定一个文件描述符以及我们对该文件描述符所关心的条件,稍后介绍 struct pollfd 结构体类型。 注意,都是把已经准备好的文件描述符放到epoll里面来监视,对于socket来说,是把已经建立连接的socket放进该数组中,也就是说,数组里的都是要监视的目标文件描述符。
nfds:参数 nfds 指定了 fds 数组中的元素个数,数据类型 nfds_t 实际为无符号整形。
timeout:该参数用于决定 poll()函数的阻塞行为,具体用法如下:
⚫ (常用)如果 timeout 等于-1,则 poll()会一直阻塞,直到 fds数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号时返回。
⚫ 如果 timeout 等于 0,poll()不会阻塞,只是执行一次检查看看哪个文件描述符处于就绪态。
⚫ 如果 timeout 大于 0,则表示设置 poll()函数阻塞时间的上限值,意味着 poll()函数最多阻塞 timeout毫秒,直到 fds 数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号为止。
struct pollfd 结构体
struct pollfd 结构体如下所示:
struct pollfd {
int fd;
/* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
fd 是一个文件描述符,struct pollfd 结构体中的 events 和 revents 都是位掩码,调用者初始化 events 来指定需要为文件描述符 fd 做检查的事件。当 poll()函数返回时,revents 变量由 poll()函数内部进行设置,用于说明文件描述符 fd 发生了哪些事件(注意,poll()没有更改 events 变量),我们可以对 revents 进行检查,判断文件描述符 fd 发生了什么事件。
这句话说明,我们在一开始需要表明要监视的文件描述符fd以及其对应的事件events,至于是否真的发生了对应的动作,则通过revents变量来指示。
应将每个数组元素的 events 成员设置为表 13.2.1 中所示的一个或几个标志,多个标志通过位或运算符( | )组合起来,通过这些值告诉内核我们关心的是该文件描述符的哪些事件。同样,返回时,revents 变量由内核设置为表13.2.1 中所示的一个或几个标志。
表 13.2.1 中第一组标志(POLLIN、POLLRDNORM、POLLRDBAND、POLLPRI、POLLRDHUP)与数据可读相关;第二组标志(POLLOUT、POLLWRNORM、POLLWRBAND)与可写数据相关;而第三组标志(POLLERR、POLLHUP、POLLNVAL)是设定在 revents 变量中用来返回有关文件描述符的附加信息,如果在 events 变量中指定了这三个标志,则会被忽略(注意,这些错误标志是文件描述符的错误信息,而不是poll函数的返回)。
如果我们对某个文件描述符上的事件不感兴趣,则可将 events 变量设置为 0;另外,将 fd 变量设置为文件描述符的负值(取文件描述符 fd 的相反数-fd),将导致对应的 events 变量被 poll()忽略,并且 revents变量将总是返回 0,这两种方法都可用来关闭对某个文件描述符的检查。
在实际应用编程中,一般用的最多的还是 POLLIN 和 POLLOUT。对于其它标志这里不再进行介绍了,后面章节内容中,如果需要使用时再给大家介绍!
poll()函数返回值
poll()函数返回值含义与 select()函数的返回值是一样的,有如下几种情况:
⚫ 返回-1 表示有错误发生,并且会设置 errno。
⚫ 返回 0 表示该调用在任意一个文件描述符成为就绪态之前就超时了。
⚫ 返回一个正整数表示有一个或多个文件描述符处于就绪态了,返回值表示 fds 数组中返回的 revents变量不为 0 的 struct pollfd 对象的数量。
poll函数实现的原理
内核将用户的fds结构体数组拷贝到内核中,当有事件发生时内核再将所有事件都返回到用户fds数组中;
polll函数只返回已就绪事件的个数,所以用户要操作就绪事件,就得用轮询的方法,
注意:poll函数是阻塞函数,当没有就绪文件描述符的时候,poll一直处于阻塞状态,直到有就绪文件描述符。
注意,poll还是需要结合轮询使用,只不过,每次轮询到poll的时候,如果没有数据,就会阻塞,有数据时才会继续执行,然后就判断返回的就绪文件描述符的数量,如果大于零,就说明有数据可读。
那么,怎么知道是哪个文件描述符发生了事件呢?
需要从头到尾一个一个地遍历!!!
因为poll只能返回就绪文件描述符的数量,而并不能反映出来是具体哪个文件描述符。
遍历时,判断结构体数据各元素变量的revents变量,当 poll()函数返回时,revents 变量由 poll()函数内部进行设置,用于说明文件描述符 fd 发生了哪些事件(注意,poll()没有更改 events 变量),我们可以对 revents 进行检查,判断文件描述符 fd 发生了什么事件。
判断某个文件描述符是否就绪之后,就需要结合read/write函数来进行数据的读写。
使用示例
示例代码 13.2.3 演示了使用 poll()函数来实现 I/O 多路复用操作,同时读取键盘和鼠标。
struct pollfd 结构体的 events 变量和 revents 变量都是位掩码,所以可以使用"revents & POLLIN"按位与的方式来检查是否发生了相应的 POLLIN 事件,判断鼠标或键盘数据是否可读。测试结果:
poll的优缺点
优点
(1)poll() 不要求开发者计算最大文件描述符加一的大小。
(2)poll() 在应付大数目的文件描述符的时候速度更快,相比于select。
(3)它没有最大连接数的限制,原因是它是基于链表来存储的。
(4)在调用函数时,只需要对参数进行一次设置就好了
缺点
(1)大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义(epoll可以解决此问题)
(2)与select一样,poll需要轮询pollfd来获取就绪的描述符,这样会使性能下降
(3)同时连接的大量客户端在一时刻可能只有很少的就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降
在使用 select()或 poll()时需要注意一个问题,当监测到某一个或多个文件描述符成为就绪态(可以读或写)时,需要执行相应的 I/O 操作(read或者write),以清除该状态,否则该状态将会一直存在。譬如示例代码 13.2.3,当 poll()成功返回时,检查文件描述符是否处于就绪态,如果文件描述符上可执行 I/O 操作时,也需要对文件描述符执行 I/O 操作,以清除就绪状态。
epoll函数
参考:
https://www.jianshu.com/p/ee381d365a29
https://xiaolincoding.com/os/8_network_system/selete_poll_epoll.html#epoll
epoll一般在socket编程中用的比较多,用于服务端监控多个客户端的连接。
epoll 的API用来执行类似poll()的任务。相比poll,epoll更高效。
epoll的接口也比较简单,一共就3/4个函数:
int epoll_create(int size);
int epoll_create1(int flags);
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);
从下图你可以看到 epoll 相关的接口作用:
epoll 通过两个方面,很好解决了 select/poll 的问题。
第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过
epoll_ctl()
函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是O(logn)
。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数来让内核将其加入到这个就绪事件列表中,当用户调用
epoll_wait()
函数时,虽然也是返回有事件发生的文件描述符的个数,但不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。接下来介绍API的详细使用
创建epoll
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
epoll_create() 可以创建一个epoll实例。在linux 内核版本大于2.6.8 后,参数size 参数就被弃用了,但是传入的值必须大于0。
在 epoll_create () 的最初实现版本时, size参数的作用是创建epoll实例时候告诉内核需要使用多少个文件描述符。内核会使用 size 的大小去申请对应的内存(如果在使用的时候超过了给定的size, 内核会申请更多的空间)。现在,这个size参数不再使用了(内核会动态的申请需要的内存)。但要注意的是,这个size必须要大于0,为了兼容旧版的linux 内核的代码。
epoll_create() 会返回一个epoll对象的文件描述符。这个文件描述符用于后续的epoll操作。如果不需要使用这个描述符,请使用close关闭。
epoll_create1() 扩展了epoll_create() 的功能,如果参数flags的值是0,epoll_create1()等同于epoll_create();flasg也可以使用 EPOLL_CLOEXEC,EPOLL_CLOEXEC标志与open 时的O_CLOEXEC 标志类似,即进程被替换时会关闭打开的文件描述符。
需要注意的是,epoll_create与epoll_create1当创建好epoll句柄后,它也会占用一个fd值,在linux下如果查看/proc/<pid>/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
设置epoll事件
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctrl是将要监视的文件描述符关联到epoll对象中。
epfd是epoll文件描述符;
op是添加事件的类型;
fd是要监视的目标文件描述符
op参数表示动作,用三个宏来表示: EPOLL_CTL_ADD,注册新的fd到epfd中 EPOLL_CTL_DEL,从epfd中删除一个fd EPOLL_CTL_MOD,修改已经注册的fd的监听事件
event是一个结构体指针,这个参数是用于关联指定的fd文件描述符的。它的定义如下:
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 */
};
看起来和poll的第一个参数有些像,poll的第一个参数是个结构体数组,我们把要监测的文件描述符以及事件放到结构体里,再把这个结构体放到待监视的列表数组中。
不过,epoll中是通过epoll_ctrl来添加的,放的时候,前三个参数都很好理解,最后一个结构体参数封装了对应的事件events,而且另外多了个data,data是个联合体,可以用来存放一些用户数据,比如,可以把目标fd在这里面也放一份。还可以用*ptr指针来存放更多的用户数据。
epoll_data_t是一个共用体,其4个成员中使用最多的是fd,它指定事件所从属的目标文件描述符。ptr成员可以用来指定与fd相关的用户数据。但由于epoll_data_t是一个共用体,我们不能同时使用其ptr成员和fd成员,因此,如果要将文件描述符和用户数据关联起来,以实现快速的数据访问,只能放弃使用epoll_data_t的fd成员,而在ptr指向的用户数据中包含fd。也就是说,epoll中可以通过epoll_ctrl的联合体data中的ptr指针来传入除fd之外更多的用户数据(注意,这个是用户数据,并不是接收到的数据,别搞混了)。
events和poll的很像,如下是可以用的事件:
EPOLLIN - 当关联的文件可以执行 read ()操作时。
EPOLLOUT - 当关联的文件可以执行 write ()操作时。
EPOLLRDHUP - (从 linux 2.6.17 开始)当socket关闭的时候,或者半关闭写段的(当使用边缘触发的时候,这个标识在写一些测试代码去检测关闭的时候特别好用)
EPOLLPRI - 当 read ()能够读取紧急数据的时候。
EPOLLERR - 当关联的文件发生错误的时候,epoll_wait() 总是会等待这个事件,并不是需要必须设置的标识。
EPOLLHUP - 当指定的文件描述符被挂起的时候。epoll_wait() 总是会等待这个事件,并不是需要必须设置的标识。当socket从某一个地方读取数据的时候(管道或者socket),这个事件只是标识出这个已经读取到最后了(EOF)。所有的有效数据已经被读取完毕了,之后任何的读取都会返回0(EOF)。
EPOLLET - 设置指定的文件描述符模式为边缘触发,默认的模式是水平触发。
EPOLLONESHOT - (从 linux 2.6.17 开始)设置指定文件描述符为单次模式。这意味着,在设置后只会有一次从epoll_wait() 中捕获到事件,之后你必须要重新调用 epoll_ctl() 重新设置。
返回值:如果成功,返回0。如果失败,会返回-1, errno将会被设置
有以下几种错误:
EBADF - epfd 或者 fd 是无效的文件描述符。
EEXIST - op是EPOLL_CTL_ADD,同时 fd 在之前,已经被注册到epoll中了。
EINVAL - epfd不是一个epoll描述符。或者fd和epfd相同,或者op参数非法。
ENOENT - op是EPOLL_CTL_MOD或者EPOLL_CTL_DEL,但是fd还没有被注册到epoll上。
ENOMEM - 内存不足。
EPERM - 目标的fd不支持epoll。
等待epoll事件
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events,int maxevents, int timeout,const sigset_t *sigmask);
一般使用epoll_wait即可。
events:结构体指针, 一般是一个数组(注意和epoll_ctrl的最后一个参数区分,epoll_ctrl里的就是一个结构体指针,epoll_wait里的是一个用来存放已就绪的目标结构体的列表)每次epoll_wait() 返回的时候,会包含用户在epoll_ctl中设置的events,就是放到这个数组里。这个数组需要我们自行创建,然后传递到epoll_wait里面去。
maxevents:事件的最大个数, 或者说是数组的大小
timeout:超时时间, 含义与poll的timeout参数相同,设为-1表示永不超时;
返回值:有多少个IO事件已经准备就绪。如果返回0说明没有IO事件就绪,而是timeout超时。遇到错误的时候,会返回-1,并设置 errno。
有以下几种错误:
EBADF - epfd是无效的文件描述符
EFAULT - 指针events指向的内存没有访问权限
EINTR - 这个调用被信号打断。
EINVAL - epfd不是一个epoll的文件描述符,或者maxevents小于等于0
补充:
参考:https://blog.csdn.net/JMW1407/article/details/107963618
事件
可读事件
,当文件描述符关联的内核读缓冲区可读,则触发可读事件。 (可读:内核缓冲区非空,有数据可以读取)
可写事件
,当文件描述符关联的内核写缓冲区可写,则触发可写事件。 (可写:内核缓冲区不满,有空闲空间可以写入)epoll是一种I/O事件通知机制,是linux 内核实现IO多路复用的一个实现,在一个操作里同时监听多个输入输出源,在其中一个或多个输入输出源可用的时候返回,然后对其的进行读写操作。
调用epoll_create时,内核除了帮我们在epoll文件系统里建了个
file结点(epoll_create创建的文件描述符)
,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件。不管是poll还是epoll,都是监视文件描述符是否可读或者可写,之后,都需要进行主动的数据读写。
epoll更多原理参考:
https://www.cnblogs.com/Hijack-you/p/13057792.html
再补充一个实际用例来加深理解。
先创建一个epoll实例
然后添加要监视的fd以及要放进去的用户数据
这里就是先构造了一些用户数据,然后把这些用户数据放到了data的ptr这个指针变量里面,这样就能放入除fd外更多的用户数据了。最后调用epoll_ctrl函数来将epoll_fd加入到监控之中。
然后就是循环进行epoll_wait监控了
在循环中,先定义了一个events数组,然后传递进入epoll_wait,用来接收就绪的文件描述符的信息。
之后,遍历已就绪的文件描述符数组,执行对应的用户行为。
注意,此处并没有去读写数据。而是直接对用户数据进行操作。