You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

20 KiB

10 | Redis事件驱动框架Redis实现了Reactor模型吗

你好我是蒋德钧。今天我们来聊聊Redis是如何实现Reactor模型的。

你在做Redis面试题的时候或许经常会遇到这样一道经典的问题Redis的网络框架是实现了Reactor模型吗这看起来像是一道简单的“是/否”问答题,但是,如果你想给出一个让面试官满意的答案,这就非常考验你的高性能网络编程基础和对Redis代码的掌握程度了。

如果让我来作答这道题我会把它分成两部分来回答一是介绍Reactor模型是什么二是说明Redis代码实现是如何与Reactor模型相对应的。这样一来就既体现了我对网络编程的理解还能体现对Redis源码的深入探究进而面试官也就会对我刮目相看了。

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

所以今天这节课我会先带你了解下Reactor模型然后一起来学习下如何实现Reactor模型。因为Redis的代码实现提供了很好的参考示例所以我会通过Redis代码中的关键函数和流程来给你展开介绍Reactor模型的实现。不过在学习Reactor模型前你可以先回顾上节课我给你介绍的IO多路复用机制epoll因为这也是学习今天这节课的基础。

Reactor模型的工作机制

首先我们来看看什么是Reactor模型。

实际上,Reactor模型就是网络服务器端用来处理高并发网络IO请求的一种编程模型。我把这个模型的特征用两个“三”来总结,也就是:

  • 三类处理事件,即连接事件、写事件、读事件;
  • 三个关键角色即reactor、acceptor、handler。

那么Reactor模型是如何基于这三类事件和三个角色来处理高并发请求的呢下面我们就来具体了解下。

事件类型与关键角色

我们先来看看这三类事件和Reactor模型的关系。

其实Reactor模型处理的是客户端和服务器端的交互过程而这三类事件正好对应了客户端和服务器端交互过程中不同类请求在服务器端引发的待处理事件

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

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

在了解了Reactor模型的三类事件后你现在可能还有一个疑问这三类事件是由谁来处理的呢

这其实就是模型中三个关键角色的作用了:

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

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

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

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

事件驱动框架

所谓的事件驱动框架就是在实现Reactor模型时需要实现的代码整体控制逻辑。简单来说事件驱动框架包括了两部分一是事件初始化;二是事件捕获、分发和处理主循环

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

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

比如说当有连接事件发生时服务器程序需要调用acceptor处理函数创建和客户端的连接。而当有读事件发生时就表明有读或写请求发送到了服务器端服务器程序就要调用具体的请求处理函数从客户端连接中读取请求内容进而就完成了读事件的处理。这里你可以参考下面给出的图例其中显示了事件驱动框架的基本执行过程

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

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

Redis提供了一个简洁但有效的参考实现非常值得我们学习而且也可以用于自己的网络系统开发。下面我们就一起来学习下Redis中对Reactor模型的实现。

Redis对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; /* one of AE_(READABLE|WRITABLE|BARRIER) */
    aeFileProc *rfileProc;
    aeFileProc *wfileProc;
    void *clientData;
} aeFileEvent;

  • mask是用来表示事件类型的掩码。对于网络通信的事件来说主要有AE_READABLE、AE_WRITABLE和AE_BARRIER三种类型事件。框架在分发事件时依赖的就是结构体中的事件类型
  • rfileProc和wfileProce分别是指向AE_READABLE和AE_WRITABLE这两类事件的处理函数也就是Reactor模型中的handler。框架在分发事件后就需要调用结构体中定义的函数进行事件处理
  • 最后一个成员变量clientData是用来指向客户端私有数据的指针。

除了事件的数据结构以外前面我还提到Redis在ae.h文件中定义了支撑框架运行的主要函数包括框架主循环的aeMain函数、负责事件捕获与分发的aeProcessEvents函数以及负责事件和handler注册的aeCreateFileEvent函数它们的原型定义如下

void aeMain(aeEventLoop *eventLoop);
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, aeFileProc *proc, void *clientData);
int aeProcessEvents(aeEventLoop *eventLoop, int flags);

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

主循环aeMain函数

我们先来看下aeMain函数。

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

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        …
        aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
    }
}

那么这里你可能要问了,aeMain函数是在哪里被调用的呢

按照事件驱动框架的编程规范来说框架主循环是在服务器程序初始化完成后就会开始执行。因此如果我们把目光转向Redis服务器初始化的函数就会发现服务器程序的main函数在完成Redis server的初始化后会调用aeMain函数开始执行事件驱动框架。如果你想具体查看main函数main函数在server.c文件中,我们在第8讲中介绍过该文件server.c主要用于初始化服务器和执行服务器整体控制流程你可以回顾下。

不过既然aeMain函数包含了事件框架的主循环**那么在主循环中,事件又是如何被捕获、分发和处理呢?**这就是由aeProcessEvents函数来完成的了。

事件捕获与分发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的调用这里我也放了一张示意图你可以看看整个调用链是如何工作和实现的。

OK现在我们就已经在aeMain函数中看到了aeProcessEvents函数被调用并用于捕获和分发事件的基本处理逻辑。

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

事件注册aeCreateFileEvent函数

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

具体来说在initServer函数的执行过程中initServer函数会根据启用的IP端口个数为每个IP端口上的网络事件调用aeCreateFileEvent创建对AE_READABLE事件的监听并且注册AE_READABLE事件的处理handler也就是acceptTcpHandler函数。这一过程如下图所示

所以这里我们可以看到,AE_READABLE事件就是客户端的网络连接事件而对应的处理函数就是接收TCP连接请求。下面的示例代码中显示了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系统为例来给你说明一下。

首先Linux提供了epoll_ctl API用于增加新的观察事件。而Redis在此基础上封装了aeApiAddEvent函数对epoll_ctl进行调用。

所以这样一来aeCreateFileEvent就会调用aeApiAddEvent然后aeApiAddEvent再通过调用epoll_ctl来注册希望监听的事件和相应的处理函数。等到aeProceeEvents函数捕获到实际事件时它就会调用注册的函数对事件进行处理了。

好了到这里我们就已经全部了解了Redis中实现事件驱动框架的三个关键函数aeMain、aeProcessEvents以及aeCreateFileEvent。当你要去实现一个事件驱动框架时Redis的设计思想就具有很好的参考意义。

最后我再带你来简单地回顾下在实现事件驱动框架的时候你需要先实现一个主循环函数对应aeMain负责一直运行框架。其次你需要编写事件注册函数对应aeCreateFileEvent用来注册监听的事件和事件对应的处理函数。只有对事件和处理函数进行了注册,才能在事件发生时调用相应的函数进行处理。

最后你需要编写事件监听、分发函数对应aeProcessEvents负责调用操作系统底层函数来捕获网络连接、读、写事件并分发给不同处理函数进一步处理。

小结

Redis一直被称为单线程架构按照我们通常的理解单个线程只能处理单个客户端的请求但是在实际使用时我们会看到Redis能同时和成百上千个客户端进行交互这就是因为Redis基于Reactor模型实现了高性能的网络框架通过事件驱动框架Redis可以使用一个循环来不断捕获、分发和处理客户端产生的网络连接、数据读写事件。

为了方便你从代码层面掌握Redis事件驱动框架的实现我总结了一个表格其中列出了Redis事件驱动框架的主要函数和功能、它们所属的C文件以及这些函数本身是在Redis代码结构中的哪里被调用。你可以使用这张表格来巩固今天这节课学习的事件驱动框架。

最后我也再强调下这节课我们主要关注的是事件驱动框架的基本运行流程并以客户端连接事件为例将框架主循环、事件捕获分发和事件注册的关键步骤串起来给你做了介绍。Redis事件驱动框架监听处理的事件还包括客户端请求、服务器端写数据以及周期性操作等这也是我下一节课要和你一起学习的主要内容。

每课一问

这节课我们学习了Reactor模型除了Redis你还了解什么软件系统使用了Reactor模型吗