redis 浅析IO多路复用与事件处理

IO模型

  在讲redis的IO多路复用机制前,先简单介绍一下其他几种常见的IO模型。这几种IO模型的根本差异在于以下两个过程的处理方式不同:

  1. kernel等待足够的数据到达。
  2. kernel将数据从内核空间拷贝到用户空间。

同步阻塞IO

  上述两个过程都是阻塞的,进程会一直阻塞直到数据到达并从内核空间拷贝到用户空间。

同步非阻塞IO

  第一个过程是非阻塞的,当没有数据到达时,调用会立即返回,此时用户进程需要不断轮询,如果轮询频繁,则浪费了大量的CPU资源;如果轮询频率低,则不能实时地获取数据。

异步IO

  以上两个过程都是非阻塞的,当进程发起系统调用之后会马上返回,可以继续干别的事情,当数据在用户空间准备好后,内核会给用户进程一个signal。

信号驱动IO

  第一个过程是非阻塞的,当数据在内核中准备好后会给进程发送一个signal。但之后用户进程必须以阻塞的方式从内核中把数据拷贝到用户空间,因此本质上还是同步IO。

Redis为何要使用IO多路复用

  IO多路复用的本质是同步阻塞IO,但是,它最大的优势在于可以在一次阻塞中监听多个文件描述符。我们代入Redis的场景,来分析一下为什么需要用到IO多路复用。

  1. 需要处理多个客户端连接,最先想到的恐怕是用多个线程,每个线程处理一个连接。多线程的的存在必然需要昂贵的上下文切换,而且每个线程会有内存消耗,开销还是较大,如果只是要找出有事件到达的客户端,用多线程显然是得不偿失的。Redis本身就是单进程单线程的模式工作,多线程等待多个客户端显然与其系统思想不符。
  2. 假如采用普通的同步阻塞IO,那么Redis可能会在一个客户端上长期阻塞。该客户端可能长期没有数据到达,而Redis需要处理多个客户端的通信,当其他客户端有请求到达时,Redis则无法处理了,这显然是无法接受的。
  3. 假如采用同步非阻塞IO,那么Redis的确可以不断地按顺序轮询所有客户端。但很可能客户端是长期空闲的,这种反复地轮询会浪费大量的CPU资源。

  Redis需要的是可以单线程同时监听多个客户端,IO多路复用显然使其首选。

IO多路复用原理

  IO多路复用的实现主要是Unix的3个系统调用,按性能升序排列分别是select,poll和epoll。其他操作系统也有类似的系统函数。下面主要介绍并比较一下select,poll和epoll这三个系统调用。

select

  主要用到的函数如下:

1
2
3
4
5
6
7
8
9
int select (int n,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
FD_CLR(int fd, fd_set *set);
FD_ISSET(int fd, fd_set *set);
FD_SET(int fd, fd_set *set);
FD_ZERO(fd_set *set);

  select的第一个参数代表要检查的最大描述符加1,第二个参数代表要监听读事件的文件描述符集合,第三个参数代表要监听写事件的文件描述符集合,第四个参数代表要监听异常事件的文件描述符集合,第五个参数代表超时时间。当有文件描述符有相应监听事件发生,则select返回,或者在超过timeout指定的时间后,仍然没有事件到达,则函数仍然返回。函数返回后,readfds,writefds和exceptfds指向的集合包含所有有事件响应的文件描述符,即函数调用前后参数会改变。

  fd_set的实现一般是比特数组,用比特置位复位操作来表示有哪个事件响应。unix定义了几个宏来处理fd_set。FD_CLR用于取消集合中某个文件描述符的事件监听;FD_ISSET用于判断文件描述符是否存在于集合中,从而判断是否有该事件发生;FD_SET用于加入某个文件符进行事件监听;FD_ZERO用于清空集合。

  select的缺点主要有:

  1. 函数调用前后参数会改变,如果要重新进行监听的话要重新修改传入的集合参数。
  2. 用比特数组构建集合,在内核中需要遍历整个集合来找出有事件响应的文件描述符,如果监听的文件描述符排列非常稀疏,那么遍历操作浪费的时间更多。
  3. 每次调用都要重复拷贝文件描述符信息到内核空间,并且重新绑定事件到设备的等待队列以等待唤醒。
  4. 监听的最大文件描述符有限制,一般是1024。

poll

  主要用到的函数与数据类型:

1
2
3
4
5
6
7
int poll (struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};

  与select不同的是,poll的参数不是比特数组,而是pollfd结构体,每个pollfd指定一个监听的文件描述符以及监听的事件。events的值一般是宏POLLIN,POLLOUT或者POLLIN|POLLOUT。并且当函数返回后,revents的值会变成有响应的事件。

  与select相比,poll主要有以下优势:

  1. 没有1024的文件描述符限制。
  2. 函数返回不会弄乱输入参数,下次还可以复用原来的参数。
  3. 内核只需要遍历需要监听的事件来寻找有事件响应的文件描述符,不会有稀疏数组的问题。

  但仍然存在以下不足:

  1. 仍然需要遍历所有监听的文件描述符,可能有大部分是没有事件响应的。
  2. 每次调用都要把要监听的文件描述符信息从用户空间拷贝到内核空间,也同样要把进程绑定到相应设备的等待队列中。

epoll

  epoll基本上解决了select和poll的所有问题,它用到的主要函数和数据类型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int epoll_create1 (int flags);
int epoll_create (int size);
int epoll_ctl (int epfd,
int op,
int fd,
struct epoll_event *event);
struct epoll_event {
__u32 events; /* events */
union {
void *ptr;
int fd;
__u32 u32;
__u64 u64;
} data;
};
int epoll_wait (int epfd,
struct epoll_event *events,
int maxevents,
int timeout);

  epoll把事件的注册和事件的监听解耦了。它把一个函数调用分成了3个。其中epoll_create1和epoll_create一般只需要调用一次,会初始化一个epoll实例,返回一个文件描述符作为后续操作的句柄。两个函数基本一样,Redis用到的是epoll_create,它的参数只是需要监听的文件描述符数量的一个hint,实际上文件描述符集合是动态调整的,用户不需要担心size的问题。

  epoll_ctl用来进行事件的注册或者注销,其中op接受三个值EPOLL_CTL_ADD、EPOLL_CTL_DEL和EPOLL_CTL_MOD。epoll_event指定监听的事件,其中evnets的值一般是EPOLLIN,EPOLLOUT或者EPOLLIN|EPOLLOUT,代表监听读事件和写事件。data字段一般只用到fd。

  epoll_wait用来真正进行事件监听,该函数会阻塞,直到有事件响应或者超时。函数返回后,events参数指向的数组会包含所有有事件响应的epoll_events。

  由于事件的注册和监听分离,只有文件描述符产生变动的情况下才会进行从内核到用户空间的内存拷贝以及绑定到等待队列上。而且,当有事件响应之后,响应的事件会以回调的方式把相应的文件描述符插入到一个队列上,因此内核只需要遍历所有发生响应的文件描述符,而不需要遍历所有文件描述符。

Redis的事件处理

  监听到有读、写事件发生以后,接着要进行事件处理。Redis会为每个监听的文件描述符绑定一个读事件处理器或者写事件处理器,当事件发生时,会执行相应的事件处理函数。对于不同的客户端绑定的事件处理器可能会不相同,例如一般的客户端和用作复制功能的代表master或slave的客户端。除了文件事件以外,Redis还会定时执行时间事件。下面阐述一下Redis的事件处理机制。

文件事件处理

  Redis的文件事件处理实际上是在select,epoll上再封装了一层,Redis会用封装起来的事件处理器,管理要监听的文件描述符集合、已经触发事件的文件描述符集合、最多监听的文件描述符数量、当前监听的最大的文件描述符、每个监听的文件描述符的待监听事件、文件描述符映射的Redis客户端以及事件处理函数等等信息。事件处理器会根据Redis所在的系统,选择性能最好的IO多路复用库作为底层实现。

选择IO多路复用库

  Redis会在编译时选择系统性能最好的IO多路复用库。

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c"
#else
#include "ae_select.c"
#endif
#endif
#endif
初始化事件处理器

  Redis服务器在启动时会初始化事件处理器状态,然后,Redis服务器的主函数的主要任务便是循环调用事件处理器,以单进程单线程的模式循环进行事件处理。

  初始化事件处理器主要是分配内存空间作为要监听的文件描述符集合以及表示已触发事件的文件描述符集合,都用数组来表示,数组索引便是文件描述符号。Redis会事先限定一部分文件描述符数量作为这个集合的size,这个size通常是Redis的最大允许客户端连接数加上一个的缓冲数。Redis服务器初始化并以后一直维护的event loop结构体如下。

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
32
33
typedef struct aeEventLoop {
// 目前已注册的最大描述符
int maxfd; /* highest file descriptor currently registered */
// 目前已追踪的最大描述符
int setsize; /* max number of file descriptors tracked */
// 用于生成时间事件 id
long long timeEventNextId;
// 最后一次执行时间事件的时间
time_t lastTime; /* Used to detect system clock skew */
// 已注册的文件事件
aeFileEvent *events; /* Registered events */
// 已就绪的文件事件
aeFiredEvent *fired; /* Fired events */
// 时间事件
aeTimeEvent *timeEventHead;
// 事件处理器的开关
int stop;
// 多路复用库的私有数据
void *apidata; /* This is used for polling API specific data */
// 在处理事件前要执行的函数
aeBeforeSleepProc *beforesleep;
} aeEventLoop;

  然后,初始化函数会调用底层IO多路复用库的初始化函数,来初始化相应IO多路复用库的事件处理状态。

  对于select来说,它会维护两组fd_set,每组都有两个fd_set,分别代表读和写。之所以有两组,是因为select调用会弄乱输入的fd_set参数,所以其中一组是用来记录当前要监听的文件描述符,另外一组是用来作为select调用的传入参数的。Redis服务器的事件处理器会维护以下结构体作为事件处理状态。

1
2
3
4
5
6
typedef struct aeApiState {
fd_set rfds, wfds;
/* We need to have a copy of the fd sets as it's not safe to reuse
* FD sets after select(). */
fd_set _rfds, _wfds;
} aeApiState;

  初始化事件处理器时,select会调用FD_ZERO来对rfds和wfds清零。

  而对于epoll,在初始化事件处理器时会调用epoll_create初始化一个epoll实例,获得一个文件描述符句柄,以及预分配返回的epoll_event指针数组的内存空间。Redis服务器的事件处理器会维护以下结构体作为事件处理状态。

1
2
3
4
5
6
7
8
9
typedef struct aeApiState {
// epoll_event 实例描述符
int epfd;
// 事件槽
struct epoll_event *events;
} aeApiState;
注册事件监听

  首先会更新event loop结构体events数组里相应文件描述符的aeFileEvent结构体,记录注册的事件监听信息,包括客户端对象,事件类型和事件处理函数等。

  对于select,会根据要监听的事件类型用FD_SET把文件描述符加到相应的fd_set中。

  对于epoll,则会调用epoll_ctl注册事件监听。

注销事件监听

  会修改event loop结构体events数组里相应文件描述符的相应事件的监听标志。

  对于select,会用FD_CLR清空相应fd_set的文件描述符标志位。

  对于epoll,会用epoll_ctl注销事件监听。

等待文件事件

  对于select,会调用select函数阻塞,当有事件响应或超时时,遍历所有文件描述符,用FD_ISSET来判断当前检查的文件描述符是否属于select调用后的fd_set参数数组中,如果属于,则更新event loop结构体的fired文件事件指针数组。

  对于epoll,会调用epoll_wait阻塞,当有事件响应或超时时,遍历所有已触发的文件描述符,并更新相应fired文件事件指针数组的元素。

  上层调用只要检查fired数组,就可获得所有已触发的文件事件,不需要管下层io多路复用库是什么。

时间事件与event loop

  Redis同样也有时间事件处理器,用一个无序链表来管理所有时间事件。Redis会在每个事件处理周期中,遍历这个链表,检查哪个时间事件到期。由于目前Redis只有一个时间事件处理器,因此无序链表不会影响服务器性能。Redis服务器在每个事件处理周期中,会寻找离当前时间最近的时间处理器,把到该时间事件的时间差作为超时时间,然后调用select或epoll_wait让进程休眠,直到有文件事件发生或超时。之后,Redis会遍历fired数组,按序处理文件事件。然后再遍历时间事件链表,处理已到时的时间事件。所有事件处理完毕后,进入下一个事件处理周期。

  这样的event loop机制既不用频繁轮询文件事件,又不会让进程长期阻塞,优雅地管理了所有文件事件与时间事件。

  由于Redis是单线程的,顺序执行事件,所以不能让单个事件阻塞服务器。每个socket连接都是非阻塞的,同时每个事件处理函数要尽快地结束,把事件让给下一个事件,如果还有数据没有读完或者写完都会留到下一个事件处理周期去完成。如果确实有可能长期阻塞服务器,则会在别的进程或线程中单独处理。

关于poll

  Redis的event loop中没有使用poll作为底层的io多路复用库,但是在某些情况若对某个socket需要进行阻塞io时,会单独用poll来在给定时间内进行监听。