vlambda博客
学习文章列表

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)


本篇文章大概6000字,阅读时间大约10分钟


本文主要是对Redis的I/O模型,或者说线程模型进行了一个拆解,并且结合核心源码做了一些分析,希望能和Netty对比着看,彻底掌握这种设计思想。

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

接上篇,继续拆解Redis服务端的入口函数,前面提到了它的initServer函数,目的是初始化服务器,比如数据库,集群,持久化等机制的初始化,在I/O模型的初始化流程里,即先创建内核的事件表,然后打开服务端套接字,绑定并监听固定端口后,会为服务端套接字关联新连接接入事件,并且绑定回调函数,这些回调函数就是前面说的Redis的事件处理器。到此,初始化服务器就完成了。接着,继续看redis.c的main函数。如下,最后会调用aeMain函数,启动服务器的事件循环机制,它和Netty的EventLoop结构如出一辙:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

可以看到上面黄色处的aeMain函数,它是启动I/O多路复用器的事件循环的入口,在这之前,还会调用aeSetBeforeSleepProc函数:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

其中的beforesleep参数是传入的一个函数,aeSetBeforeSleepProc函数里还有一个->符号,它用于访问eventLoop这个结构体。前面提到过在initServer时,会创建这个Redis的事件循环辅助结构eventLoop,如下:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

后续会反复出现,这里提前给出。


继续看aeSetBeforeSleepProc中的beforesleep函数,它被保存到了eventLoop结构体,如下是beforesleep的实现源码:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

注释也写到,每次运行事件循环之前都会调用该函数一次,一方面是为了持久化,最后一行是在做磁盘的增量持久化,AOF(append only file)是Redis的增量持久化机制,该机制会额外使用一个后台线程去处理,所以有人说Redis是单线程的其实是不准确的,只能说它的事件循环是单线程的,就和Netty是一样的道理,并且最新版的Redis也额外增加了一些命令的异步处理策略,同样会利用多线程。除了AOF外,该函数另一方面是处理设置为非阻塞模式的客户端的套接字里剩余的(缓存)数据,这些数据就是用户发送的Redis命令,核心是调用上图的processInputBuffer函数去执行这些命令,不用细究,还是抓主要矛盾。


继续看aeMain函数启动的事件循环机制:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

看到这段源码,应该是非常熟悉了,和Netty的run实现大同小异,同样写在一个“死”循环里,除非外界主动关闭或者自己宕机,否则它会一直循环下去。期间每次循环开始之前调用一次beforesleep函数,顾名思义,联系Netty的事件循环机制就非常清楚了,不论它怎么秀,循环的中途一定会调用阻塞的类似JDK Selector的select(time)函数去检测套接字有无就绪的I/O事件发生,所以它的名字是sleep之前。前面也简单提过,该函数一方面是做增量持久化(AOF),一方面是处理刚刚解除阻塞的客户端套接字的数据。而这个eventLoop前面也提过,是在redis.c的main函数调用initServer()函数时,在其内部创建的事件循环数据结构:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

以上,可以看到,Redis用events这个数组保存已经注册,需要监听的文件事件,fired数组保存已经就绪的文件事件,而Redis的定时任务的组织被实现为了单向链表结构(Netty是用的自定义的优先级队列,Redis后续版本也升级为了优先级队列),上面的timeEventHead就是定时任务链表的头结点,其它的就不多说了,都有注释。了解了以上背景,下面开始重点看Redis事件循环的主处理函数。如下,while循环的第二块儿逻辑——aeProcessEvents(eventLoop, AE_ALL_EVENTS)

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

它通过参数AE_ALL_EVENTS标明默认处理所有类型的事件,这里的事件自然就包括了文件事件——Socket的可读可写事件的监听与处理,和时间事件——到达了deadline的定时任务。该函数返回的是已经处理的事件的数量,如下它的源码,比较长,先看前面:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

上面,开头会做一个判断,即当前如果没有任何事件需要处理,那么就立即返回。否则如下,会先判断有没有时间事件。。。看到这里不禁一句卧槽——这和Netty的处理机制一模一样,深刻理解和掌握了Netty的实现,Redis的就扫一眼就行,面试就有b可以吹了:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

以上流程简单看,如果判断当前有时间事件存在,那么就在黄色1和2处,获得链表里的第一个定时任务的deadline和取出现在的时间,然后判断I/O多路复用器的阻塞时间。判断方法仍然是用定时任务的deadline减去当前时间,作为I/O多路复用器监听的超时时间,这里Netty做的更细致,有一个0.5ms的判断以及外部线程的唤醒机制的处理,具体可以回忆文章



不妨偷偷看一下Redis处理定时任务的机制,如下是黄色1处调用的aeSearchNearestTimer函数:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

以上函数的黄色方块逻辑的目的是寻找目前截止时间最近的定时任务,我看的Redis版本比较老,定时任务竟然tmd用普通链表组织,查找时间复杂度是O(n),这里就不如Netty的设计了。带着不可思议的困惑,我后续查找了一些资料,发现新版的Redis已经将其改为了最小堆,其实本质就是一个优先级队列。


继续回头看事件循环的第二块儿逻辑——aeProcessEvents函数的剩余的部分:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

以上大体内容都注释了,即如果当前没有定时任务,那么就直接处理文件事件即可,通过tvp参数控制。


继续看下面的部分,开始处理文件事件了,首先是黄色1处,调用函数aeApiPoll,顾名思义就是调用系统的I/O多路复用器去检测套接字,它的参数tvp是前面结合定时任务的deadline和当前时间,计算得到的阻塞时间:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

看黄色1处调用的aeApiPoll函数源码,它的实现就和Redis在编译时选择的I/O多路复用器的库函数来自什么系统有关了,一般Redis部署在Linux服务器下,所以这里只看epoll的实现,即在文件ae_epoll.c里面被实现:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

一看到红色线标记的epoll_wait函数,我就乐了。。。又是一句卧槽行天下,不多说了,关于epoll机制,主要是它的三个函数,以及两类工作模式,可以参考前面的文章。简单说下,即首先拿到辅助事件循环的结构体eventLoop里的apidata字段,该字段的类型aeApiState也是一个结构体,通过state变量保存:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

里面主要存的是epoll的句柄epfd,以及保存就绪的套接字的fd集合。然后调用epoll_wait函数,通过apidata结构体的events字段(本质是一个数组)传入,来准备接收就绪的套接字的fd集合,接着epoll_wait函数阻塞,直到要么超时,要么有I/O事件被触发,epoll_wait函数返回,此时apidata结构体的events字段里可能就会有就绪的套接字fd了,然后Redis在一个for循环里再次遍历所有的已经就绪的fd,将它们存储到eventLoop结构体的fired字段,该字段也是一个数组,数组类型仍然是一个结构体——aeFiredEvent,里面的字段mask代表就绪的事件的类型,fd是就绪事件的文件描述符。这里再重点看下eventLoop结构体里的apidata字段:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

apidata字段的值在函数aeCreateEventLoop里面初始化,该函数在Redis服务端main函数里的initServer函数里已经被调用了,回忆函数aeCreateEventLoop(int setsize)片段,它内部通过调用aeApiCreate函数设置eventLoop结构体里的apidata字段,为其分配内存,并且保存了epoll的句柄efpd

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)


再次回到事件循环的aeProcessEvents函数,前面分析总结了黄色1的逻辑——调用epoll_wait函数监听是否有就绪的套接字,并且将就绪的套接字的fd保存到结构体eventLoop的fired数组,下面接着看后面的逻辑:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

以上,黄色代码1返回的是已经就绪的文件事件的数量,然后Redis额外用一个循环遍历每个事件。在黄色2处,通过eventLoop的events字段,结合eventLoop的fired字段,拿到一个就绪的文件事件类型,这个类型也是Redis自定义的,本质是events这个数组字段的元素类型——文件事件结构类型,其结构体aeFileEvent如下被定义:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

看到这里就豁然开朗,该结构体封装了需要回调的用户的读写函数,里面的两个读写事件的函数是两个函数指针,前面也简单阐述过函数指针的用法和概念。它们分别指向了读事件处理的函数和写事件处理的函数,而clientData指针指向了对应的客户端句柄。所以,回到aeProcessEvents函数,上面在处理就绪的I/O事件时,会在黄色代码3和4处,使用这两个指针,去回调对应客户端发来的的读写指令。并且也能验证前面的一个结论,Redis也是读优先的服务器,和Netty一样。

 

接着,看aeProcessEvents函数的最后一部分,即前面的I/O事件处理完毕后,会恰到好处执行到点儿的定时任务,这里和Netty的处理流程一样,只不过Netty是一个通用的服务器,所以还额外会处理普通的异步任务(非定时任务):

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

到这里aeProcessEvents函数终于分析完了,它在return后,紧接着在外围while里又是一轮新的循环。。。直到被外界停止,或者Redis自己宕机。


以上,就是整个Redis的事件循环要做的事情,可以看到和Netty如出一辙。综上,也可以得知Redis的线程模型和Netty的线程模型同宗同源,我不好说是不是抄袭(借鉴),当然这也没什么丢人或者不好的,但是感觉也不至于谁抄谁,因为道理就是:只要你对某个语言掌握的很深,加上理解了这种高性能服务器的设计思想以及操作系统I/O模型,实现一个类似的服务器是一个水到渠成的事情,包括Tomcat8以后也是一样的套路,只不过它用JDK的NIO结合了Servlet标准,在通用的服务器之上,实现了一个Java Web的容器。

 

做个小结,在一个循环里做三件事

1、依赖操作系统的I/O多路复用模型,监听是否有I/O事件就绪的套接字

2、同时处理I/O事件,以及定时任务

3、对耗时的指令(比如AOF等),仍然会使用额外的非I/O线程去异步处理

 

和上篇开头呼应,再回看这个图:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

Redis的事件循环里的aeProcessEvents函数,就是网上有些人说的文件事件分发器。。。这名字。。。而事件处理器就在前面eventLoop结构体的events数组里,该数组元素类型是结构体aeFileEvent,它是Redis自定义的文件事件类型,里面有两个函数指针,会绑定用户或者服务本身的回调函数,前面刚开始分析redis.c中初始化服务器的initServer函数时,提到了initServer函数会为eventLoop结构体注册接收处理新客户端连接的回调函数acceptTcpHandler,这些回调函数就被关联到了aeFileEvent里的函数指针,而aeFileEvent被组织为了一个数组,在eventLoop结构体里表现为events字段。


所以开头的图,蓝色的事件处理器本质就是eventLoop里的函数指针关联的回调函数,它们是和事件类型一一对应,可以是新连接接收处理器,可以是处理客户端发来的命令的处理器,可以是回复客户端命令执行结果的处理器,还可以是进行主从复制的复制处理器,等等吧。


简单的说,Redis除了服务器还有客户端程序,用户启动了客户端后与服务端建立TCP连接,这个客户端新连接接收的过程,就是前面刚刚分析的acceptTcpHandler回调函数实现的,它是其中一个事件处理器,如下被实现在了networking.c文件,该文件里定义的都是Redis的网络相关的事件处理器函数:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

其中通过函数anetTcpAccept去accept新连接,该过程不会发生无意义的阻塞,最终调用如下函数:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

获取到客户端的套接字fd后,记录日志,同时创建服务器里的客户端处理程序,准备处理后续该套接字上的命令收发。如下,即acceptTcpHandler的最后一行——调用函数acceptCommonHandler(cfd,0)实现,期间会对服务器的处理能力进行评估,如果超过了最大可打开客户端数量,那么就强制关闭并做健壮性处理:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

而对客户端套接字的命令处理的本质是acceptCommonHandler函数里的createClient函数实现:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

服务器会根据客户端的类型,去创建不同的处理程序,根据fd属性的值来判断,fd可以是-1或者是大于-1的整数。如果是套接字连接的客户端,fd一定不是-1,只有伪客户端(fake client)的fd属性的值为-1,伪客户端处理的命令请求来源于AOF或者Redis的Lua的脚本,而不是网络套接字,这里知道即可。如果新进来的客户端套接字是网络套接字,那么就将其设置为非阻塞,并且为其关联文件事件,通过黄色代码1处的函数——aeCreateFileEvent完成,这里也是代码的复用,前面服务器启动时也会调用它,不再多说,对于客户端接入后是为其注册I/O读事件,同时注册回调函数——readQueryFromClient,即黄色2处代码,它在networking.c文件里实现,该函数就是客户端指令的事件处理器。


后续,用户输入Redis的命令,比如set dashuai 111,客户端接收用户的命令请求,对该命令按照Redis指定的协议进行编码,接着转发该请求给服务端。而前面总结了服务端I/O模型和Netty是一样的套路,使用I/O多路复用器,实现了单个线程维护多个客户端连接上的消息收发。故客户端转发命令请求后,服务器会检测到该客户端套接字上有可读事件就绪,服务器内部会将所有触发了I/O事件的客户端套接字按照顺序放入一个数组(eventLoop的fired字段),通过文件事件分发器顺序处理。文件事件分发器轮询到文件事件,会触发提前用函数指针注册的回调函数readQueryFromClient,即所谓的事件执行器开始运行。此时服务端开始处理这些命令请求,如下:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

以上,Redis会给每个客户端套接字都关联一个指令的请求队列——querybuf,即红色1处代码,一个客户端的所有指令会通过队列来排队进行处理,继续看readQueryFromClient函数最后一部分:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

在红色3处执行命令set dashuai 111。调用函数processInputBuffer实现,细节不看了,就是根据命令的格式分策略执行。这里重点看下客户端在服务器保存的结构体redisClient:

/* * 客户端结构 * * 为每个连接到服务器的客户端保存维持一个该结构的映射, * 从而实现多路复用。 */typedef struct redisClient { // socket 文件描述符 int fd; // 指向当前目标数据库的指针 redisDb *db; // 当前目标数据库的号码 int dictid; // 查询缓存 sds querybuf; size_t querybuf_peak; /* Recent (100ms or more) peak of querybuf size */ // 参数的个数 int argc; // 字符串表示的命令,以及命令的参数 robj **argv; // 命令,以及上个命令 struct redisCommand *cmd, *lastcmd; // 回复类型 int reqtype; int multibulklen; /* number of multi bulk arguments left to read */ long bulklen; /* length of bulk argument in multi bulk request */ // 保存回复的链表 list *reply;    。。。 还有很多,大多数都是AOF,主从复制,同步状态等相关结构} redisClient;

以上,当正常解析命令后,processInputBuffer内部最终会调用到函数processCommand:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

processCommand被实现在了redis.c文件,目的就是单纯的执行命令,而需要执行的命令,已经经过前面层层解析,被按照固定协议解码到了redisClient结构体,其中命令的类型保存到了redisClient的argv[0],命令参数保存到了argv数组后面,然后在processCommand函数里,会根据Redis的命令表,以及传入的redisClient参数,去查找argv[0]所对应的命令实现的回调函数。这里知道即可,该命令表在Redis服务器main函数里,调用initServerConfig函数时初始化的,如下:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)


下面简单总结下processCommand函数,它主要是负责执行C语言客户端的命令,执行前还会做一系列的校验,比如语法检查,权限检查,可用内存检查等等,都没问题,才会真的执行命令,同时开启AOF,慢日志,主从同步等操作。最后将命令执行结果保存到redisClient的回复缓冲队列,即可以得知:Redis也会为每个客户端套接字关联一个指令的响应队列,让Redis将指令的返回结果(ok等)通过响应队列返回。如下processCommand函数的最后一部分代码:

同宗同源——Redis线程模型源码拆解,以及和Netty的对比(下)

以上,也能知道,Redis的事务是一个队列来维护的。这里不细究,只看非事务命令,最终会执行call函数,call函数里除了执行命令set dashuai 111外,还会计算命令执行时间,决定是否添加到慢日志,还会同步该命令到从节点以及AOF,统计命令执行数据。call执行期间,会回调命令表里的一些回调函数,这里就有些越来越深入C语言的一些特性了,知道简单了解即可。回调的函数里,就包括了响应命令执行结果给客户端的函数。客户端收到响应消息后,在解码返回给用户,期间类似Netty处理NIO的一些坑,在C语言里照样有,比如不能随意注册写事件,写完必须取消注册,这些在Redis里的实现套路都是一样一样的,不多说。可以参考:


以上一个完整的Redis请求,编码,转发,处理,响应的基本流程就介绍完了。有了对Netty的线程图像,异步图像,流水线图像,以及以上Redis服务器的线程模型基本结构和业务脉络的理解,其实再去阅读Redis的一些分布式特性,内存数据库,一些数据结构等的源码,个人认为会事半功倍,速度应该很快。

 

以上, 其实并没有过多的涉及到Redis的持久化,集群,数据同步,事务等特性的拆解,这就太多内容了,完全可以搞一个新的专栏。到此为止吧,其实也不用非得去深究Redis的这些细节,因为它们往往都是一通百通,懂了一个组件(框架)的各种细节和实现原理,这是最难也是最容易出回报的一个事儿,其它组件无非就是对计算机体系里的这些特性来回封装和优化的过程而已,所以计算机基础是互联网的底层基石,有了这些基石,再加上好的业务(idea)和运营就能赚钱!所以学好操作系统,计算机网络(尤其是TCP/IP协议族),数据结构和算法等等等等,是非常有必要的。通透了一个,其它的组件就可以事半功倍,有时候一眼看去,就知道它撅什么屁股拉什么屎,前面花时间分析和掌握的东西,后面就不需要再做一遍了。不过还是计划额外总结一个对Redis的一些特性进行拆解的专栏。


以上,完。

END


点亮在看,你最好看

~

阅读原文,获得更多精彩内容