Redis事件机制:高效运行的秘密武器
对,没错,这又是一篇讲为什么 Redis 如此之快的文章。不过这次的内容于上一篇文章不一样,本文将聚焦在 Redis 的事件机制的基础概念和实现,不在过多提及它对 Redis 速度的影响。
我在之前的文章中多次提过,Redis 是单线程的,你是否想过,一个线程要如何处理来自各个客户端的各种请求呢?它忙的过来吗?
了解 Redis 的都知道,它不光忙得过来,还做的井井有条。其中就多亏了 IO 多路复用,不仅仅是它,事件机制在其中也是一个不错的设计。
关于 IO 多路复用,我们在之前的文章中也提到过很多次了,所以这里就只聚焦在 Redis 的事件机制上。
如果是你,会怎么做?
让我们来设计一个 redis,我们要怎么处理请求连接呢?
最笨的方法,那就是来一个客户端就 accept
一次,然后有什么请求就做什么事,先来先做。显然,这样做别说一个线程了,就算有十个线程都不够用的,太慢了。
我们还可以这样设计,来一个我就单独开设一个线程处理它,相当于你一来我就单独找一个人为你服务,而服务的人最终会将请求给到一个处理中心,让处理中心统一去处理,然后将结果返回。但显然 Redis 没有那么多资源让你浪费。
各种设计都不能满足要求,那就摇人,叫大哥——IO多路复用。至少他能帮我解决前面服务的问题,fd 我就不管了,直接告诉我哪些人来了,并且告诉我有事的是哪些人。
既然 epoll_wait 能 告诉我们有那些 socket 已经就绪,那么我们就处理就绪的这些就可以了。但我们需要一个合理的机制来帮我们来优雅的处理他们,毕竟 Redis 后面只有个单线程在处理。由于处理没这么快,肯定需要一个地方来存放未处理的这些事件,那很合理就能想到需要一个类似 buffer 的东西。
所以,对于这个事件机制,我第一个想法就是弄个队列,或者 ringbuffer 来搞,那不就是一个生产消费者模型吗?
事件机制
Redis中的事件驱动库只关注网络IO,以及定时器。
该事件库处理下面两类事件:
- 文件事件(file event):用于处理 Redis 服务器和客户端之间的网络IO。
- 时间事件(time eveat):Redis 服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是处理这类定时操作的。
事件驱动库的代码主要是在src/ae.c
中实现的,其示意图如下所示。
aeEventLoop
是整个事件驱动的核心,它管理着文件事件表和时间事件列表,不断地循环处理着就绪的文件事件和到期的时间事件。
文件事件:Redis 的特工队
Redis基于Reactor模式开发了自己的网络事件处理器,也就是文件事件处理器。文件事件处理器使用IO多路复用技术,同时监听多个套接字,并为套接字关联不同的事件处理函数。当套接字的可读或者可写事件触发时,就会调用相应的事件处理函数。
Redis 事件想用框架 ae_event 及文件事件处理器
Redis并没有使用 libevent 或者 libev 这样的成熟开源方案,而是自己实现一个非常简洁的事件驱动库 ae_event。
Redis 使用的IO多路复用技术主要有:select
、epoll
、evport
和kqueue
等。每个IO多路复用函数库在 Redis 源码中都对应一个单独的文件,比如ae_select.c
,ae_epoll.c
, ae_kqueue.c
等。Redis 会根据不同的操作系统,按照不同的优先级选择多路复用技术。事件响应框架一般都采用该架构,比如 netty 和 libevent。
如下图所示,文件事件处理器有四个组成部分,它们分别是套接字、I/O多路复用程序、文件事件分派器以及事件处理器。
文件事件是对套接字操作的抽象,每当一个套接字准备好执行 accept
、read
、write
和 close
等操作时,就会产生一个文件事件。因为 Redis 通常会连接多个套接字,所以多个文件事件有可能并发的出现。
I/O多路复用程序负责监听多个套接字,并向文件事件派发器传递那些产生了事件的套接字。
尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生的套接字都放到同一个队列(也就是后文中描述的aeEventLoop的fired就绪事件表)里边,然后文件事件处理器会以有序、同步、单个套接字的方式处理该队列中的套接字,也就是处理就绪的文件事件。
所以,一次 Redis 客户端与服务器进行连接并且发送命令的过程如上图所示。
- 客户端向服务端发起建立 socket 连接的请求,那么监听套接字将产生 AE_READABLE 事件,触发连接应答处理器执行。处理器会对客户端的连接请求
- 进行应答,然后创建客户端套接字,以及客户端状态,并将客户端套接字的 AE_READABLE 事件与命令请求处理器关联。
- 客户端建立连接后,向服务器发送命令,那么客户端套接字将产生 AE_READABLE 事件,触发命令请求处理器执行,处理器读取客户端命令,然后传递给相关程序去执行。
- 执行命令获得相应的命令回复,为了将命令回复传递给客户端,服务器将客户端套接字的 AE_WRITEABLE 事件与命令回复处理器关联。当客户端试图读取命令回复时,客户端套接字产生 AE_WRITEABLE 事件,触发命令回复处理器将命令回复全部写入到套接字中。
其实,select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。
时间事件:Redis的时间旅行者
Redis 的时间事件分为以下两类:
- 定时事件:让一段程序在指定的时间之后执行一次。
- 周期性事件:让一段程序每隔指定时间就执行一次。
Redis 的时间事件的具体定义结构如下所示。
1 | typedef struct aeTimeEvent { |
一个时间事件是定时事件还是周期性事件取决于时间处理器的返回值:
- 如果返回值是
AE_NOMORE
,那么这个事件是一个定时事件,该事件在达到后删除,之后不会再重复。 - 如果返回值是非
AE_NOMORE
的值,那么这个事件为周期性事件,当一个时间事件到达后,服务器会根据时间处理器的返回值,对时间事件的 when 属性进行更新,让这个事件在一段时间后再次达到。
服务器所有的时间事件都放在一个无序链表中,每当时间事件执行器运行时,它就遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器。正常模式下的Redis服务器只使用serverCron一个时间事件,而在benchmark模式下,服务器也只使用两个时间事件,所以不影响事件执行的性能。
aeEventLoop
的具体实现
介绍完文件事件和时间事件,我们接下来看一下
aeEventLoop
的具体实现
创建事件管理器
Redis 服务端在其初始化函数 initServer中,会创建事件管理器aeEventLoop
对象。
函数aeCreateEventLoop
将创建一个事件管理器,主要是初始化 aeEventLoop
的各个属性值,比如events、fired、timeEventHead和apidata:
- 首先创建
aeEventLoop
对象。 - 初始化未就绪文件事件表、就绪文件事件表。events指针指向未就绪文件事件表、fired指针指向就绪文件事件表。表的内容在后面添加具体事件时进行初变更。
- 初始化时间事件列表,设置
timeEventHead
和timeEventNextId
属性。 - 调用
aeApiCreate
函数创建epoll实例,并初始化apidata
。
1 | aeEventLoop *aeCreateEventLoop(int setsize) { |
aeApiCreate
函数首先创建了aeApiState
对象,初始化了epoll就绪事件表;然后调用epoll_create创建了epoll实例,最后将该aeApiState
赋值给apidata
属性。
aeApiState
对象中epfd存储epoll的标识,events是一个epoll就绪事件数组,当有epoll事件发生时,所有发生的epoll事件和其描述符将存储在这个数组中。这个就绪事件数组由应用层开辟空间、内核负责把所有发生的事件填充到该数组。
1 | static int aeApiCreate(aeEventLoop *eventLoop) { |
创建文件事件
aeFileEvent
是文件事件结构,对于每一个具体的事件,都有读处理函数和写处理函数等。Redis 调用aeCreateFileEvent
函数针对不同的套接字的读写事件注册对应的文件事件。
1 | typedef struct aeFileEvent { |
比如说,Redis 进行主从复制时,从服务器需要主服务器建立连接,它会发起一个 socekt连接,然后调用aeCreateFileEvent
函数针对发起的socket的读写事件注册了对应的事件处理器,也就是syncWithMaster函数。
1 | aeCreateFileEvent(server.el,fd,AE_READABLE|AE_WRITABLE,syncWithMaster,NULL); |
aeCreateFileEvent
的参数fd指的是具体的socket套接字,proc指fd产生事件时,具体的处理函数,clientData
则是回调处理函数时需要传入的数据。
aeCreateFileEvent
主要做了三件事情:
- 以fd为索引,在events未就绪事件表中找到对应事件。
- 调用
aeApiAddEvent
函数,该事件注册到具体的底层 I/O 多路复用中,本例为epoll。 - 填充事件的回调、参数、事件类型等参数。
1 | int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, |
如上文所说,Redis 基于的底层 I/O 多路复用库有多套,所以aeApiAddEvent
也有多套实现,下面的源码是epoll下的实现。其核心操作就是调用epoll的epoll_ctl函数来向epoll注册响应事件。
1 | static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) { |
事件处理
因为 Redis 中同时存在文件事件和时间事件两个事件类型,所以服务器必须对这两个事件进行调度,决定何时处理文件事件,何时处理时间事件,以及如何调度它们。
aeMain
函数以一个无限循环不断地调用aeProcessEvents
函数来处理所有的事件。
1 | void aeMain(aeEventLoop *eventLoop) { |
下面是aeProcessEvents
的伪代码,它会首先计算距离当前时间最近的时间事件,以此计算一个超时时间;然后调用aeApiPoll
函数去等待底层的I/O多路复用事件就绪;aeApiPoll
函数返回之后,会处理所有已经产生文件事件和已经达到的时间事件。
1 | /* 伪代码 */ |
与aeApiAddEvent
类似,aeApiPoll
也有多套实现,它其实就做了两件事情,调用epoll_wait
阻塞等待epoll的事件就绪,超时时间就是之前根据最快达到时间事件计算而来的超时时间;然后将就绪的epoll事件转换到fired就绪事件。aeApiPoll
就是上文所说的I/O多路复用程序。具体过程如下图所示。
1 | static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) |
processFileEvent是处理就绪文件事件的伪代码,也是上文所述的文件事件分派器,它其实就是遍历fired就绪事件表,然后根据对应的事件类型来调用事件中注册的不同处理器,读事件调用rfileProc,而写事件调用wfileProc。
1 | void processFileEvent(int numevents) { |
而processTimeEvents是处理时间事件的函数,它会遍历aeEventLoop
的事件事件列表,如果时间事件到达就执行其timeProc函数,并根据函数的返回值是否等于AE_NOMORE来决定该时间事件是否是周期性事件,并修改器到达时间。
1 | static int processTimeEvents(aeEventLoop *eventLoop) { |
删除事件
当不在需要某个事件时,需要把事件删除掉。例如: 如果fd同时监听读事件、写事件。当不在需要监听写事件时,可以把该fd的写事件删除。
aeDeleteEventLoop函数的执行过程总结为以下几个步骤
- 根据fd在未就绪表中查找到事件
- 取消该fd对应的相应事件标识符
- 调用aeApiFree函数,内核会将epoll监听红黑树上的相应事件监听取消。
总结
那我们通过 Redis 的事件机制能学到什么呢?
- 这个事件机制的模型很通用也很清晰,包含:接收、循环、处理,三个部分,很标准的设计
- 其中对于任务的处理有一个专门的分配器去分配,这在很多 handler 的设计中非常实用,熟悉 java 的同学应该知道 DispatcherServlet 没错这样的模型会更加的清晰
- 易于扩展,这里的扩展有两方面一方面是对于处理器的扩展,之后有其他事件类型只需要增加事件处理器就可以了;而另一方面这里的扩展还包括了多线程的扩展,方便了同时支持多个事件的处理。
其实,Redis 的事件机制是一个标准的 Reactor模式 是一种基于事件驱动的设计模式,所以我们更多的是要学到这样设计模式,来运用到以后的编码中,可以更清晰也易扩展。