Redis——事件

Redis服务器是一个事件驱动程序,服务器需要处理以下两种事件:

  • 文件事件(file event):Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接,而文件事件就是服务器对套接字操作的抽象(linux下一切皆文件,返回的套接字id为文件描述符)。服务器与客户端(或者其他服务器)的通信或产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作。
  • 时间事件(time event):Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。

一.文件事件

Redis基于Reactor模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器。

  • 文件事件处理器使用I/O多路复用程序来同时监听多个套接字,并且根据套接字目前执行的任务来为套接字关联不同的事件处理器。
  • 被监听的套接字准备好执行连接应答(accept),读取(read),写入(write),关闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用I/O多路复用程序来监听多个套接字,文件事件处理器即实现了高性能的网络通信模型,又可以很好地与Redis服务器中其他同样以单线程方式运行地模块进行对接,这保持了Redis内部单线程设计地简单性。

1.1 文件处理事件的构成

文件事件处理器由四个部分组成,分别是套接字,I/O多路复用程序,文件事件分派器以及事件处理器。

文件事件的对套接字操作的抽象,每当一个套接字准备好执行连接应答(accept),写入,读取关闭等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个套接字,所以多个文件事件可能并发的发生。

I/O多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。

尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生地事件的套接字都放到一个队列里面,然后通过这个队列,以有序,同步,每次一个套接字的方式向文件事件分派器传送套接字。当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕),I/O多路复用程序才会继续向文件事件分派器传送写一个套接字。

文件事件分派器接受I/O多路复用程序传来的套接字,并根据套接字产生的事件的类型调用相应的事件处理器。

服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数,它们定义了某些事件发生时,服务器应该执行的动作。

1.2 I/O多路复用程序的实现

Redis的I/O多路复用程序的所有功能都是通过包装常见的select,epoll,evport和kqueue这些I/O多路复用函数库来实现的。每个I/O多路复用函数库在Redis源码中都对应一个单独的文件。比如:ae_select_.c,ae_epoll.c,ae_evport.c和ae_kqueue.c。

因为Redis每个I/O多路复用函数库都实现了相同的API,所以I/O多路复用程序底层实现是可以互换的。

Redis在I/O多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时自动选择系统中性能最高的I/O多路复用函数库来作为Redis的I/O多路复用程序的底层实现:

cpp 复制代码
/* 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

1.3 事件类型

I/O多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,这两类事件和套接字操作之间的对应关系如下:

  • 当套接字变成可读时(客户端对套接字执行write操作,或者close操作),或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行connect操作),套接字产生AE_READABLE事件。
  • 当套接字变得可写时(客户端对套接字执行read操作) ,套接字产生AE_WRITABLE事件。

I/O多路复用程序允许服务器同时监听套接字的AE_READABLE和AE_WRITABLE事件,如果一个套接字同时产生了两种事件,那么文件事件分派器会优先处理AE_READABLE事件,等到AE_READABLE事件处理完,才处理AE_WRITABLE事件。

也就是说一个套接字可读可写,那么服务器将先读套接字,后写套接字。

1.4 API

ae.c/aeCreateFileEvent函数接受一个套接字描述符,一个事件类型以及一个事件处理器作为参数,将给定套接字的给定事件加入到I/O多路复用程序的监听范围之内,并对事件和事件处理器进行关联。

cpp 复制代码
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
        aeFileProc *proc, void *clientData)
{
    if (fd >= eventLoop->setsize) {
        errno = ERANGE;
        return AE_ERR;
    }
    aeFileEvent *fe = &eventLoop->events[fd];

    if (aeApiAddEvent(eventLoop, fd, mask) == -1)
        return AE_ERR;
    fe->mask |= mask;
    if (mask & AE_READABLE) fe->rfileProc = proc;
    if (mask & AE_WRITABLE) fe->wfileProc = proc;
    fe->clientData = clientData;
    if (fd > eventLoop->maxfd)
        eventLoop->maxfd = fd;
    return AE_OK;
}

ae.c/aeDeleteFileEvent函数接受一个套接字描述符,一个监听事件类型作为参数,让多路复用程序取消对给定套接字的给定事件的监听,并取消事件和事件处理器之间的关联。

cpp 复制代码
void aeDeleteFileEvent(aeEventLoop *eventLoop, int fd, int mask)
{
    if (fd >= eventLoop->setsize) return;
    aeFileEvent *fe = &eventLoop->events[fd];
    if (fe->mask == AE_NONE) return;

    /* We want to always remove AE_BARRIER if set when AE_WRITABLE
     * is removed. */
    if (mask & AE_WRITABLE) mask |= AE_BARRIER;

    aeApiDelEvent(eventLoop, fd, mask);
    fe->mask = fe->mask & (~mask);
    if (fd == eventLoop->maxfd && fe->mask == AE_NONE) {
        /* Update the max fd */
        int j;

        for (j = eventLoop->maxfd-1; j >= 0; j--)
            if (eventLoop->events[j].mask != AE_NONE) break;
        eventLoop->maxfd = j;
    }
}

ae.c/aeGetFileEvents函数接受一个套接字描述符,返回该套接字正在监听的事件类型:

  • 如果套接字没有任何事件被监听,那么函数返回AE_NONE。
  • 如果套接字的读事件正在被监听,那么函数返回AE_READABLE。
  • 如果套接字的写事件正在被监听,那么函数返回AE_WRITABLE。
  • 如果套接字的读和写事件正在被监听,那么函数返回AE_READABLE | AE_WRITABLE。
cpp 复制代码
int aeGetFileEvents(aeEventLoop *eventLoop, int fd) {
    if (fd >= eventLoop->setsize) return 0;
    aeFileEvent *fe = &eventLoop->events[fd];

    return fe->mask;
}

ae.c/aeWait函数接受一个套接字描述符,一个事件类型和一个毫秒数作为参数,在给定时间内阻塞并等待套接字的给定类型事件的产生,当事件成功产生,或者等待超时之后,函数返回。

cpp 复制代码
/* Wait for milliseconds until the given file descriptor becomes
 * writable/readable/exception */
int aeWait(int fd, int mask, long long milliseconds) {
    struct pollfd pfd;
    int retmask = 0, retval;

    memset(&pfd, 0, sizeof(pfd));
    pfd.fd = fd;
    if (mask & AE_READABLE) pfd.events |= POLLIN;
    if (mask & AE_WRITABLE) pfd.events |= POLLOUT;

    if ((retval = poll(&pfd, 1, milliseconds))== 1) {
        if (pfd.revents & POLLIN) retmask |= AE_READABLE;
        if (pfd.revents & POLLOUT) retmask |= AE_WRITABLE;
        if (pfd.revents & POLLERR) retmask |= AE_WRITABLE;
        if (pfd.revents & POLLHUP) retmask |= AE_WRITABLE;
        return retmask;
    } else {
        return retval;
    }
}

ae_select.c/aeApiPoll函数接收一个sys/time.h/struct timeval结构作为参数,并在指定的时间内,阻塞并等待所有被aeCreateFileEvent函数设置为监听状态的套接字产生的文件事件,当至少一个事件产生,或者等待超时之后,函数返回。

ae.c/aeProcessEvents函数是文件事件分派器,他先调用aeApiPoll函数来等待事件产生,然后遍历所有已经产生的事件,并调用相应事件处理器来处理这些事件。

ae.c/aeGetApiName函数返回I/O多路复用程序底层所使用的I/O多路复用函数库的名称,返回"epoll"表示底层为epoll库,返回"select"表示底层为select函数库,诸如此类。

1.5 文件事件处理器

Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求,比如说:

  • 为了对连接服务器的各个客户端进行应答,服务器要为监听套接字关联连接应答处理器。
  • 为了接收客户端传来的命令请求 ,服务器要为客户端套接字关联命令请求处理器。
  • 为了向客户端返回命令执行结果,服务器要为客户端套接字关联命令回复处理器。
  • 当主服务器和从服务器进行复制操作,主从服务器都需要关联特别为复制功能编写的复制处理器。

在这些事件处理器中,服务器常用的是与客户端进行通信的连接应答处理器,命令请求处理器和命令回复处理器。

1.5.1 连接应答处理器

networking.c/acceptTcpHandler函数是Redis的连接应答处理器,这个处理器用于对连接服务器监听套接字的客户端进行应答,具体实现为sys/socket.h/accept函数的包装。

当Redis服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听套接字的AE_READABLE事件关联起来,当有客户端用sys/socket.h/connect函数连接服务器监听套接字的时候,套接字就会产生AE_READABLE事件,引发连接应答处理器执行。

1.5.2 命令请求处理器

networking.c/readQueryFromClient函数是Redis的命令请求处理器,这个处理器负责从套接字中读入客户端发送的命令请求,具体实现为unistd.h/read函数的包装。

当一个客户端通过连接应答处理器成功连接到服务器后,服务器会将客户端套接字的AE_READABLE事件和命令请求处理器关联,当客户端向服务器发送命令请求的时候,套接字会产生AE_READABLE事件,引发命令请求处理器执行,并执行相应套接字读入操作。

在客户端连接服务器的整个过程中,服务器都会一直为客户端套接字的AE_READABLE事件关联命令请求处理器。

1.5.3 命令回复处理器

networking.c/sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端,具体事项为unistd.h/write函数的包装。

当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的AE_WRITABLE事件和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令回复时,就会产生AE_WRITABLE事件,引发命令回复处理器执行,并执行相应的套接字写入操作。

当命令回复完毕之后,服务器就会解除命令回复处理器与客户端套接字的AE_WRITABLE事件之间的关联。

1.5.4 一次完整的客户但与服务器连接事件示例

假设一个Redis服务器正在允许,那么这个服务器的监听套接字的AE_READABLE事件正处于监听状态之下,而该事件所对应的处理器为连接应答处理器。

如果这时有一个Redis客户端向服务器发起连接,那么监听套接字将产生AE_READABLE事件,触发连接应答处理器执行,处理器会对客户端的连接请求进行应答,然后创建客户端套接字 ,以及客户端状态,并将**客户端套接字的AE_READABLE事件与命令请求处理器关联,**使得客户端可以向服务器发送命令请求。

之后,假设客户端向主服务器发送发送命令请求,那么客户端套接字将产生AE_READABLE事件,引发命令请求处理器执行,处理器读取客户端的命令内容,然后传给相关程序去执行。

执行命令将产生相应的命令回复,为了将命令回复传送给客户端,服务器会**将客户端套接字与AE_WRITABLE事件与命令回复处理器关联,当客户端尝试读取命令回复的时候,**客户端套接字将产生AE_WRITABLE事件,触发命令回复处理器执行,当命令回复处理器将命令回复全部写入到套接字之后,服务器就会解除客户端套接字的AE_WRITABLE事件与命令回复处理器之间的关联。

二.时间事件

Redis时间事件分为以下两类

  • 定时事件:让一段程序在指定的时间之后执行一次。比如:让程序X在当前时间的30毫秒之后执行一次。
  • 周期性事件:让一段程序每隔指定时间就执行一次。比如:让程序Y每隔30毫秒就执行一次。

一个时间事件主要由以下三个属性组成:

  • id:服务器为时间事件创建的全局唯一ID(标识号)。ID号按从小到大的顺序递增,新的事件ID号比旧事件的ID号大。
  • when:毫秒精度的UNIX时间戳,记录时间事件的到达时间。
  • timeproc:时间事件处理器,一个函数,当时间事件到达时,服务器就会调用相应的处理器来处理事件。

一个时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值:

  • 如果事件处理器返回ae.h/AE_NOMORE,那么这个事件为定时事件:该事件在到达一次之后就会被删除,只会不会被达到。
  • 如果事件处理器返回非AE_NOMORE的整数值,那么这个事件为周期性时间:当一个时间事件达到之后,服务器会根据事件处理器返回值,对时间事件的when属性进行更新,让这个事件在一段时间之后再次到达,并以这种方式一直更新并运行下去。比如:如果一个时间事件的处理器返回整数值30,那么服务器应该对这个时间事件进行更新,让这个事件在30毫秒之后再次到达。

2.1 实现

服务器将所有时间事件都放在一个无序链表中,每当时间事件执行器运行时,它就遍历整个链表,查找所有已到达的时间事件,并调用相应的时间处理器。

下图展示了一个保存时间事件的链表,链表中包含了三个不同的时间事件:因为新的时间事件总是插入到链表的表头,所以三个时间事件分别按ID逆序排列,表头事件的ID为3,中间事件ID为2,表尾事件ID为1。

注意,这里说保存时间事件的链表为无序链表,指的不是链表不按ID排序,而是说,该链表不按when属性的大小排列,正因为链表没有按when属性进行排列,所以当时间事件执行器运行的时候,它必须遍历链表中的所有时间事件,这样才能确保服务器中所有已到达的时间事件都会被处理。

无序链表并不影响时间事件处理器的性能:

在目前版本中,正常模式下的Redis服务器只使用serverCron一个时间事件,而benchmark模式下,服务器也只使用两个时间事件。在这种情况下,服务器几乎是将无序链表退化成一个指针来使用,所以使用无序链表来保存时间事件,并不影响事件执行的性能。

2.2 API

ae.c/aeCreateTimeEvent函数接受一个毫秒数milliseconds和一个时间事件处理器proc作为参数,将一个新的时间事件添加到服务器。这个新的时间事件将在当前时间的milliseconds毫秒之后到达,而事件处理器为proc。

ae.c/aeDeleteFileEvent函数接受一个时间事件ID作为参数,然后从服务器中删除该ID作为对应的时间事件。

ae.c/aeSearchNearestTimer函数返回到达时间距离当前时间最接近的那个时间事件。

ae.c/processTimeEvents函数是时间事件的执行器,这个函数会遍历所有已到达的时间事件,并调用这些事件的处理器。已到达的是指,时间事件when属性记录的UNIX时间戳等于或小于当前时间的UNIX时间戳。

processTimeEvents的伪代码:

python 复制代码
def processTimeEvents():
    #遍历服务器中的所有时间事件
    for time_event in all_time_event():
        #检查事件是否已经达到
        if time_event.when <= unix_ts_now():
            
            #事件已经到达,执行时间事件处理器
            #获取返回值
            ret_val = time_event.timeProc()

            #如果这个事件是一个定时事件
            if ret_val == AE_NOMORE:
                #将该事件从服务器中删除
                delete_time_event_from_server(time_event)

            else:
                #周期性事件
                #更新when属性
                update_when(time_event, retval)

2.3 时间事件应用实例:serverCron函数

持续运行的Redis服务器需要定期对自身的资源和状态进行检查和调整。从而确保服务器可以长期,稳定的运行。这些定期操作由redis.c/serverCron函数负责执行,它的主要工作包括:

  • 更新服务器的各类统计信息,比如时间,内存占用,数据库占用情况等。
  • 清理数据库中的过期键值对。
  • 关闭和清理连接失效的客户端。
  • 尝试进行AOF或RDB持久化操作。
  • 如果服务器是主服务器,那么对从服务器进行定期同步。
  • 如果处于集群模式,对集群进行定期同步和连接测试。

Redis服务器以周期性事件的方式来运行serverCron函数,在服务器运行期间,每隔一段时间,serverCron就会执行一次,直到服务器关闭为止。

在Redis2.6版本,服务器默认规定serverCron每秒运行10次,平均每隔100毫秒运行一次。

从Redis2.8版本,用户可以通过修改hz选项调整serverCron的每秒执行次数,具体信息参看配置文件redis.conf关于hz选项的说明。

三. 事件的调度与执行

因为服务器中同时存在文件事件和时间事件两种事件类型,所以服务器必须对这两种事件进行调度,决定何时处理文件事件,何时处理时间事件,以及花多少时间来处理它们等。

事件的调度和执行有ae.c/aeProcessEvent函数负责,以下是该函数的伪代码:

python 复制代码
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()
    

注意:

前面在介绍文件事件的API的时候,并没有讲到processFileEvents这个函数,因为它并不存在,在实际中,处理文件事件的代码是直接写在aeProcessEvents函数里面的。

将aeProcessEvents函数置于一个循环里面,加上初始化和清理函数,这就构成了Redis服务器的主函数,以下是伪代码:

python 复制代码
def main():

        #初始化服务器
        init_server()

        #一直处理事件,直到服务器关闭为止
        while server_is_not_shutdown():
                aeProcessEvents()

        #服务器关闭,执行清理操作
        clean_server()

从事件处理角度来看,Redis服务器的运行流程可以用下面的流程图来概括:

以下是事件调度和执行规则:

  • aeApiPoll函数的最大阻塞时间由到达时间最接近当前时间事件决定,这个方法既可以避免服务器对时间事件进行频繁的轮询等待,可以确保aeApiPoll函数不会阻塞过长时间。
  • 因为文件事件是随机出现的,如果等待并处理完一次文件事件之后,仍未有任何时间事件到达,那么服务器将再次等待并处理文件事件,随着文件事件的不断执行,时间会逐渐向时间事件所设置的时间逼近,最终达到到达时间,这时服务器就可以开始处理到达的时间事件。
  • 对文件事件和时间事件的处理都是同步,有序和原子的,服务器不会中途中断事件处理,也不会抢占事件。所以,不管是文件事件处理器,还是时间事件处理器,都会尽可能地减少程序地阻塞时间,并在有需要时让出执行权,从而减低造成事件饥饿地可能性。
    • 比如:在命令回复处理器将一个命令回复写入到客户端套接字时,如果写入字节超过了一个预设值,命令回复处理器就会主动用break跳出循环,将余下的数据留到下次再写,另外,时间事件也会将非常耗时的持久化操作放到子线程或者子进程中执行。
  • 因为时间事件在文件事件后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间,通常会比时间事件设定的到达时间晚。

事件调度例子:

  • 因为时间事件尚未到达,所以在处理时间事件之前,已经处理了两次文件事件。
  • 因为处理事件过程中不会出现抢占,所以实际处理时间事件的时间比设定的时间慢。
相关推荐
C++忠实粉丝1 小时前
Redis 介绍和安装
数据库·redis·缓存
ClouGence2 小时前
Redis 到 Redis 数据迁移同步
数据库·redis·缓存
苏三说技术2 小时前
Redis 性能优化的18招
数据库·redis·性能优化
Tttian6222 小时前
基于Pycharm与数据库的新闻管理系统(2)Redis
数据库·redis·pycharm
言之。3 小时前
redis延迟队列
redis
hanbarger4 小时前
nosql,Redis,minio,elasticsearch
数据库·redis·nosql
弗罗里达老大爷4 小时前
Redis
数据库·redis·缓存
DT辰白19 小时前
基于Redis的网关鉴权方案与性能优化
数据库·redis·缓存
木子七20 小时前
Redis-十大数据类型
redis
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭1 天前
聊聊volatile的实现原理?
java·jvm·redis