Linux| IO多路复用机制

IO多路复用Demo

select

select可以完成非阻塞方式的工作的程序,它能够监视需要被监视的文件描述符的变化情况 — 读、写或异常

select 函数原型

1
int select(int maxfdp, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);

主要涉及到两个结构体:fd_settimeval

  • 结构体fd_set可以理解为一个集合,这个集合中存放的是文件描述符(file description),即文件句柄,这可以认为是常说的普通意义的文件;当然 UNIX 下任何设备、管道、FIFO等都是文件形式,所以毫无疑问,一个 socket 就是一个文件, socket 句柄就是一个文件描述符。fd_set 集合可以通过一些宏由人为来操作,比如以下代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    fd_set set;
    // 将set清零
    FD_ZERO(&set);
    // 将fd加入set
    FD_SET(fd, &set);
    // 将fd从set中清除
    FD_CLR(fd, &set);
    // 如果fd在set中则真
    FD_ISSET(fd, &set);
  • 结构体timeval是一个常用的结构,用来代表时间值,有两个成员,一个是秒数,另一个是毫秒数

select的各个参数所表示的含义

  • maxfdp是一个整数值,是指集合中所有文件描述符的范围,即所有文件描述符的最大值加1
  • readfds 是指向 fd_set 结构的指针,这个集合中应该包括文件描述符。因为要监视文件描述符的读变化的,即关心是否可以从这些文件中读取数据,如果这个集合中有一个文件可读,select 就会返回一个大于 0 的值,表示有文件可读。如果没有可读的文件,则根据timeout参数再判断是否超时:若超出 timeout 的时间,select 返回 O;若发生错误返回负值;也可以传入 NULL 值,表示不关心任何文件的读变化
  • writefds 是指向 fd_set 结构的指针,这个集合中应该包括文件描述符。因为要监视文件描述符的写变化的,即关心是否可以向这些文件中写入数据,如果这个集合中有一个文件可写, select 就会返回一个大于 0 的值,表示有文件可写。 如果没有可写的文件,则根据 timeout 参数再判断是否超时:若超出 timeout 的时间,select 返回 O;若发生错误返回负值;也可以传入 NULL 值,表示不关心任何文件的写变化。
  • errorfds同上面两个参数的意图,用来监视文件错误异常
  • timeout 是 select 的超时时间,这个参数至关重要,它可以使 select 处于 3 种状态
    • 若将 NULL 以形参传入,即不传入时间结构,就是将 select 置于阻塞状态,一定等到监视文件描述符集合中某个文件描述符发生变化为止
    • 若将时间值设为0,就变成一个纯粹的非阻塞函数,不管文件描述符是否有变化,都立刻返回继续执行,文件无变化返回0,有变化返回一个正值
    • timeout 的值大于0,这就是等待的超时时间,即 select 在 timeout 时间内阻塞,超时时间之内有事件到来就返回,否则在超时后不管怎样一定返回,返回值同上述
  • 返回值:准备就绪的描述符数,若超时则返回 0,若出错则返回 -1

poll

select 函数一样,poll函数也可以用于执行多路复用 IO

poll 函数原型

1
2
#include <poll.h>
int poll(struct pollfd* fds, unsigned int nfds, int timeout);

pollfd 结构体定义

1
2
3
4
5
struct pollfd {
int fd; // 文件描述符
short events; // 等待的事件
short revents; // 实际发生了的事件
};

每个 pollfd结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示poll()监视多个文件描述符。每个结构体的 events 域是监视文件描述符的事件掩码,由用户来设置这个域的属性。revents 域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域;并且 events 域中请求的任何事件都可能在 revents 域中返回。具体的事件代码和代表含义如下所示

nfds 用于标记数组fds中的struct pollfd结构元素的总数量

poll事件

使用poll()select()不一样,不需要显式地请求异常情况报告。

POLLIN | POLLPRI 等价于 select()的读事件POLLOUT | POLLWRBAND 等价于 select()的写事件POLLIN 等价于 POLLRDNORM | POLLRDBAND,而 POLLOUT 则等价于 POLLWRNORM。例如,要同时监视一个文件描述符是否可读或可写,可以设置 events 为 POLLIN | POLLOUT。在 poll 返回时,只要检查 revents 中的标志,获得对应于文件描述符请求的 events 结构体。如果 POLLIN 事件被设置,则文件描述符可以被读取而不阻塞;如果 POLLOUT 被设置,则文件描述符可以写入而不导致阻塞。这些标志并不是互斥的,它们可能被同时设置,表示这个文件描述符的读取和写人操作都会正常返回而不阻塞。

timeout参数指定等待的毫秒数,无论 IO 是否准备号,poll 都会返回。timeout 指定负数值时表示无限超时,使poll()一直挂起直到一个指定事件发生;timeout为0指示poll调用立即返回并列出准备好 IO 的文件描述符,但并不等待其他的事件。这种情况下,poll()的返回值,一旦被选举出来,立即返回。成功时,poll()返回结构体中 revents 域不为0的文件描述符个数,如果在超时前没用任何事件发生,poll()返回0.失败时,poll()返回-1,并设置 errno 为下列值之一

  • EBADF: 一个或多个结构体中指定的文件描述符无效
  • EFAULTfds: 指针指向的地址超出进程的地址空间
  • EINTR: 请求的事件之前产生一个信号,调用可以重新发起
  • EINVALnfds: 参数超出 PLIMIT_NOFILE
  • ENOMEM: 可用内存不足,无法完成请求

epoll

epoll 是在 Linux 2.6 内核中提出的,是之前selectpoll的增强版本。相对于selectpoll来说,epoll更加灵活,没用描述符的限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间之间的数据拷贝只需一次

epoll 接口
使用 epoll 必须包含下面这个头文件

1
#include <sys/epoll.h>

epoll 操作过程需要3个接口,分别如下
1
2
3
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);

接下来介绍这3个接口的功能
1
int epoll_create(int size);

创建一个 epoll 句柄, size 用来告诉内核要监听的数目。这个参数不同于select()中的第一个参数,是最大监听的 fd+1 的值。需要注意的是,当创建好 epoll 句柄后,它就会占用一个 fd 值,在 Linux 下如果查看 /proc/进程的id/fd/,是能够看到这个 fd 值的,所以在使用 epoll 后,必须调用close()关闭,否则可能导致 fd 耗尽

1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll 的事件注册函数,它不同于select()在监听事件时告诉内核要监听数目类型的事件,而是先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数表示动作,用3个宏表示

  • EPOLL_CTL_ADD,注册新的 fd 到epfd 中
  • EPOLL_CTL_MOD,修改已经注册的 fd 的监听事件
  • EPOLL_CTL_DEL,从 epfd 中删除一个fd
    第3个参数是需要监听的 fd,第4个参数是告诉内核需要监听什么事,struct epoll_event结构
    1
    2
    3
    4
    struct epoll_event {
    __unit32_t events; // epoll events
    epoll_data_t data; // user data variable
    }
    events 可以是以下几个宏的集合
  • EPOLLIN,表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭)
  • EPOLLOUT,表示响应的文件描述符可以写
  • EPOLLPRI,表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
  • EPOLLERR,表示对应的文件描述符发送错误
  • EPOLLHUB,表示对应的文件描述符被挂断
  • EPOLLET,将 EPOLL 设为边缘触发(Edge Triggered)模式,这是相对于公平触发(Level Triggered)来说的
  • EPOLLONESHOT,只监听一次事件,当监听完这次事件后,如果还需要继续监听这个 socket 的话,需要在此把这个 socket 加入到 EPOLL 队列里
1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

等待事件的产生,类似于select()调用。参数 events 用来从内核得到事件的集合, maxevents 告诉内核这个 events 有多大,且 maxevents 的值不能大于创建epoll_create()时的 size,参数 timeout 是超时事件(ms为单位,0会立即返回,-1将不确定或称永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。


select、poll和epoll的区别

selectpollepoll都是多路 IO 复用的机制。多路 IO 复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但selectpollepoll本质上都是同步IO,因为它们都需要在读写事件就绪后自己负责进行读写,即是阻塞的,而异步IO则无须自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

3种多路IO复用对比

  • select()poll()
    • poll()不要求开发者在计算最大文件描述符时进行 +1 操作
    • poll()在应付大数目的文件描述符的时候速度更快,因为对于select()来说内核需要检查大量描述符对应的 fd_set 中的每一个比特位,比较费时
    • select() 可以监控的文件描述符数目是固定的,相对来说也较少(1024或2048)。如果需要监控数值比较大的文件描述符,或是分布得很稀疏得较少的描述符,效率也会很低。而对于poll()函数来说,就可以创建特定大小的数组来保存监控的描述符,而不受文件描述符值大小的影响,而且poll()可以监控的文件数目远大于
    • 对于select()来说,所监控的 fd_set 在select()返回之后会发生变化,所以在下一次进入select()之前都需要重新初始化需要监控的 fd_set,poll()函数将监控的输入和输出事件分开,允许被监控的文件数组被复用而不需要重新初始化
    • select()函数的超时参数在返回时也是未定义的,考虑到可移植性,每次在超时之后在下一次进入到select()之前都需要重新设置超时参数
  • select()的优点
    • select()的可移植性更好,在某些 UNIX 系统上不支持poll()
    • select()对于超时值提供了更好的精度,而poll()时精度较差
  • epoll()的优点
    • 支持一个进程打开大数目的 socket 描述符(FD)
      select()最不能忍受的是一个进程所打开的 FD 是有一定限制的,由 FD_SETSIZE 的默认值是 1024/2048。对于那些需要支持上万连接数目的 IM 服务器来说显然太少了。这时候可以选择修改这个宏然后重新编译内核。不过 epoll 则没有这个限制,它所支持的 FD 上限是最大可以打开文件的数目,这个数字一般远大于 2048。举个例子,在 lGB 内存的空间中这个数字一般是 10 万左右,具体数目可以使用 cat /proc/sys/fs/file-max查看,一般来说这个数目和系统内存关系很大。
    • IO效率不随FD数目增加而线性下降
      传统的 select/poll 另一个致命弱点就是当你拥有一个很大的 socket 集合,不过由于网络延迟,任一时间只有部分的 socket 是“活跃”的,但是 select/poll 每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是 epoll 不存在这个问题,它只会对“活跃”的 socket 进行操作 — 主是因为在内核中实现 epoll 是根据每个 fd 上面的 callback 函数实现的。 那么,只有“活跃”的 socket 才会主动去调用 callback 函数,其他 idle 状态 socket 则不会,在这点上,epoll 实现了一个“伪” AIO,因为这时候推动力由 Linux 内核提供
    • 使用 mmap 加速内核与用户空间的信息传递
      这点实际上涉及 epoll 的具体实现。无论是 select、poll 还是 epoll 都需要内核把消息通知给用户空间,如何避免不必要的内存拷贝就显得尤为重要。在这点上, epoll 是通过内核与用户空间 mmap 处于同一块内存实现的
      对于 poll 来说需要将用户传入的 pollfd 数组拷贝到内核空间,因为拷贝操作和数组长度相关,时间上来看,这是一个 $O(n)$ 操作,当事件发生后,poll 将获得的数据传送到用户空间,并执行释放内存和剥离等待队列等工作,向用户空间拷贝数据与剥离等待队列等操作的时间复杂度同样是$O(n)$。