Redis之线程模型

1、前言
 
Redis4.0版本之后开始使用多线程,之前使用的是单线程。无论是使用单线程模型还是多线程模型,这两个设计上的决定都是为了更好地提升 Redis 的开发效率、运行性能。虽然 Redis 在较新的版本中引入了多线程,不过是在部分命令上引入的,其中包括非阻塞的删除操作,在整体的架构设计上,主处理程序还是单线程模型的。
 
2、线程模型
 
Redis 基于 Reactor 模式开发了自己的网络事件处理器: 这个处理器被称为文件事件处理器(file event handler),由套接字、I/O多路复用程序、文件事件分派器(dispatcher),事件处理器四部分组成。
 
文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
 
虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。
 
Redis服务端通过Socket与客户端连接。每当一个套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个套接字, 所以多个文件事件有可能会并发地出现。比如,当socket变得可读时,socket就会产生一个 AE_READABLE事件;当socket变得可写的时候,socket会产生一个 AE_WRITABLE事件。
 
2.1、文件事件处理器的构成
 
文件事件处理器的四个组成部分, 它们分别是套接字、 I/O 多路复用程序、 文件事件分派器(dispatcher)、 以及事件处理器。
文件事件是对套接字操作的抽象, 每当一个套接字准备好执行连接应答(accept)、写入、读取、关闭等操作时, 就会产生一个文件事件。 因为一个服务器通常会连接多个套接字, 所以多个文件事件有可能会并发地出现。
 
I/O 多路复用程序负责监听多个套接字, 并向文件事件分派器传送那些产生了事件的套接字。
 
尽管多个文件事件可能会并发地出现, 但 I/O 多路复用程序总是会将所有产生事件的套接字都入队到一个队列里面, 然后通过这个队列, 以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字: 当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕), I/O 多路复用程序才会继续向文件事件分派器传送下一个套接字。
 
文件事件分派器接收 I/O 多路复用程序传来的套接字, 并根据套接字产生的事件的类型, 调用相应的事件处理器。服务器会为执行不同任务的套接字关联不同的事件处理器, 这些处理器是一个个函数, 它们定义了某个事件发生时, 服务器应该执行的动作。
 
 
2.2、I/O 多路复用程序的实现
 
Redis 的 I/O 多路复用程序的所有功能都是通过包装常见的 select 、 epoll 、 evport 和 kqueue 这些 I/O 多路复用函数库来实现的, 每个 I/O 多路复用函数库在 Redis 源码中都对应一个单独的文件, 比如 ae_select.c 、 ae_epoll.c 、 ae_kqueue.c , 诸如此类。因为 Redis 为每个 I/O 多路复用函数库都实现了相同的 API , 所以 I/O 多路复用程序的底层实现是可以互换的, 如图所示。
Redis 在 I/O 多路复用程序的实现源码中用 #include 宏定义了相应的规则, 程序会在编译时自动选择系统中性能最高的 I/O 多路复用函数库来作为 Redis 的 I/O 多路复用程序的底层实现:
/* 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
 
2.3、事件的类型与调度
 
服务器需要处理两类事件:文件事件,时间事件。
 
(1)文件事件:Redis服务器对套接字的操作,当一个套接字准备执行连接、读、写、关闭等操作时就会产生一个文件事件。文件事件分为AE_READABLE和AE_WRITABLE两类。
  • 当套接字变得可读时(客户端对套接字执行 write 操作,或者执行 close 操作), 或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行 connect 操作), 套接字产生AE_READABLE事件。
  • 当套接字变得可写时(客户端对套接字执行 read 操作), 套接字产生AE_WRITABLE事件。
 
I/O 多路复用程序允许服务器同时监听套接字的AE_READABLE事件和AE_WRITABLE事件, 如果一个套接字同时产生了这两种事件, 那么文件事件分派器会优先处理AE_READABLE事件, 等到 AE_READABLE事件处理完之后, 才处理AE_WRITABLE事件。也就是说, 如果一个套接字又可读又可写的话, 那么服务器将先读套接字, 后写套接字。
 
(2)时间事件:Redis服务器中一些需要在给定时间点执行的操作。
 
服务器将所有时间事件放在一个无序链表中,每当时间事件执行器运行时,它就会遍历整个链表,查找所有已经到达的时间事件,并调用相应的事件处理器。Redis服务器一般情况下只执行serverCron函数一个时间事件,通过redis.c/serverCron函数定期对自身的资源和状态进行检查和调整,主要工作包括:
  • 更新服务器的各类统计信息,如时间、内存占用、数据库占用情况等。
  • 清理数据库中的过期键值对。
  • 关闭和清理连接失效的客户端。
  • 尝试进行AOF或RDB持久化操作。
  • 如果服务器是主服务器,那么对从服务器进行定期同步。
  • 如果处于集群模式,对集群进行定期执行同步和连接测试。
  • Redis2.6版本,服务器默认serverCron每秒运行10次,平均每隔100毫秒运行一次。Redis2.8版本之后,通过hz调整每秒执行次数。
(3)因为服务器中同时存在文件事件和时间事件,所以服务器必须对这两种事件进行调度,事件的调度和执行由ae.c/aeProcessEvents函数负责,逻辑如下:
先计算最近的时间事件距离到达还有多少毫秒remaind_ms,根据这个值阻塞并等待文件事件产生,remaind_ms<=0不阻塞,阻塞期间会不断处理出现的文件事件。当时间事件最终到达时,服务器才会开始处理达到的时间事件。
注意:对文件事件和时间事件的处理都是同步、有序、原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占,因此,不管是文件事件的处理器,还是时间事件的处理器,它们都会尽可能减少程序的阻塞时间,并在有需要时主动让出执行权。因为时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间通常会比设定的到达时间晚一些。
 
如图,在时间事件到达前(100ms),服务器已经等待并处理了两次文件事件,又因为处理事件的过程中不会出现抢占,所以实际处理时间事件的时间比预计慢了30毫秒。
 
2.4、API
ae.c/aeCreateFileEvent 函数接受一个套接字描述符、 一个事件类型、 以及一个事件处理器作为参数, 将给定套接字的给定事件加入到 I/O 多路复用程序的监听范围之内, 并对事件和事件处理器进行关联。
ae.c/aeDeleteFileEvent 函数接受一个套接字描述符和一个监听事件类型作为参数, 让 I/O 多路复用程序取消对给定套接字的给定事件的监听, 并取消事件和事件处理器之间的关联。
ae.c/aeGetFileEvents 函数接受一个套接字描述符, 返回该套接字正在被监听的事件类型:
  • 如果套接字没有任何事件被监听, 那么函数返回 AE_NONE 。
  • 如果套接字的读事件正在被监听, 那么函数返回 AE_READABLE 。
  • 如果套接字的写事件正在被监听, 那么函数返回 AE_WRITABLE 。
  • 如果套接字的读事件和写事件正在被监听, 那么函数返回 AE_READABLE | AE_WRITABLE 。
ae.c/aeWait 函数接受一个套接字描述符、一个事件类型和一个毫秒数为参数, 在给定的时间内阻塞并等待套接字的给定类型事件产生, 当事件成功产生, 或者等待超时之后, 函数返回。
ae.c/aeApiPoll 函数接受一个 sys/time.h/struct timeval 结构为参数, 并在指定的时间內, 阻塞并等待所有被 aeCreateFileEvent 函数设置为监听状态的套接字产生文件事件, 当有至少一个事件产生, 或者等待超时后, 函数返回。
ae.c/aeProcessEvents 函数是文件事件分派器, 它先调用 aeApiPoll 函数来等待事件产生, 然后遍历所有已产生的事件, 并调用相应的事件处理器来处理这些事件。
ae.c/aeGetApiName 函数返回 I/O 多路复用程序底层所使用的 I/O 多路复用函数库的名称: 返回 "epoll" 表示底层为 epoll 函数库, 返回"select" 表示底层为 select 函数库, 诸如此类。
 
2.5、事件的处理器
 
Redis 为文件事件编写了多个处理器, 这些事件处理器分别用于实现不同的网络通讯需求, 比如说:
  • 为了对连接服务器的各个客户端进行应答, 服务器要为监听套接字关联连接应答处理器。
  • 为了接收客户端传来的命令请求, 服务器要为客户端套接字关联命令请求处理器。
  • 为了向客户端返回命令的执行结果, 服务器要为客户端套接字关联命令回复处理器。
  • 当主服务器和从服务器进行复制操作时, 主从服务器都需要关联特别为复制功能编写的复制处理器。
在这些事件处理器里面, 服务器最常用的要数与客户端进行通信的连接应答处理器、 命令请求处理器和命令回复处理器。
 
2.6、连接应答处理器
 
networking.c/acceptTcpHandler 函数是 Redis 的连接应答处理器, 这个处理器用于对连接服务器监听套接字的客户端进行应答, 具体实现为sys/socket.h/accept 函数的包装。
 
当 Redis 服务器进行初始化的时候, 程序会将这个连接应答处理器和服务器监听套接字的 AE_READABLE 事件关联起来, 当有客户端用sys/socket.h/connect 函数连接服务器监听套接字的时候, 套接字就会产生 AE_READABLE 事件, 引发连接应答处理器执行, 并执行相应的套接字应答操作, 如图所示。
2.7、命令请求处理器
 
networking.c/readQueryFromClient 函数是 Redis 的命令请求处理器, 这个处理器负责从套接字中读入客户端发送的命令请求内容, 具体实现为 unistd.h/read 函数的包装。
当一个客户端通过连接应答处理器成功连接到服务器之后, 服务器会将客户端套接字的 AE_READABLE 事件和命令请求处理器关联起来, 当客户端向服务器发送命令请求的时候, 套接字就会产生 AE_READABLE 事件, 引发命令请求处理器执行, 并执行相应的套接字读入操作, 如图所示。
在客户端连接服务器的整个过程中, 服务器都会一直为客户端套接字的 AE_READABLE 事件关联命令请求处理器。
 
2.8、命令回复处理器
networking.c/sendReplyToClient 函数是 Redis 的命令回复处理器, 这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端, 具体实现为 unistd.h/write 函数的包装。
当服务器有命令回复需要传送给客户端的时候, 服务器会将客户端套接字的 AE_WRITABLE 事件和命令回复处理器关联起来, 当客户端准备好接收服务器传回的命令回复时, 就会产生 AE_WRITABLE 事件, 引发命令回复处理器执行, 并执行相应的套接字写入操作, 如图所示。
当命令回复发送完毕之后, 服务器就会解除命令回复处理器与客户端套接字的 AE_WRITABLE 事件之间的关联。
  • 注意1:只有当上一个套接字产生的事件被所关联的事件处理器执行完毕,I/O多路复用程序才会继续向文件事件分派器传送下一个套接字,所以对每个命令的执行时间是有要求的,如果某个命令执行过长,会造成其他命令的阻塞。所以慎用O(n)命令,Redis是面向快速执行场景的数据库。
  • 注意2:命令的并发性。Redis是单线程处理命令,命令会被逐个被执行,假如有3个客户端命令同时执行,执行顺序是不确定的,但能确定不会有两条命令被同时执行,所以两条incr命令无论怎么执行最终结果都是2。
综上:
Redis服务启动初始化的时候,Redis会将【连接应答处理器】跟 AE_READABLE事件关联起来;
 
如果一个Redis客户端发起连接请求,此时会产生一个 AE_READABLE事件,然后由【连接应答处理器】来处理跟客户端建立连接,创建客户端对应的socket,同时将这个socket的 AE_READABLE事件跟【命令请求处理器】关联起来;
 
当客户端向Redis发起命令请求时,首先就会在socket产生一个 AE_READABLE事件,然后由对应的【命令请求处理器】来处理。这个【命令请求处理器】就会从socket中读取请求相关数据,然后进行执行和处理;
 
当Redis准备好了给客户端的响应数据后,就会将socket的 AE_WRITABLE事件跟【命令回复处理器】关联起来,当客户端准备好读取响应数据时,就会在socket上产生一个 AE_WRITABLE事件,对应的【命令回复处理器】会将准备好的响应数据写入socket,供客户端来读取;
 
【命令回复处理器】写完之后,就会删除这个socket的 AE_WRITABLE事件和【命令回复处理器】的关联关系。
 
3、一次完整的客户端与服务器连接事件示例
 
让我们来追踪一次 Redis 客户端与服务器进行连接并发送命令的整个过程, 看看在过程中会产生什么事件, 而这些事件又是如何被处理的。
  1. 假设一个 Redis 服务器正在运作, 那么这个服务器的监听套接字的 AE_READABLE 事件应该正处于监听状态之下, 而该事件所对应的处理器为连接应答处理器。
  2. 如果这时有一个 Redis 客户端向服务器发起连接, 那么监听套接字将产生 AE_READABLE 事件, 触发连接应答处理器执行: 处理器会对客户端的连接请求进行应答, 然后创建客户端套接字, 以及客户端状态, 并将客户端套接字的 AE_READABLE 事件与命令请求处理器进行关联, 使得客户端可以向主服务器发送命令请求。
  3. 之后, 假设客户端向主服务器发送一个命令请求, 那么客户端套接字将产生 AE_READABLE 事件, 引发命令请求处理器执行, 处理器读取客户端的命令内容, 然后传给相关程序去执行。
  4. 执行命令将产生相应的命令回复, 为了将这些命令回复传送回客户端, 服务器会将客户端套接字的 AE_WRITABLE 事件与命令回复处理器进行关联: 当客户端尝试读取命令回复的时候, 客户端套接字将产生 AE_WRITABLE 事件, 触发命令回复处理器执行, 当命令回复处理器将命令回复全部写入到套接字之后, 服务器就会解除客户端套接字的 AE_WRITABLE 事件与命令回复处理器之间的关联。
 
4、执行命令请求过程
 
对于每个与服务器进行连接的客户端,服务器都为这些客户端建立了相应的redis.h/redisClient结构(客户端状态)。
 
Redis服务器状态结构的clients属性是一个链表,这个链表保存了所有与服务器连接的客户端的状态结构。对客户端执行批量操作,或者查找某个指定的客户端,都可以通过遍历clients链表来完成。
//客户端状态的关键属性:
typedef struct redisClient {
    //……
    sds querybuf;
    robj **argv;
    int argc;
    struct redisCommand *cmd;
    char buf[REDIS_REPLY_CHUNK_BYTES];
    int bufpos;
    list *reply;
    //……
} redisClient;
 
一个命令请求从发送到获得回复的过程:
 
1、发送命令请求:
 
当客户端使用connect函数连接到服务器时,服务器就会调用连接事件处理器,为客户端创建相应的客户端状态,并将这个新的客户端状态添加到服务器状态结构clients链表的末尾。
当用户在客户端中键入一个命令请求时,客户端会将这个命令请求转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器。
 
2、读取命令请求:
 
当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:
 
  • 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区querybuf。
  • 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的argv和argc属性。
  • 调用命令执行器,执行客户端指定的命令。
 
3、执行命令
 
  • 命令执行器要做的第一件事就是根据客户端状态的argv[0]参数,在命令表中查找参数所指定的命令,并将找到的命令保存到客户端状态的cmd属性中。
  • 到目前为止,服务器已经将执行命令所需的命令实现函数(cmd)、参数(argv)、参数个数(argc)都收集齐了,但在真正执行命令前,程序还需要进行一些预备操作,从而确保命令可以正确、顺利地被执行:检查客户端状态的cmd指针是否执行NULL、检查命令请求所给定的参数个数是否正确、检查客户端是否已经通过了身份验证...
  • 被调用的命令实现函数会执行指定的操作,并产生相应的命令回复,这些回复会被保存在客户端状态的输出缓冲区中(buf属性和reply属性),之后实现函数还会为客户端的套接字关联命令回复处理器,这个处理器负责将命令回复返回给客户端。
4、在执行完实现函数之后,服务器还需要执行一些后续工作:
 
  • 如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志。
  • 根据刚刚执行命令所耗费的时长,更新被执行命令的redisCommand结构的milliseconds属性,并将命令的redisCommand结构的calls计数器的值加1。
  • 如果服务器开启AOF持久化功能,那么AOF持久化模块会将刚刚执行的命令请求写入到AOF缓冲区。
  • 如果有其他服务器正在复制当前这个服务器,那么服务器会将刚刚执行的命令传播给所有从服务器。
当以上操作都执行完之后,服务器对当前命令的执行到此告一段落,之后服务器就可以继续从文件事件处理器中取出并处理下一个命令请求了。
 
5、将命令回复发送给客户端
 
  • 当客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。当命令回复发送完毕之后,回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备。
  • 当客户端接收到协议格式的命令回复之后,它会将这些回复转换成人们可以识别的可读的格式。
补充:
querybuf最大不能超过1GB。
argv属性是一个数组,其中argv[0]是要执行的命令,而之后的其他项则是传给命令的参数。argc属性负责记录argv数组的长度。
命令表是一个字典,字典的键是一个个命令名字,比如"set"、"get"、"del"等等;而字典的值则是一个个redisCommand结构体,每个redisCommand结构体记录了一个Redis命令的实现信息,包括命令的实现函数、命令的标志、命令应该给定的参数个数、命令的总执行次数和总消耗时长等统计信息。命令表使用的时大小写无关的查找算法。
buf是一个大小为REDIS_REPLY_CHUNK_BYTES字节的字节数组,而bufpos属性则记录了buf数组目前已使用的字节数量。REDIS_REPLY_CHUNK_BYTES常量目前的默认值为16*1024,也即是说,buf数组的默认大小为16KB。当buf数组的空间已经用完,或者回复因为太大而无法装进buf数组里面,服务器就会开始使用可变大小缓冲区reply,可变大小缓冲区是链表结构。
服务器处理完客户端的命令请求后,命令回复只是暂时缓存在client结构体的buf缓冲区,待客户端的可写事件发生时,才会真正往客户端发送命令回复。
 
 
5、初始化服务器
 
1、初始化服务器状态
 
初始化服务器的第一步就是创建一个struct redisServer类型的实例变量server作为服务器的状态,并为结构中的各个属性设置默认值。由redis.c/initServerConfig函数完成。
 
2、载入服务器配置
 
服务器在用initServerConfig函数初始化完server变量之后,就会开始载入用户给定的配置参数和配置文件,并根据用户设定的配置,对server变量相关属性的值进行修改。
 
  • 如果用户为这些属性的相应选项指定了新的值,那么服务器就使用用户指定的值来更新相应的属性。
  • 如果用户没有为属性的相应选项设置新值,那么服务器就沿用为initServerConfig函数为属性设置的默认值。
 
3、初始化服务器数据结构
 
调用initServer函数初始化数据结构。打开服务器的监听端口,并为监听套接字关联连接应答事件处理器,等待服务器正式运行时接受客户端的连接。为serverCron函数创建时间事件,等待服务器正式运行时执行serverCron函数。如果AOF持久化功能已经打开,那么打开现有的AOF文件,如果AOF文件不存在,那么创建并打开一个新的AOF文件,为AOF写入做好准备。
 
4、还原数据库状态
 
在完成了对服务器状态server变量的初始化之后,服务器需要载入RDB文件或AOF文件,并根据文件记录的内容来还原服务器的数据库状态。
 
  • 如果服务器启用了AOF持久化功能,那么服务器使用AOF文件来还原数据库状态
  • 相反地,如果服务器没有启用AOF持久化功能,那么服务器使用RDB文件来还原数据库状态
 
当服务器完成数据库状态还原工作之后,服务器将在日志中打印出载入文件并还原数据库状态所耗费的时长:
 
[5244] 21 Nov 22:43:49.084 * DB loaded from disk: 0.068 seconds
 
5、执行事件循环
 
在初始化最后一步,服务器将打印出日志:
 
[5244] 21 Nov 22:43:49.084 * The server is now ready to accept connections on port 6379
 
并开始执行服务器的事件循环(loop)。至此,服务器的初始化工作圆满完成,服务器现在开始可以接受客户端的连接请求,并处理客户端发来的命令请求了。
补充:
当服务器接收到一条命令请求时,需要从命令表中查找命令,而redisCommandTable命令表是一个数组,意味着查询命令的时间复杂度为O(N),效率低下。因此Redis在服务器初始化时,会将redisCommandTable转换为一个字典存储在redisServer对象的commands字段,key为命令名称,value为命令redisCommand对象。
 
6、单线程与高并发
 
为什么Redis使用单线程模型会达到每秒万级别的处理能力呢?
 
第一,纯内存访问,Redis将所有数据放在内存中,内存的响应时长大约为100纳秒,这是Redis达到每秒万级别访问的重要基础。
第二,非阻塞I/O多路复用的实现。
第三,单线程避免了线程切换和竞态产生的消耗。
 
一、二是重点,非阻塞是指I/O多路复用程序监听到事件不处理就直接压到队列里,让事件处理器去处理,又因为是内存访问,效率很高。 如果是阻塞I/O,要等一个请求处理完得到响应才能处理下一个请求。
 
 
 
 
posted @ 2021-11-27 22:07  jrliu  阅读(425)  评论(0编辑  收藏  举报