Redis对Reactor模型的实现

166 阅读17分钟

如果要让服务器服务多个客户端,那么最直接的方式就是为每一条连接创建线程。

其实创建进程也是可以的,原理是一样的,进程和线程的区别在于线程比较轻量级些,线程的创建和线程间切换的成本要小些,为了描述简述,后面都以线程为例。

处理完业务逻辑后,随着连接关闭后线程也同样要销毁了,但是这样不停地创建和销毁线程,不仅会带来性能开销,也会造成浪费资源,而且如果要连接几万条连接,创建几万个线程去应对也是不现实的。

要这么解决这个问题呢?我们可以使用资源复用的方式。

也就是不用再为每个连接创建线程,而是创建一个线程池,将连接分配给线程,然后一个线程可以处理多个连接的业务。

不过,这样又引来一个新的问题,线程怎样才能高效地处理多个连接的业务?

当一个连接对应一个线程时,线程一般采用read -> 业务处理 -> send的处理流程,如果当前连接没有数据可读,那么线程会阻塞在 read 操作上( socket 默认情况是阻塞 I/O),不过这种阻塞方式并不影响其他线程。

但是引入了线程池,那么一个线程要处理多个连接的业务,线程在处理某个连接的 read 操作时,如果遇到没有数据可读,就会发生阻塞,那么线程就没办法继续处理其他连接的业务。

要解决这一个问题,最简单的方式就是将 socket 改成非阻塞,然后线程不断地轮询调用 read 操作来判断是否有数据,这种方式虽然该能够解决阻塞的问题,但是解决的方式比较粗暴,因为轮询是要消耗 CPU 的,而且随着一个 线程处理的连接越多,轮询的效率就会越低。

上面的问题在于,线程并不知道当前连接是否有数据可读,从而需要每次通过 read 去试探。

那有没有办法在只有当连接上有数据的时候,线程才去发起读请求呢?答案是有的,实现这一技术的就是 I/O 多路复用。

I/O 多路复用技术会用一个系统调用函数来监听我们所有关心的连接,也就说可以在一个监控线程里面监控很多的连接。

我们熟悉的 select/poll/epoll 就是内核提供给用户态的多路复用系统调用,线程可以通过一个系统调用函数从内核中获取多个事件。

select/poll/epoll 是如何获取网络事件的呢?

在获取事件时,先把我们要关心的连接传给内核,再由内核检测:

  • 如果没有事件发生,线程只需阻塞在这个系统调用,而无需像前面的线程池方案那样轮训调用 read 操作来判断是否有数据。
  • 如果有事件发生,内核会返回产生了事件的连接,线程就会从阻塞状态返回,然后在用户态中再处理这些连接对应的业务即可。

当下开源软件能做到网络高性能的原因就是 I/O 多路复用吗?

是的,基本是基于 I/O 多路复用,用过 I/O 多路复用接口写网络程序的同学,肯定知道是面向过程的方式写代码的,这样的开发的效率不高。

于是,大佬们基于面向对象的思想,对 I/O 多路复用作了一层封装,让使用者不用考虑底层网络 API 的细节,只需要关注应用代码的编写

大佬们还为这种模式取了个让人第一时间难以理解的名字:Reactor 模式。

Reactor 翻译过来的意思是反应堆,可能大家会联想到物理学里的核反应堆,实际上并不是的这个意思。

这里的反应指的是对事件反应,也就是来了一个事件,Reactor 就有相对应的反应/响应。

事实上,Reactor 模型也叫 Dispatcher 模型,这个名字更贴合该模式的含义,即 I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程

实际上,Reactor模型是高性能网络系统实现高并发请求处理的一个重要技术方案。掌握Reactor模型的设计思想与实现方法,可以指导你设计和实现自己的高并发系统。当你要处理成千上万的网络连接时,就不会一筹莫展了。

Reactor模型

其实,Reactor模型处理的是客户端和服务器端的交互过程,Reactor 模型处主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下:

  • Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;
  • 处理资源池负责处理事件,如 read -> 业务逻辑 -> send;

可以看到进程里有 Reactor、Acceptor、Handler 这三个对象:

  • Reactor 对象的作用是监听和分发事件;
  • Acceptor 对象的作用是获取连接;
  • Handler 对象的作用是处理业务;

不同请求类型在服务器端引发的待处理事件:

  • 当一个客户端要和服务器端进行交互时,客户端会向服务器端发送连接请求,以建立连接,这就对应了服务器端的一个连接事件。
  • 一旦连接建立后,客户端会给服务器端发送读请求,以便读取数据。服务器端在处理读请求时,需要向客户端写回数据,这对应了服务器端的写事件。
  • 无论客户端给服务器端发送读或写请求,服务器端都需要从客户端读取请求内容,所以在这里,读或写请求的读取就对应了服务器端的读事件。

如下所示的图例中,就展示了客户端和服务器端在交互过程中,不同类请求和Reactor模型事件的对应关系。

Reactor模型中三个关键角色的作用:

  • 首先,连接事件由acceptor来处理,负责接收连接;acceptor在接收连接后,会创建handler,用于网络连接上对后续读写事件的处理;
  • 其次,读写事件由handler处理;
  • 最后,在高并发场景中,连接事件、读写事件会同时发生,所以,我们需要有一个角色专门监听和分配事件,这就是reactor角色。当有连接请求时,reactor将产生的连接事件交由acceptor处理;当有读写请求时,reactor将读写事件交由handler处理。

下图就展示了这三个角色之间的关系,以及它们和事件的关系。

这三个角色都是Reactor模型中要实现的功能的抽象。当我们遵循Reactor模型开发服务器端的网络框架时,就需要在编程的时候,在代码功能模块中实现reactor、acceptor和handler的逻辑。

Reactor模型的概要设计:

  • Reactor 对象通过 select/poll/epoll(IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
  • 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;
  • 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;
  • Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。

单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。

但是,这种方案存在 2 个缺点:

  • 第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能;
  • 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟;

所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。

Redis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。

Redis 对Reactor模型的实现

Redis 遵循Reactor模型开发服务器端的网络框架时,就需要在编程的时候,在代码功能模块中实现reactor、acceptor和handler的逻辑。

现在我们已经知道,这三个角色是围绕事件的监听、转发和处理来进行交互的,那么在编程时,我们又该如何实现这三者的交互呢?这就离不开事件驱动框架了。

事件驱动框架

所谓的事件驱动框架,就是在实现Reactor模型时,需要实现的代码整体控制逻辑。简单来说,事件驱动框架包括了两部分:

  • 一是事件初始化;
  • 二是事件捕获、分发和处理主循环。

事件初始化

事件初始化是在服务器程序启动时就执行的,它的作用主要是创建需要监听的事件类型,以及该类事件对应的handler。而一旦服务器完成初始化后,事件初始化也就相应完成了,服务器程序就需要进入到事件捕获、分发和处理的主循环中。

在开发代码时,我们通常会用一个while循环来作为这个主循环。然后在这个主循环中,我们需要捕获发生的事件、判断事件类型,并根据事件类型,调用在初始化时创建好的事件handler来实际处理事件。

比如说,当有连接事件发生时,服务器程序需要调用acceptor处理函数,创建和客户端的连接。而当有读事件发生时,就表明有读或写请求发送到了服务器端,服务器程序就要调用具体的请求处理函数,从客户端连接中读取请求内容,进而就完成了读事件的处理。

那么到这里,你应该就已经了解了Reactor模型的基本工作机制:客户端的不同类请求会在服务器端触发连接、读、写三类事件,这三类事件的监听、分发和处理又是由reactor、acceptor、handler三类角色来完成的,然后这三类角色会通过事件驱动框架来实现交互和事件处理。

所以可见,实现一个Reactor模型的关键,就是要实现事件驱动框架。那么,如何开发实现一个事件驱动框架呢?

首先我们要知道的是,Redis的网络框架实现了Reactor模型,并且自行开发实现了一个事件驱动框架。这个框架对应的Redis代码实现文件是ae.c,对应的头文件是ae.h。

事件驱动框架的实现离不开事件的定义,以及事件注册、捕获、分发和处理等一系列操作。当然,对于整个框架来说,还需要能一直运行,持续地响应发生的事件。

那么由此,我们从ae.h头文件中就可以看到,Redis为了实现事件驱动框架,相应地定义了事件的数据结构、框架主循环函数、事件捕获分发函数、事件和handler注册函数。所以接下来,我们就依次来了解学习下。

事件的数据结构定义:以aeFileEvent为例

首先,我们要明确一点,就是在Redis事件驱动框架的实现当中,事件的数据结构是关联事件类型和事件处理函数的关键要素。而Redis的事件驱动框架定义了两类事件:IO事件和时间事件,分别对应了客户端发送的网络请求和Redis自身的周期性操作。

这也就是说,不同类型事件的数据结构定义是不一样的。不过,由于这节课我们主要关注的是事件框架的整体设计与实现,所以对于不同类型事件的差异和具体处理,我会在下节课给你详细介绍。那么在今天的课程中,为了让你能够理解事件数据结构对框架的作用,我就以IO事件aeFileEvent为例,给你介绍下它的数据结构定义。

aeFileEvent是一个结构体,它定义了4个成员变量mask、rfileProce、wfileProce和clientData,如下所示:

typedef struct aeFileEvent {
    int mask; //掩码标记,包括可读事件、可写事件和屏障事件
    aeFileProc *rfileProc;   //处理可读事件的回调函数
    aeFileProc *wfileProc;   //处理可写事件的回调函数
    void *clientData;  //私有数据
} aeFileEvent;
  • mask是用来表示事件类型的掩码。对于网络通信的事件来说,主要有AE_READABLE、AE_WRITABLE和AE_BARRIER三种类型事件。框架在分发事件时,依赖的就是结构体中的事件类型;
  • rfileProc和wfileProce分别是指向AE_READABLE和AE_WRITABLE这两类事件的处理函数,也就是Reactor模型中的handler。框架在分发事件后,就需要调用结构体中定义的函数进行事件处理;
  • 最后一个成员变量clientData是用来指向客户端私有数据的指针。
void aeMain(aeEventLoop *eventLoop);
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, aeFileProc *proc, void *clientData);
int aeProcessEvents(aeEventLoop *eventLoop, int flags);
  • 框架主循环的aeMain函数
  • 负责事件捕获与分发的aeProcessEvents函数
  • 负责事件和handler注册的aeCreateFileEvent函数

而这三个函数的实现,都是在对应的ae.c文件中,那么接下来,我就给你具体介绍下这三个函数的主体逻辑和关键流程。

aeCreateEventLoop函数的初始化操作

因为Redis server在完成初始化后,就要开始运行事件驱动框架的循环流程,所以,aeEventLoop结构体在server.c的initServer函数中,就通过调用aeCreateEventLoop函数进行初始化了。这个函数的参数只有一个,是setsize。

下面的代码展示了initServer函数中对aeCreateEventLoop函数的调用。

initServer() {
…
//调用aeCreateEventLoop函数创建aeEventLoop结构体,并赋值给server结构的el变量
server.el = aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR);
…
}

aeCreateEventLoop函数会调用aeApiCreate函数。aeApiCreate函数封装了操作系统提供的IO多路复用函数,假设Redis运行在Linux操作系统上,并且IO多路复用机制是epoll,那么此时,aeApiCreate函数就会调用epoll_create创建epoll实例,同时会创建epoll_event结构的数组,数组大小等于参数setsize。

aeEventLoop *aeCreateEventLoop(int setsize) {
    aeEventLoop *eventLoop;
    int i;
    //重点
    if (aeApiCreate(eventLoop) == -1) goto err;
    return NULL;
}


typedef struct aeApiState {  //aeApiState结构体定义
    int epfd;   //epoll实例的描述符
    struct epoll_event *events;   //epoll_event结构体数组,记录监听事件
} aeApiState;


static int aeApiCreate(aeEventLoop *eventLoop) {
    aeApiState *state = zmalloc(sizeof(aeApiState));

    if (!state) return -1;
    //将epoll_event数组保存在aeApiState结构体变量state中
    state->events = zmalloc(sizeof(struct epoll_event)*eventLoop->setsize);
    if (!state->events) {
        zfree(state);
        return -1;
    }
    // 调用epoll_create
    state->epfd = epoll_create(1024); /* 1024 is just a hint for the kernel */
    if (state->epfd == -1) {
        zfree(state->events);
        zfree(state);
        return -1;
    }
    eventLoop->apidata = state;
    return 0;
}

主循环:aeMain函数

aeMain函数的逻辑很简单,就是用一个循环不停地判断事件循环的停止标记。如果事件循环的停止标记被设置为true,那么针对事件捕获、分发和处理的整个主循环就停止了;否则,主循环会一直执行。aeMain函数的主体代码如下所示:

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        if (eventLoop->beforesleep != NULL)
            eventLoop->beforesleep(eventLoop);
      	// 重点
        aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
    }
}

事件捕获与分发:aeProcessEvents函数

aeProcessEvents函数实现的主要功能,包括捕获事件、判断事件类型和调用具体的事件处理函数,从而实现事件的处理。

从aeProcessEvents函数的主体结构中,我们可以看到主要有三个if条件分支,如下所示:

int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
    int processed = 0, numevents;
 
    /* 若没有事件处理,则立刻返回*/
    if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;
    /*如果有IO事件发生,或者紧急的时间事件发生,则开始处理*/
    if (eventLoop->maxfd != -1 || ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
       …
    }
    /* 检查是否有时间事件,若有,则调用processTimeEvents函数处理 */
    if (flags & AE_TIME_EVENTS)
        processed += processTimeEvents(eventLoop);
    /* 返回已经处理的文件或时间*/
    return processed; 
}

这三个分支分别对应了以下三种情况:

  • 情况一:既没有时间事件,也没有网络事件;
  • 情况二:有IO事件或者有需要紧急处理的时间事件;
  • 情况三:只有普通的时间事件。

那么对于第一种情况来说,因为没有任何事件需要处理,aeProcessEvents函数就会直接返回到aeMain的主循环,开始下一轮的循环;而对于第三种情况来说,该情况发生时只有普通时间事件发生,所以aeMain函数会调用专门处理时间事件的函数processTimeEvents,对时间事件进行处理。

对于第二种情况。当该情况发生时,Redis需要捕获发生的网络事件,并进行相应的处理。那么从Redis源码中我们可以分析得到,在这种情况下,aeApiPoll函数会被调用,用来捕获事件,如下所示:

int aeProcessEvents(aeEventLoop *eventLoop, int flags){
   ...
   if (eventLoop->maxfd != -1 || ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
       ...
       //调用aeApiPoll函数捕获事件
       numevents = aeApiPoll(eventLoop, tvp);
       ...
    }
    ...
」

那么,aeApiPoll是如何捕获事件呢?

实际上,Redis是依赖于操作系统底层提供的 IO多路复用机制,来实现事件捕获,检查是否有新的连接、读写事件发生。为了适配不同的操作系统,Redis对不同操作系统实现的网络IO多路复用函数,都进行了统一的封装,封装后的代码分别通过以下四个文件中实现:

  • ae_epoll.c,对应Linux上的IO复用函数epoll;
  • ae_evport.c,对应Solaris上的IO复用函数evport;
  • ae_kqueue.c,对应macOS或FreeBSD上的IO复用函数kqueue;
  • ae_select.c,对应Linux(或Windows)的IO复用函数select。

这样,在有了这些封装代码后,Redis在不同的操作系统上调用IO多路复用API时,就可以通过统一的接口来进行调用了。

不过看到这里,你可能还是不太明白Redis封装的具体操作,所以这里,我就以在服务器端最常用的Linux操作系统为例,给你介绍下Redis是如何封装Linux上提供的IO复用API的。

首先,Linux上提供了epoll_wait API,用于检测内核中发生的网络IO事件。在ae_epoll.c文件中,aeApiPoll函数就是封装了对epoll_wait的调用。

这个封装程序如下所示,其中你可以看到,在aeApiPoll函数中直接调用了epoll_wait函数,并将epoll返回的事件信息保存起来的逻辑:

static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
    …
    //调用epoll_wait获取监听到的事件
    retval = epoll_wait(state->epfd,state->events,eventLoop->setsize,
            tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);
    if (retval > 0) {
        int j;
        //获得监听到的事件数量
        numevents = retval;
        //针对每一个事件,进行处理
        for (j = 0; j < numevents; j++) {
             #保存事件信息
        }
    }
    return numevents;
}

事件驱动框架最终实现对epoll_wait的调用

那么,事件具体是由哪个函数来处理的呢?这就和框架中的aeCreateFileEvents函数有关了。

事件注册:aeCreateFileEvent函数

当Redis启动后,服务器程序的main函数会调用initSever函数来进行初始化,而在初始化的过程中,aeCreateFileEvent就会被initServer函数调用,用于注册要监听的事件,以及相应的事件处理函数。

在initServer函数的执行过程中,initServer函数会根据启用的IP端口个数,为每个IP端口上的网络事件,调用aeCreateFileEvent,创建对AE_READABLE事件的监听,并且注册AE_READABLE事件的处理handler,也就是acceptTcpHandler函数。

下面的示例代码中,显示了initServer中调用aeCreateFileEvent的部分片段:

void initServer(void) {
    …
    for (j = 0; j < server.ipfd_count; j++) {
        if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,acceptTcpHandler,NULL) == AE_ERR)
            {
                serverPanic("Unrecoverable error creating server.ipfd file event.");
            }
	}
	…
}

那么,aeCreateFileEvent如何实现事件和处理函数的注册呢?这就和刚才我介绍的Redis对底层IO多路复用函数封装有关了,下面我仍然以Linux系统为例,来给你说明一下。

static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
    aeApiState *state = eventLoop->apidata;
    struct epoll_event ee = {0}; /* avoid valgrind warning */
    int op = eventLoop->events[fd].mask == AE_NONE ?
            EPOLL_CTL_ADD : EPOLL_CTL_MOD;

    ee.events = 0;
    mask |= eventLoop->events[fd].mask; /* Merge old events */
    if (mask & AE_READABLE) ee.events |= EPOLLIN;
    if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
    ee.data.fd = fd;
  	// 调用epoll_ctl方法
    if (epoll_ctl(state->epfd,op,fd,&ee) == -1) return -1;
    return 0;
}

调用epoll_ctl方法

其实有一根线那就是多路复用中的epoll机制工作流程。

  • 先用epoll_create 创建一个 epoll对象 epfd,
  • 再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,
  • 最后调用 epoll_wait 函数获取就绪的文件描述符。

上述Redis对Reactor模型的实现也是按照这条线来实现的。