select、poll 和 epoll 多路复用

mark

Nginx 和 Redis 中都用到了 epoll 多路复用模型,本节将讲述常见的多路复用模型:select、poll 和 epoll,以及部分示例代码,还是先回顾 IO 的两个重要过程:

任何 IO 过程中,都包含两个步骤:第一是等待,第二是拷贝。而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让 IO 更高效,最核心的办法就是让等待的时间尽量少。

在以前的文章中介绍了五种 IO 模型,分别是阻塞式 IO、非阻塞式 IO、信号驱动 IO、多路复用 IO、异步 IO;前四种都属于同步 IO。今天重点介绍的是多路复用 IO,多路复用 IO 通俗讲就是一次等待多个文件描述符,减少了等待时间,提高了 IO 过程的效率(此 IO 过程并不是只是从内核态到用户态数据的拷贝,而是从发起 IO 请求直到 IO 完成的过程),接下来将介绍 Linux 的三种多路复用模型。

非阻塞 IO

在介绍多路复用 IO 之前,先看看非阻塞 IO 吧:一个文件描述符,默认都是阻塞 IO,我们可以通过 fcntl 函数来将文件描述符设置为非阻塞

mark

传入的 cmd 的值不同,后面追加的参数也不相同。fcntl 函数有 5 种功能:

  • 复制一个现有的描述符(cmd=F_DUPFD)
  • 获得 / 设置文件描述符标记 (cmd=F_GETFD 或 F_SETFD).
  • 获得 / 设置文件状态标记 (cmd=F_GETFL 或 F_SETFL).
  • 获得 / 设置异步 I/O 所有权 (cmd=F_GETOWN 或 F_SETOWN).
  • 获得 / 设置记录锁 (cmd=F_GETLK,F_SETLK 或 F_SETLKW).

我们此处只是用第三种功能,获取 / 设置文件状态标记,就可以将一个文件描述符设置为非阻塞。对 fcntl 封装实现一个将文件描述符更改为非阻塞的功能:

mark

使用 F_GETFL 将当前的文件描述符的属性取出来 (这是一个位图),然后再使用 F_SETFL 将文件描述符设置回去。设置回去的同时,加上一个 O_NONBLOCK 参数。下面使用轮询方式读取标准输入:

mark

mark

select 多路复用

系统提供 select 函数来实现多路复用输入 / 输出模型,select 系统调用是用来让我们的程序监视多个文件描述符的状态变化的;程序会停在 select 这里等待,直到被监视的文件描述符有一个或多个发生了状态改变;

select 函数

mark

参数 nfds 是需要监视的最大的文件描述符值 + 1;rdset、wrset、exset 分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集合及异常文件描述符的集合;参数 timeout 为结构 timeval,用来设置 select () 的等待时间

  • NULL:则表示 select () 没有 timeout,select 将一直被阻塞,直到某个文件描述符上发生了事件;
  • 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
  • 特定的时间值:如果在指定的时间段里没有事件发生,select 将超时返回。

void FD_CLR (int fd, fd_set set) 用来清除描述词组 set 中相关 fd 的位
int FD_ISSET (int fd, fd_set
set) 用来测试描述词组 set 中相关 fd 的位是否为真
void FD_SET (int fd, fd_set set) 用来设置描述词组 set 中相关 fd 的位
void FD_ZERO (fd_set
set) 用来清除描述词组 set 的全部位

select 函数返回值 :执行成功则返回文件描述词状态已改变的个数;如果返回 0 代表在描述词状态改变前已超过 timeout 时间,没有返回;当有错误发生时则返回 - 1,错误原因存于 errno,此时参数 readfds,writefds,exceptfds 和 timeout 的值变成不可预测。

错误值可能为:

  • EBADF 文件描述词为无效的或该文件已关闭
  • EINTR 此调用被信号所中断
  • EINVAL 参数 n 为负值
  • ENOMEM 核心内存不足

select 特点

可监控的文件描述符个数取决与 sizeof (fd_set) 的值,我的服务器上 sizeof (fd_set)=128,每 bit 表示一个文件
描述符,则我服务器上支持的最大文件描述符是 128*8=1024。fd_set 的大小可以调整,可能涉及重新编译内核。

1
2
3
4
int main(){
printf("% lu\n", sizeof(fd_set)); //128
return 0;
}

将 fd 加入 select 监控集的同时,还要再使用一个数据结构 array 保存放到 select 监控集中的 fd,一是用于再 select 返回后,array 作为源数据和 fd_set 进行 FD_ISSET 判断。二是 select 返回后会把以前加入的但并无事件发生的 fd 清空,则每次开始 select 前都要重新从 array 取得 fd 逐一加入 (FD_ZERO 最先),扫描 array 的同时取得 fd 最大值 maxfd,用于 select 的第一个参数。

select 缺点

每次调用 select, 都需要手动设置 fd 集合,从接口使用角度来说也非常不便,每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大,同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大,select 支持的文件描述符数量太小。

使用 select 监控 stdin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <stdbool.h>
#include <unistd.h>
#include <zconf.h>

int main(){
fd_set read_fds;
// 清除全部标志位
FD_ZERO (&read_fds);
FD_SET (0, &read_fds);
while (true){
printf(">");
fflush (stdout);
int ret = select (1, &read_fds, NULL, NULL, NULL);
if(ret < 0){
perror ("select");
continue;
}
if(FD_ISSET (0, &read_fds)){
char buf [1024] = {0};
read (0, buf, sizeof(buf) - 1);
printf("input:% s\n", buf);
} else{
printf("error!");
continue;
}
FD_ZERO (&read_fds);
FD_SET (0, &read_fds);
}
return 0;
}

mark

poll 多路复用

poll 函数

mark

对于 poll 这个系统函数来说,fds 是一个 poll 函数监听的结构列表。每一个元素中,包含了三部分内容:文件描述符,监听的事件集合,返回的事件集合。nfds 表示 fds 数组的长度,timeout 表示 poll 函数的超时时间,单位是毫秒 (ms)。

下面是 events 和 revents 的取值

mark

返回值:返回值小于 0,表示出错;返回值等于 0,表示 poll 函数等待超时;返回值大于 0,表示 poll 由于监听的文件描述符就绪而返回。

poll 特点

不同与 select 使用三个位图来表示三个 fdset 的方式,poll 使用一个 pollfd 的指针实现,pollfd 结构包含了要监视的 event 和发生的 event,不再使用 select “参数 - 值” 传递的方式(因为 select 三个集合简直要命),接口使用比 select 更方便。poll 并没有最大数量限制 (但是数量过大后性能也是会下降),因为列表长度可以任意长,当然指的是内存充足的情况下;

poll 缺点

poll 中监听的文件描述符数目增多时,和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符,每次调用 poll 都需要把大量的 pollfd 结构从用户态拷贝到内核中。同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

使用 poll 监控 stdin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <stdbool.h>
#include <unistd.h>
#include <zconf.h>
#include <poll.h>

int main(){

struct pollfd poll_fd;
poll_fd.fd = 0;
poll_fd.events = POLLIN;// 数据可读事件
while (true){
int ret = poll (&poll_fd, 1, 1000);
if(ret < 0){
perror ("poll");
continue;
}

if(ret == 0){
printf("poll timeout\n");
continue;
}

if(poll_fd.revents == POLLIN){
char buf [1024] = {0};
read (0, buf, sizeof(buf) - 1);
printf("stdin:% s\n", buf);
}
}
return 0;
}

epoll 多路复用

按照 man 手册的说法:是为处理大批量句柄而作了改进的 poll,它是在 2.5.44 内核中被引进的,对于有大量的连接,但是却只有少数连接是活跃的这种情况非常适用。

epoll 有三个系统调用:

epoll_create

创建一个 epoll 的句柄,自从 linux2.6.8 之后,size 参数是被忽略的。用完之后,必须调用 close () 关闭。

mark

epoll_ctl

epoll_ctl 是 epoll 的事件注册函数

mark

它不同于 select () 是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

  • 第一个参数是 epoll_create () 的返回值 (epoll 的句柄)
  • 第二个参数表示动作,用三个宏来表示
  • 第三个参数是需要监听的 fd
  • 第四个参数是告诉内核需要监听什么事

第二个参数的取值也如上图,分别是注册新的 fd 到 epfd 中;修改已经注册的 fd 的监听事件;从 epfd 中删除一个 fd;第三个参数的取值如下图:

mark

events 可以是以下几个宏的集合

  • EPOLLIN : 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭)
  • EPOLLOUT : 表示对应的文件描述符可以写
  • EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来)
  • EPOLLERR : 表示对应的文件描述符发生错误
  • EPOLLHUP : 表示对应的文件描述符被挂断
  • EPOLLET : 将 EPOLL 设为边缘触发 (Edge Triggered) 模式,这是相对于水平触发 (Level Triggered) 来说的
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里

epoll_wait

收集在 epoll 监控的事件中已经发送的事件。

  • 参数 events 是分配好的 epoll_event 结构体数组
  • epoll 将会把发生的事件赋值到 events 数组中 (events 不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存)
  • maxevents 告之内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create () 时的 size
  • 参数 timeout 是超时时间 (毫秒,0 会立即返回,-1 是永久阻塞)

如果函数调用成功,返回对应 I/O 上已准备好的文件描述符数目,如返回 0 表示已超时,返回小于 0 表示函数失败。

mark

epoll 工作原理

mark

当某一进程调用 epoll_create 方法时,Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个成员与 epoll 的使用方式密切相关。

1
2
3
4
5
6
7
8
9
10
11
struct eventpoll{
....

/* 红黑树的根节点,这颗树中存储着所有添加到 epoll 中的需要监控的事件 */
struct rb_root rbr;

/* 双链表中则存放着将要通过 epoll_wait 返回给用户的满足条件的事件 */
struct list_head rdlist;

....
};
  • 每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl 方法向 epoll 对象中添加进来的事件。
  • 这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来 (红黑树的插入时间效率是 lgN,其中 n 为树的高度)。
  • 而所有添加到 epoll 中的事件都会与设备 (网卡) 驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法。
  • 这个回调方法在内核中叫 ep_poll_callback, 它会将发生的事件添加到 rdlist 双链表中。
  • 在 epoll 中,对于每一个事件,都会建立一个 epitem 结构体。
1
2
3
4
5
6
7
struct epitem{
struct rb_node rbn;// 红黑树节点
struct list_head rdllink;// 双向链表节点
struct epoll_filefd ffd; // 事件句柄信息
struct eventpoll *ep; // 指向其所属的 eventpoll 对象
struct epoll_event event; // 期待发生的事件类型
}

当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdlist 双链表中是否有 epitem 元素即可。
如果 rdlist 不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。这个操作的时间复杂度是 O (1)。

总结一下,epoll 的使用过程就是三部曲:
1、调用 epoll_create 创建一个 epoll 句柄;
2、调用 epoll_ctl,将要监控的文件描述符进行注册;
3、调用 epoll_wait,等待文件描述符就绪;

epoll 的优点

1、接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效,不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离开;

2、数据拷贝轻量:只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中,这个操作并不频繁,而 select/poll 都是每次循环都要进行拷贝;

3、事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪。这个操作时间复杂度 O (1),即使文件描述符数目很多,效率也不会受到影响;

4、没有数量限制:文件描述符数目无上限;