Redis设计与实现 学习笔记 第十二章 事件
Redis服务器是一个事件驱动程序,服务器需要处理以下两类事件:
1.文件事件(file event):Redis服务器通过套接字与客户端(或其他Redis服务器)连接,而文件事件就是数据库对套接字操作的抽象。服务器与客户端(或其他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作。
2.时间事件(time event):Redis服务器中的一些操作(如serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。
12.1 文件事件
Redis基于Reactor模式开发了网络事件处理器,又被称为文件事件处理器(file event handler):
1.文件事件处理器使用IO多路复用(multiplexing)来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
2.当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相关联的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
虽然文件事件处理器以单线程方式运行,但通过使用IO多路复用来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与Redis中其他同样以单线程方式运行的模块进行对接,这保持了Redis内部单线程设计的简单性。
12.1.1 文件事件处理器的构成
下图展示了文件事件处理器的四个组成部分,分别是套接字、IO多路复用程序、文件事件分派器(dispatcher)、事件处理器:
文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答(accept)、写入、读取、关闭等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个套接字,所以多个文件事件有可能并发地出现。
IO多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。
尽管多个文件事件可能会并发出现,但IO多路复用程序总是会将所有产生事件的套接字都放到一个队列里,然后通过这个队列,以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字。当上一个套接字产生的事件被处理完毕后(该套接字为事件所关联的事件处理器执行完毕),IO多路复用程序才会继续向文件事件分派器传送下一个套接字:
文件事件分派器接收IO多路复用程序传来的套接字,并根据套接字产生的事件类型,调用相应的事件处理器。
服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是函数,它们定义了某个时间发生时,服务器应该执行的动作。
12.1.2 IO多路复用程序的实现
Redis的IO多路复用程序的所有功能都是通过包装常见的select、poll、evport(solaris系统下的异步IO库)、kqueue这些IO多路复用函数库来实现的,每个IO多路复用函数库都对应一个单独的文件,如ae_select.c、ae_epoll.c、ae_kqueue.c等。
Redis为每个IO多路复用函数库都包装了相同的API,所以IO多路复用程序的底层是可以换的:
Redis在IO多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时自动选择系统中性能最高的IO多路复用函数库作为Redis的IO多路复用程序的底层实现:
/* Include the best multiplexing layer supported by this system.
* The following should be ordered by performances, descending. */
# 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
12.1.3 事件的类型
IO多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITEABLE事件,这两类事件和套接字操作之间的对应关系如下:
1.当套接字变得可读时(客户端对套接字执行write或close操作),或有新的可应答套接字出现(客户端对服务器的监听套接字执行connect操作)时,套接字产生AE_READABLE事件。
2.当套接字变得可写时(客户端对套接字执行read操作;作者这里说是客户端的读导致的可写,其实是服务器的发送缓冲区有空间了,即使客户端不read,只要有发送缓冲区空间,就可写),套接字产生AE_WRITABLE事件。
IO多路复用程序允许服务器同时监听AE_READABLE和AE_WRITABLE事件,如果一个套接字同时产生了这两种事件,那么文件事件分派器会优先处理AE_READABLE事件。
即,如果一个套接字即可读又可写,那么服务器将先读套接字。
12.1.4 API
ae.c/aeCreateFileEvent函数接受一个套接字描述符、一个事件类型、一个事件处理器作为参数,将给定套接字的给定事件加入到IO多路复用程序的监听范围内,并对事件和事件处理器进行关联。
ae.c/aeDeleteFileEvent函数接受一个套接字描述符、一个监听事件类型作为参数,让IO多路复用程序取消对给定套接字的给定事件的监听,并取消事件和事件处理器之间的关联。
ae.c/aeGetFileEvents函数接受一个套接字描述符,返回该套接字正在被监听的事件类型:
1.如果套接字没有任何事件被监听,那么函数返回AE_NONE。
2.如果套接字的读事件正被监听,那么函数返回AE_READABLE。
3.如果套接字的写事件正被监听,那么函数返回AE_WRITABLE。
4.如果套接字的读事件和写事件正被监听,那么函数返回AE_READABLE | AE_WRITABLE。
ae.c/aeWait函数接受一个套接字描述符、一个事件类型、一个毫秒数为参数,在给定时间内阻塞并等待套接字的给定类型事件产生,当事件成功产生,或等待超时后,函数返回。
ae.c/aeApiPoll函数接受一个sys/time.h/struct timeval结构为参数,并在指定时间内,阻塞并等待所有被aeCreateFileEvent函数设置为监听状态的套接字,当至少有一个事件产生,或等待超时后,函数返回。
ae.c/aeProcessEvents函数是文件事件分派器,它先调用aeApiPoll函数等待事件产生,然后遍历所有已产生的事件,并调用相应的事件处理器来处理这些事件。
ae.c/aeGetApiName函数返回IO多路复用程序使用的底层函数库,返回"epoll"表示底层为epoll函数库。
12.1.5 文件事件的处理器
Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求,比如:
1.为了对发起连接到服务器的客户端进行应答,服务器要为监听套接字关联连接应答处理器。
2.为了接收客户端传来的命令请求,服务器要为客户端套接字关联命令请求处理器。
3.为了向客户端返回命令的执行结果,服务器要为客户端套接字关联命令回复处理器。
4.当主服务器和从服务器进行复制操作时,主从服务器都需要关联主从复制相关的处理器。
1.连接应答处理器
networking.c/acceptTcpHandler函数是Redis的连接应答处理器,用于对连接监听套接字的客户进行应答,具体实现为sys/socket.h/accept函数的包装。
当Redis服务器初始化时,程序会将连接应答处理器和监听套接字的AE_READABLE事件关联起来,当有客户端用sys/socket.h/connect函数连接服务器监听套接字时,监听套接字就会产生AE_READABLE事件,引发连接应答处理器执行:
2.命令请求处理器
networking.c/readQueryFromClient函数是Redis的命令请求处理器,这个处理器负责从套接字中读入客户发送的请求内容,具体实现为unistd.h/read函数的包装。
当一个客户端通过连接应答处理器成功连接到服务器后,服务器会将客户端套接字的AE_READABLE事件命令请求处理器关联起来,当客户端向服务器发送命令请求时,套接字就会产生AE_READABLE事件,引发命令请求处理器执行:
3.命令回复处理器
networking.c/sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将服务器执行命令后的回复通过套接字返回客户端,具体实现为unistd.h/write函数的包装。
当服务器有命令回复需要给客户端时,服务器会将客户端套接字的AE_WRITABLE事件和命令回复处理器关联起来,当客户端准备好接受服务器传回的命令回复时(实际上,是在服务器的发送缓冲区有空间时)就会产生AE_WRITABLE事件,引发命令回复处理器执行:
当命令回复发送完后,服务器就会解除命令回复处理器与客户端套接字的AE_WRITABLE事件之间的关联。
4.一次完整的客户端与服务器连接事件示例
假设Redis服务器正在运行,那么这个服务器的监听套接字的AE_READABLE事件应该正处于监听状态下,而该事件对应的处理器为连接应答处理器。
如果这时有一个Redis客户端向服务器发起连接,那么监听套接字将产生AE_READABLE事件,触发连接应答处理器执行。处理器会接受请求、创建客户端套接字和客户端状态、将客户端套接字的AE_READABLE事件与命令请求处理器进行关联,使得客户端向主服务器发送命令请求时能被处理。
之后,假设客户端向主服务器发送一个命令请求,那么客户端套接字将产生AE_READABLE事件,引发命令请求处理器执行,处理器读取客户端的命令内容,然后去执行。
执行命令将产生命令回复,为了将命令回复传送回客户端,服务器会将客户端套接字的AE_WRITABLE事件与命令回复处理器进行关联。当回复能发送时,将产生AE_WRITABLE事件,触发命令回复处理器执行,当命令回复处理器将全部回复写入套接字后,将不再监听客户端套接字的AE_WRITABLE事件。
12.2 时间事件
Redis的时间事件分为两类:
1.定时事件:让一段程序在指定时间后执行一次。
2.周期性事件:让一段程序每隔指定时间就执行一次。
一个时间事件主要由以下三个属性组成:
1.id:服务器为时间事件创建的全局唯一ID标识,ID按从小到大的顺序递增,新事件的ID比旧事件ID要大。
2.when:毫秒精度的UNIX时间戳,记录了时间事件的到达(arrive)时间。
3.timeProc:时间事件处理器,一个函数。当时间事件到达时,服务器会调用相应的处理器来处理事件。
一个时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值:
1.如果事件处理器返回ae.h/AE_NOMORE,那么这个事件为定时事件,该事件在到达一次后就会被删除,之后不再到达。
2.如果事件处理器返回一个非AE_NOMORE的整数值,那么这个事件为周期性事件:服务器会根据处理器返回的值,对when属性进行更新,从而让这个事件在一段时间后再次到达。如一个时间事件处理器返回30,那么这个事件在30毫秒后会再次到达。
目前版本(Redis 2.9)的Redis只使用了周期性事件,没有使用定时事件。
12.2.1 实现
服务器将所有时间事件都放在一个无序链表中,每当时间事件执行器运行时,就遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器(感觉用堆会更好,在事件多时查找性能更高)。
下图展示了一个保存时间事件的链表的例子,其中包含三个不同的时间事件,因为新的时间事件总会插入到链表表头,因此靠近表头的时间事件的ID更大:
我们说保存时间事件的链表为无序链表,指的是不按when属性大小排序,而非ID。
无序链表并不影响时间事件处理器的性能,目前版本(2.9)中,正常模式下Redis服务器只使用serverCron一个时间事件,而benchmark模式下,服务器也只使用两个时间事件。正常情况下,服务器几乎是将无序链表退化成一个指针来使用,因此不会影响事件执行的性能。
12.2.2 API
ae.c/aeCreateTimeEvent函数接受一个毫秒数milliseconds和一个时间事件处理器proc作为参数,将一个新时间事件添加到服务器,这个新时间事件将在milliseconds毫秒后到达,到达后会执行proc函数。
ae.c/aeDeleteFileEvent函数接受一个时间事件ID为参数,删除该ID对应的时间事件。
ae.c/aeSearchNearestTimer函数返回距现在最近的那个时间事件,即下一个即将到达的时间事件。
ae.c/processTimeEvents函数是时间事件的执行器,它会遍历所有已到达的时间事件,并调用这些事件的处理器。
processTimeEvents函数可用以下伪代码描述:
def processTimeEvents():
# 遍历服务器中所有时间事件
for time_event in all_time_event():
# 检查事件是否已经到达
if time_event.when <= unix_ts_now():
# 事件已到达
# 执行事件处理器,并获取返回值
retval = time_event.timeProc()
# 如果这是一个定时事件
if retval == AE_NOMORE:
# 那么将该事件从服务器中删除
delete_time_event_from_server(time_event)
# 如果这是一个周期性事件
else:
# 那么按照事件处理器的返回值更新时间事件的when属性
# 让这个事件在指定时间后再次到达
update_when(time_event, retval)
12.2.3 时间事件应用实例:serverCron函数
持续运行的Redis服务器需要定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行,这些定期操作由redis.c/serverCron函数负责执行,它的主要工作包括:
1.更新服务器的各类统计信息,如时间、内存占用、数据库占用情况等。
2.清理数据库中的过期键值对。
3.关闭和清理连接失效的客户端。
4.尝试进行AOF或RDB持久化操作。
5.如果服务器是主服务器,那么对从服务器进行定期同步。
6.如果处于集群模式,对集群进行定期同步和连接测试。
Redis服务器以周期性事件的方式运行serverCron函数,在服务器运行期间,每隔一段时间,serverCron就会执行一次,直到服务器关闭。
在Redis2.6版本,服务器默认规定serverCron每秒运行十次,平均每间隔100毫秒运行一次。
从Redis2.8开始,用户可通过修改hz选项调整serverCron的每秒执行数,具体参考示例配置文件redis.conf中关于hz选项的说明。
12.3 事件的调度与执行
服务器会对文件事件和时间事件进行调度,决定何时应处理文件事件,何时应处理时间事件,以及花多少时间来处理它们等。
事件的调度和执行由ae.c/aeProcessEvents函数负责,以下是它的伪代码表示:
def aeProcessEvents():
# 获取到达时间离当前时间最近的时间事件
time_event = aeSearchNearestTimer()
# 计算最接近的时间事件距离到达还有多少毫秒
remaind_ms = time_event.when - unix_ts_now()
# 如果事件已到达,那么remaind_ms会是负数,将它设为0
if remaind_ms < 0:
remaind_ms = 0
# 根据remaind_ms的值,创建timeval结构
timeval = create_timeval_with_ms(remaind_ms)
# 阻塞并等待文件事件产生,最大等待时间由传入的timeval结构决定
# 如果remaind_ms为0,那么aeApiPoll调用后马上返回,不阻塞
aeApiPoll(timeval)
# 处理所有产生的文件事件
processFileEvents()
# 处理所有到达的时间事件
processTimeEvents()
注意,前面12.1节介绍文件事件API时,没有讲到processFileEvents函数,因为它并不存在,实际上,处理文件事件的代码是直接写在aeProcessEvents函数里的,这里为了方便讲述,才虚构了processFileEvetns函数。
将aeProcessEvents函数置于一个循环里,加上初始化和清理函数,就构成了Redis服务器的主函数,将其用伪代码表示:
def main():
# 初始化服务器
# 一直处理事件,直到服务器关闭
while server_is_not_shutdown():
aeProcessEvents()
# 服务器关闭,执行清理操作
clean_server()
以下是事件的调度和执行规则:
1.aeApiPoll函数的最大阻塞时间由到达时间最接近当前时间的时间事件决定,这样既可以避免服务器对时间事件进行频繁的轮询(忙等待),也可以确保aeApiPoll函数不会阻塞过长时间。
2.因为文件事件是随机的,如果等待并处理完一次文件事件后,仍未有时间事件到达,那么服务器将再次等待并处理文件事件。随着文件事件的不断执行,时间会逐渐向时间事件所设置的到达时间逼近,并最终来到到达时间,这时服务器就可以开始处理到达的时间事件了。
3.对文件事件和时间事件的处理都是同步、有序、原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占,因此,不管是文件事件的处理器,还是时间事件的处理器,它们都会尽可能减少程序的阻塞时间,并在有需要时主动让出执行权,从而降低造成事件饥饿的可能性。比如说,在命令回复处理器将一个命令回复写入客户端套接字时如果写入字节数超过一个预设常量,命令回复处理器会主动用break跳出写循环,将余下的数据留到下次再写;另外,时间事件也会将非常耗时的持久化操作放到子进程或子线程中执行。
4.因为时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间通常比时间设定的到达时间稍晚一些。
上表记录的事件执行过程凸显了上面列举的时间调度规则中的2、3、4:
1.因为时间事件尚未到达,所以在处理时间事件之前,服务器已经等待并处理了两次文件事件。
2.因为处理事件的过程中不会出现抢占,所以实际处理时间事件的时间比预订的100毫秒晚了30毫秒。
12.4 重点回顾
1.Redis服务器是一个事件驱动程序,服务器处理的事件分为时间事件和文件事件两类。
2.文件事件处理器是基于Reactor模式实现的网络通信程序。
3.文件事件是对套接字操作的抽象:每次套接字变为可应答(acceptable)、可写(writable)、可读(readable)时,相应的文件事件就会产生。
4.文件事件分为AE_READABLE事件(读事件)和AE_WRITABLE(写事件)两类。
5.时间事件分为定时事件和周期性事件:定时事件只在指定的时间到达一次,而周期性事件每隔一段时间到达一次。
6.服务器一般情况下只执行serverCron函数一个时间事件,且这个事件是周期性事件。
7.服务器会轮流处理时间事件和文件事件,且事件处理过程中也不会发生抢占。
8.时间事件的实际处理时间通常会比设定的到达时间晚一些。