vlambda博客
学习文章列表

Redis系列5-事件驱动模型+IO多路复用机制

    Redis中所有命令执行都是基于事件驱动模型的,结合IO多路复用机制,实现单进程单线程的高并发快速访问。

    Redis中的事件包含文件事件和时间事件,文件事件基于客户端和Redis服务端建立Socket连接产生的事件,是对Socket连接的一个抽象概念。而时间事件指的是Redis服务端需要的定时出发的事件,类似于定时任务。

一、Redis 事件驱动模型

1.1 事件驱动模型

    事件驱动的意思是只有发生某个事件时,程序才会有所行动。事件驱动模型在架构设计领域被称为Reactor 模式,体现的是一种被动响应的特征。模型流程图如下

主程序处于一个阻塞状态的事件循环(event loop)中等待事件(event),当有事件发生时,根据事件的属性分发到相应的处理函数进行处理。事件以并发的方式发送到服务处理器 (service handler),服务处理器将事件整合到一个有序队列中(这过程称为demultiplexes),并分发到具体的请求处理器 (request handler)进行处理。

1.2 Redis 核心原理

Redis程序的整个运作都是围绕事件循环 (event loop)进行的,当有来自外部或内部的请求的时候,才会执行相关的流程。
事件循环对于 Redis 而言,就像是一台车的引擎一样,提供了整个系统所需的流转动力。所有其他的组件都是基于这个引擎的基础上组合和构建起来的。可以说理解了 Redis 的事件循环就能了解 Redis 的工作原理的核心。

Redis 事件模型如下图所示:

Redis系列5-事件驱动模型+IO多路复用机制

Redis中循环事件本质上是对Socket连接的抽象,循环事件包括accept事件、read事件、write事件。
Redis中eventLoop中保存这events和fired两个事件队列,events中保存的是所有事件,而fired保存的是已就绪待处理的事件。

当Socker连接可读或者可写时,就把对应的事件加入到事件待处理队列fired中,以有序 (sequentially)、同步 (synchronously) 的方式发送给事件处理器进行处理。这个过程在Redis中被称为Fire。最后由事件对应的处理器(Handler)将处理的结果返回给客户端去。

Redis在实现这个过程时,采用了IO 多路复用 (multiplexing) 的方式,封装了操作系统底层 select/epoll 等函数,实现对多个套接字 (socket) 的监听,这些套接字就是对应多个不同客户端的连接。

通过以上概括了Redis 处理用户请求的大致过程。从这个过程我们可以发现:

  • Redis 处理所有命令都是顺序执行的,其中包括来自客户端的连接请求。所以当 Redis 在处理一个复杂度高、时间很长的请求(比如 KEYS 命令)的时候,其他客户端的连接都没办法相应。

  • Redis 内部定时执行的任务也是放在顺序队列中处理,其中也可能包含时间较长的任务,比如自动删除一个过期的大 Key,比如很大 list, hash, set 等。所以有时候会遇到明明业务没有主动操作复杂,但也会出现卡顿的问题。

1.2.1 什么是 IO 多路复用 (multiplexing)? 

I/O多路复用是一种机制,通过监视多个描述符,一旦某个描述符就绪,就通知程序进行相应的操作。多路-指的是多个socket连接,复用-指的是复用一个线程。

I/O多路复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis具有很高的吞吐量。
多路复用主要有三种技术:select,poll,epoll。epoll是最新的也是目前最好的多路复用技术。
Redis中的IO多路复用模块
Redis中IO多路复用是封装了操作系统提供的select,epoll,avport和kqueue这些基础函数,向上层提供了一个统一的接口,屏蔽了底层实现的细节。现在看看Redis是怎么利用linux提供的epoll实现I/O 多路复用。

epoll提供的3个方法:

/*
 * 创建一个epoll的句柄对象,在epoll文件系统中为这个句柄对象分配资源,
 * size用来告诉内核这个监听的数目一共有多大
 */

int epoll_create(int size);
/*
 * 可以理解为,增删改 fd 需要监听的事件
 * epfd 是 epoll_create() 创建的句柄。
 * op 表示 增删改
 * epoll_event 表示需要监听的事件,Redis 只用到了可读,可写,错误,挂断 四个状态
 */

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
 * 可以理解为查询符合条件的事件
 * 收集发生的事件的连接
 * epfd 是 epoll_create() 创建的句柄。
 * epoll_event 用来存放从内核得到事件的集合
 * maxevents 获取的最大事件数
 * timeout 等待超时时间
 */

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
;
 Redis 封装epoll向上提供的接口:
//事件状态
typedef struct aeApiState {
    // epoll_event 实例描述符
    int epfd;
    // 事件槽
    struct epoll_event *events;

} aeApiState
//创建一个新的epoll;对 epoll.epoll_create() 的封装。
static int  aeApiCreate(aeEventLoop *eventLoop)
//调整事件槽的大小
static int  aeApiResize(aeEventLoop *eventLoop, int setsize)
// 释放 epoll 实例和事件槽
static void aeApiFree(aeEventLoop *eventLoop)
//关联给定事件到 fd;对 epoll.epoll_ctl()的封装。
static int  aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask)
//从 fd 中删除给定事件;对 epoll.epoll_ctl()的封装。
static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int mask)
//拉取可执行事件;对 epoll_wait()的封装。
static int  aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp)

1.3 事件驱动模型的优势

  • 有利于架构解耦和模块化开发,使功能架构实现上更加解耦,模块的可重用性更高。因事件循环的流程本身和具体的处理逻辑之间是独立的,只要在创建事件的时候关联特定的处理逻辑(事件处理器),就可以完成一次事件的创建和处理。

  • 有利于减小高并发量情况下对性能的影响,相比一个连接分配一个线程的模型, Reactor 模式(固定线程数)在连接数增大的情况下吞吐量不会明显降低,延时也不会也受到显著的影响。

二、事件循环的 Redis 实现

2.1 Redis 事件循环 Event Loop

Redis实现事件循环主要涉及三个源码文件:server.cae.cnetworking.c

  • server.cmain()函数是整个Redis程序的开始,这里初始化redis的配置,创建时间循环对象EventLoop,同时注册一个服务端可读事件,等待客户端连接。

  • ae.c实现事件循环和事件的相关功能。

  • networking.c则负责处理网络IO相关的功能。

事件循环流程如下图:

Redis系列5-事件驱动模型+IO多路复用机制

a. 初始化配置,创建时间循环,并调用执行时间循环函数
//main方法中初始化执行3个步骤:
//1.加载配置 2.创建时间循环对象并注册可读事件 3.执行事件循环
// 0. 定义服务器主要结构体, 加载服务器配置
struct redisServer server;
initServerConfig();
loadServerConfig();
// 1. 根据配置参数初始化,
initServer() {
    // 1.1 实际创建事件循环
    server.el = aeCreateEventLoop();
    // 1.2 创建一个关联了acceptTcpHandler处理器的可读事件,
   //用于响应外部客户端请求,实现Redis对外提供的服务地址和端口的连接服务。
    aeCreateFileEvent(server.el, AE_READABLE, acceptTcpHandler)
}
//2. 执行事件循环,等待连接和命令请求
aeMain(server.el);

//初始化过程中被创建的server.el包含了两个事件的列表,它的结构体实现如下:
typedef struct aeEventLoop
{

    aeFileEvent events[AE_SETSIZE]; /* 注册的事件,被 eventloop 监听 */
    aeFiredEvent fired[AE_SETSIZE]; /* 有读写操作需要执行的事件(就绪事件) */
} aeEventLoop;
b. ae.c文件中的aeMain()实现事件循环的执行,简化后的代码如下:

//事件循环主要就是一个while循环,不断去轮询是否有就绪的事件需要处理,具体的处理函数是aeProcessEvents
void aeMain(aeEventLoop *eventLoop) {
    while (!eventLoop->stop) {
        aeProcessEvents(eventLoop, AE_ALL_EVENTS);
    }
}

c. networking.c文件中,综合调度器(aeProcessEvents)是 Redis 统一处理所有事件的地方。

//从 epoll 中获关注的事件
numevents = aeApiPoll(eventLoop, tvp);
for (j = 0; j < numevents; j++) {
    // 从已就绪数组中获取事件 fe就是要处理的文件事件 file event
    aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
    int mask = eventLoop->fired[j].mask;
    int fd = eventLoop->fired[j].fd;int rfired = 0;
    // 读事件
    if (fe->mask & mask & AE_READABLE) {
        // rfired 确保读/写事件只能执行其中一个
        rfired = 1;
        fe->rfileProc(eventLoop,fd,fe->clientData,mask);
    }
        // 写事件
    if (fe->mask & mask & AE_WRITABLE) {
        if (!rfired || fe->wfileProc != fe->rfileProc)
            fe->wfileProc(eventLoop,fd,fe->clientData,mask);
    }
    processed++;
}
...
//最后一步:如果有时间事件,则进行时间事件的处理:
processTimeEvents(eventLoop);

2.2 事件处理器Event Handler

所有 事件 被创建时,都会关联一个 处理器 (handler) ,并注册到 事件循环 中,事件处理器用于具体的读写操作。 Redis的常 几个事 件处理器 有:
一个客户端一次正常的连接和命令操作流程,可以通过上述三个处理器完成。 当 Redis 需要监听某个套接字的时候,就会创建一个 事件 ,并注册到 事件循环 中进行监听,Redis 将 处理器 以参数的方式关联到事件中。

比如以下是注册一个可读事件的操作:

/**
 * server.el:事件循环 eventloop,一个服务器只有一个el
 * fd:表示这个客户端连接的文件描述符,每个客户端连接对应一个
 * AE_READABLE:表示这是一个可读事件,可以理解为客户端准备进行写操作
 * readQueryFromClient:这个事件关联的处理器,当事件就绪后,就会调用此处理器
 * c:表示这个客户端在Redis中指向的变量
 **/

aeCreateFileEvent(server.el, fd, AE_READABLE, readQueryFromClient, c)
//注册完毕后,事件循环就会将这个事件(套接字)加入到监听的范围,当事件可读时,Redis就会将这个事件
//发送到待处理事件队列中等待处理,等到可读就绪时,会被readQueryFromClient处理器处理。

至此,Redis 的事件循环的机制已经介绍完毕,可以观察到整个事件循环的逻辑过程都没有涉及具体的命令操作,只需要定义事件的类型和处理器即可。可以说这部分就是Reactor 模式体现出来的一个好处:接收事件和处理流程的实现相互解耦。

三、一次命令操作的完整流程

本章是建立在 Redis 已经完成了初始化工作,主要是创建事件循环之后,Redis 接受一个客户端操作的完整流程的介绍。

本章主要分为两个阶段:

  1. 第一个阶段:一个外部客户端与 Redis 服务器建立 TCP 连接。

  1. 第二阶段:已经建立连接的客户端,对Redis 发起一次SET命令的操作。

3.1 一个客户端连接进服务器的过程

如图,展示一个新的外部客户端与 Redis 服务器建立连接的过程。

当有客户端连接到 Redis 服务器的时候,注册在事件循环中的监听服务端口的事件就会变成读就绪状态,从而触发这个事件到待处理事件队列中,准备调用acceptTcpHandler进行处理。

  1. 为在服务器端创建一个对应本次连接的套接字。

  2. 把服务端套接字的文件描述符cfd作为参数,创建client变量。

  3. 为该客户端连接创建并注册一个关联了readQueryFromClient处理器的可读事件到事件循环,用于下一步接收并执行命令的工作。

3.2 一次客户端连接和调用命令的执行流程

如图展示一个客户端已经完成了连接,对 Redis 服务器发起一次SET操作后,Redis 处理命令的完整流程。

在上一节中提到,当一个客户端建立连接后,会有一个 可读事件 关联到 事件循环 ,等待接收命令。当有客户端发起一次命令操作后,Redis 就会调 readQueryFromClient 处理器,对用户发送过来的请求,按 RESP (REdis Serialization Protocol) 进行解析处理后,调用相关的命令进行处理。
  1. 调用命令的函数主要做两个事情:(1)查找对应的命令,比如这里的SET(2)调用该命令关联的函数进行处理,这里就是setCommand

  2. setCommand函数将客户端传进来的参数,变更数据库对应 KEY 的值,然后回复客户端。

  3. 回复客户端addReply函数将返回给客户端的内容,写到客户端变量的输出缓冲client.buf中,等待发送给客户端。

返回结果给客户端

以上是整个SET命令的事件处理,不过在这个时候,返回给用户的回复内容,只存放于服务器的客户端变量输出缓冲中。至于将结果返回给用户的过程,取决于版本,有不同的操作。

在 4.0 以前,每次的addReply操作会创建一个写事件,然后放到事件循环中执行。

而 4.0 开始,在每次重新进入一个新的循环之前,就是eventLoop->beforesleep();这个操作,Redis 会尝试直接发送给客户端,只有当发送的内容超过一定大小,无法一次发送完成的时候,才会去创建一个可写事件

有兴趣的读者可以去看下 Redis 作者的这个 commit:

antirez in commit 1c7d87d: Avoid installing the client write handler when possible.

目的是减少一次系统调用,适用于大部分操作类命令的回复。

Redis为什么单线程能承载高并发?

1. Redis是纯内存数据库,一般都是简单的存取操作,线程占用的时间很多,时间的花费主要集中在IO上,所以读取速度快。
2. Redis使用的是非阻塞IO,IO多路复用,使用了单线程来轮询描述符,将数据库的开、关、读、写都转换成了事件,减少了线程切换时上下文的切换和竞争。
3. Redis采用了单线程的模型,保证了每个操作的原子性,也减少了线程的上下文切换和竞争。
4. 数据结构,Redis全程使用hash结构,读取速度快,还有一些特殊的数据结构,对数据存储进行了优化,如压缩表,对短数据进行压缩存储,再如,跳表,使用有序的数据结构加快读取的速度。

5.Redis采用自己实现的事件分离器,效率比较高,内部采用非阻塞的执行方式,吞吐能力比较大。

四、总结

    Redis采用IO多路复用技术,将多客户端的Socket连接、读、写转化为服务端文件的可读可写事件,并采用单线程循环的方式去拉取已就绪的事件,然后找到事件对应的处理器执行相应的处理操作,使用单线程减少了线程的上下文切换和竞争。这就是Redis的事件驱动模型,整个事件循环的逻辑过程都没有涉及具体的命令操作,只需要定义事件的类型和处理器即可,实现接收事件和处理流程相互解耦。